1. 新增分页接口聚合查询注解支持

2. 优化 databus api 日志记录的字段缺失问题
3. 新增 eplat sso 页面登录校验
4. 用户、部门编辑新增 seata 事务支持
5. 新增 iwork 流程发起接口
6. 新增 eban 同步用户时的岗位处理逻辑
7. 新增无 skywalking 时的 traceId 支持
This commit is contained in:
chenbowen
2025-11-18 10:03:34 +08:00
parent af7f103a38
commit 266eb45e00
74 changed files with 5001 additions and 102 deletions

View File

@@ -0,0 +1,51 @@
package com.zt.plat.module.system.controller.admin.integration.iwork;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
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 static com.zt.plat.framework.common.pojo.CommonResult.success;
/**
* 提供统一 iWork 流程能力的管理端接口。
*/
@Tag(name = "管理后台 - iWork 集成")
@RestController
@RequestMapping("/system/integration/iwork")
@RequiredArgsConstructor
@Validated
public class IWorkIntegrationController {
private final IWorkIntegrationService integrationService;
@PostMapping("/user/resolve")
@Operation(summary = "根据外部标识获取 iWork 用户编号")
public CommonResult<IWorkUserInfoRespVO> resolveUser(@Valid @RequestBody IWorkUserInfoReqVO reqVO) {
return success(integrationService.resolveUserId(reqVO));
}
@PostMapping("/workflow/create")
@Operation(summary = "发起 iWork 流程")
public CommonResult<IWorkOperationRespVO> createWorkflow(@Valid @RequestBody IWorkWorkflowCreateReqVO reqVO) {
return success(integrationService.createWorkflow(reqVO));
}
@PostMapping("/workflow/void")
@Operation(summary = "作废 / 干预 iWork 流程")
public CommonResult<IWorkOperationRespVO> voidWorkflow(@Valid @RequestBody IWorkWorkflowVoidReqVO reqVO) {
return success(integrationService.voidWorkflow(reqVO));
}
}

View File

@@ -0,0 +1,20 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* iWork 集成接口公用的请求字段。
*/
@Data
public class IWorkBaseReqVO {
@Schema(description = "配置的 iWork 凭证 appId为空时使用默认凭证", example = "iwork-app")
private String appId;
@Schema(description = "iWork 操作人用户编号", example = "1")
private String operatorUserId;
@Schema(description = "是否强制刷新 token", example = "false")
private Boolean forceRefreshToken;
}

View File

@@ -0,0 +1,23 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* 传递给 iWork 的单条明细记录。
*/
@Data
public class IWorkDetailRecordVO {
@Schema(description = "记录序号,从 0 开始", example = "0")
private Integer recordOrder;
@Schema(description = "明细字段列表")
@NotEmpty(message = "明细字段不能为空")
@Valid
private List<IWorkFormFieldVO> fields;
}

View File

@@ -0,0 +1,25 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* iWork 流程请求中的明细表定义。
*/
@Data
public class IWorkDetailTableVO {
@Schema(description = "表名", example = "formtable_main_26_dt1")
@NotBlank(message = "明细表名不能为空")
private String tableDBName;
@Schema(description = "明细记录集合")
@NotEmpty(message = "明细记录不能为空")
@Valid
private List<IWorkDetailRecordVO> records;
}

View File

@@ -0,0 +1,20 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 提交给 iWork 的单个表单字段。
*/
@Data
public class IWorkFormFieldVO {
@Schema(description = "字段名", example = "sqr")
@NotBlank(message = "字段名不能为空")
private String fieldName;
@Schema(description = "字段值", example = "张三")
@NotBlank(message = "字段值不能为空")
private String fieldValue;
}

View File

@@ -0,0 +1,25 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
/**
* iWork 流程操作的通用响应封装。
*/
@Data
public class IWorkOperationRespVO {
@Schema(description = "iWork 返回的原始数据")
private Map<String, Object> payload;
@Schema(description = "iWork 返回的原始字符串")
private String rawBody;
@Schema(description = "是否判断为成功")
private boolean success;
@Schema(description = "返回提示信息")
private String message;
}

View File

@@ -0,0 +1,12 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 获取 iWork 会话令牌的请求载荷。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class IWorkTokenApplyReqVO extends IWorkBaseReqVO {
}

View File

@@ -0,0 +1,30 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Map;
/**
* 用于解析 iWork 用户编号的请求体。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class IWorkUserInfoReqVO extends IWorkBaseReqVO {
@Schema(description = "用户识别字段", example = "loginid")
@NotBlank(message = "用户识别字段不能为空")
private String identifierKey;
@Schema(description = "用户识别值", example = "zhangsan")
@NotBlank(message = "用户识别值不能为空")
private String identifierValue;
@Schema(description = "额外的请求载荷,会与识别字段合并后提交")
private Map<String, Object> payload;
@Schema(description = "额外的查询参数")
private Map<String, Object> queryParams;
}

View File

@@ -0,0 +1,28 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
/**
* iWork 用户信息查询结果。
*/
@Data
public class IWorkUserInfoRespVO {
@Schema(description = "iWork 返回的原始数据")
private Map<String, Object> payload;
@Schema(description = "iWork 返回的原始字符串")
private String rawBody;
@Schema(description = "是否判断为成功")
private boolean success;
@Schema(description = "返回提示信息")
private String message;
@Schema(description = "解析出的 iWork 用户编号")
private String userId;
}

View File

@@ -0,0 +1,41 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
import java.util.Map;
/**
* 发起 iWork 流程的请求体。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class IWorkWorkflowCreateReqVO extends IWorkBaseReqVO {
@Schema(description = "流程标题", example = "测试流程")
@NotBlank(message = "流程标题不能为空")
private String requestName;
@Schema(description = "流程模板编号,可为空使用默认配置", example = "54")
private Long workflowId;
@Schema(description = "主表字段")
@NotEmpty(message = "主表字段不能为空")
@Valid
private List<IWorkFormFieldVO> mainFields;
@Schema(description = "明细表数据")
@Valid
private List<IWorkDetailTableVO> detailTables;
@Schema(description = "额外参数")
private Map<String, Object> otherParams;
@Schema(description = "额外 Form 数据")
private Map<String, String> formExtras;
}

View File

@@ -0,0 +1,29 @@
package com.zt.plat.module.system.controller.admin.integration.iwork.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Map;
/**
* 作废 / 干预 iWork 流程的请求体。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class IWorkWorkflowVoidReqVO extends IWorkBaseReqVO {
@Schema(description = "流程请求编号", example = "REQ-001")
@NotBlank(message = "流程请求编号不能为空")
private String requestId;
@Schema(description = "作废原因")
private String reason;
@Schema(description = "额外参数")
private Map<String, Object> extraParams;
@Schema(description = "额外 Form 数据")
private Map<String, String> formExtras;
}

View File

@@ -0,0 +1,40 @@
package com.zt.plat.module.system.controller.admin.sso;
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.auth.vo.AuthLoginRespVO;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
import com.zt.plat.module.system.service.sso.ExternalSsoService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
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 static com.zt.plat.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 外部单点登录接口。
*/
@Tag(name = "管理后台 - 外部单点登录")
@RestController
@RequestMapping("/system/sso")
@Validated
@RequiredArgsConstructor
public class ExternalSsoController {
private final ExternalSsoService externalSsoService;
@PostMapping("/verify")
@PermitAll
@TenantIgnore
@Operation(summary = "校验外部单点登录令牌")
public CommonResult<AuthLoginRespVO> verify(@Valid @RequestBody ExternalSsoVerifyReqVO reqVO) {
return success(externalSsoService.verifyToken(reqVO));
}
}

View File

@@ -0,0 +1,23 @@
package com.zt.plat.module.system.controller.admin.sso.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 外部 SSO token 校验请求 VO。
*/
@Data
public class ExternalSsoVerifyReqVO {
@Schema(description = "外部系统下发的单点登录 token", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "token 不能为空")
private String token;
@Schema(description = "外部系统跳转时的原始地址", example = "/#/dashboard")
private String targetUri;
@Schema(description = "来源系统标识", example = "partner-a")
private String sourceSystem;
}

View File

@@ -17,6 +17,8 @@ public class UserCreateRequestVO {
private String bimRequestId;
@Schema(description = "用户归属部门(多个为逗号分割)", required = true)
private String deptIds;
@Schema(description = "所属岗位名称")
private String postName;
@Schema(description = "用户名")
private String username;
@Schema(description = "密码")

View File

@@ -1,11 +1,11 @@
package com.zt.plat.module.system.dal.dataobject.user;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.pojo.CompanyDeptInfo;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import com.zt.plat.module.system.enums.common.SexEnum;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.*;

View File

@@ -0,0 +1,12 @@
package com.zt.plat.module.system.framework.integration.iwork.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 负责加载 {@link IWorkProperties} 的自动配置类。
*/
@Configuration
@EnableConfigurationProperties(IWorkProperties.class)
public class IWorkIntegrationConfiguration {
}

View File

@@ -0,0 +1,126 @@
package com.zt.plat.module.system.framework.integration.iwork.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
/**
* iWork 集成所需的配置项。
*/
@Data
@Validated
@ConfigurationProperties(prefix = "iwork")
public class IWorkProperties {
/**
* 是否开启 iWork 集成能力。
*/
private boolean enabled = false;
/**
* iWork 网关的基础地址。
*/
private String baseUrl;
/**
* 当调用方未传入时使用的默认 appId。
*/
private String appId;
/**
* 与 iWork 侧预先约定的 RSA 公钥Base64 编码)。
*/
private String clientPublicKey;
/**
* 当调用方未指定流程编号时使用的默认流程模板编号。
*/
private Long workflowId;
/**
* 当请求未指定操作人时使用的默认用户编号。
*/
private String userId;
@Valid
private final Paths paths = new Paths();
private final Headers headers = new Headers();
@Valid
private final Token token = new Token();
@Valid
private final Client client = new Client();
@Data
public static class Paths {
/**
* 负责交换公钥和密钥的注册接口路径。
*/
@NotBlank(message = "iWork 注册接口路径不能为空")
private String register;
/**
* 申请访问令牌的接口路径。
*/
@NotBlank(message = "iWork 申请 Token 接口路径不能为空")
private String applyToken;
/**
* 查询用户信息的接口路径。
*/
@NotBlank(message = "iWork 用户信息接口路径不能为空")
private String userInfo;
/**
* 发起流程的接口路径。
*/
@NotBlank(message = "iWork 发起流程接口路径不能为空")
private String createWorkflow;
/**
* 干预或作废流程的接口路径。
*/
@NotBlank(message = "iWork 作废流程接口路径不能为空")
private String voidWorkflow;
}
@Getter
public static class Headers {
private final String appId = "app-id";
private final String clientPublicKey = "client-public-key";
private final String secret = "secret";
private final String token = "token";
private final String time = "time";
private final String userId = "user-id";
}
@Data
public static class Token {
/**
* 向 iWork 申请的 Token 有效期(单位秒)。
*/
@Min(value = 1, message = "iWork Token 有效期必须大于 0")
private long ttlSeconds;
/**
* Token 过期前提前刷新的秒数。
*/
@Min(value = 0, message = "iWork Token 提前刷新秒数不能为负数")
private long refreshAheadSeconds;
}
@Data
public static class Client {
/**
* Reactor Netty 连接超时时间。
*/
@NotNull(message = "iWork 客户端连接超时时间不能为空")
private Duration connectTimeout;
/**
* Reactor Netty 响应超时时间。
*/
@NotNull(message = "iWork 客户端响应超时时间不能为空")
private Duration responseTimeout;
}
}

View File

@@ -0,0 +1,223 @@
package com.zt.plat.module.system.framework.sso.config;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.util.*;
/**
* 外部 SSO 相关的可配置属性。
*
* <p>该配置支持通过配置中心(如 Nacos在运行期动态刷新。</p>
*/
@Component
@ConfigurationProperties(prefix = "external-sso")
@Validated
@RefreshScope
@Data
public class ExternalSsoProperties {
/**
* 是否开启外部 SSO 集成。
*/
private boolean enabled = true;
/**
* 用于区分不同上游系统的业务标识。
*/
private String systemCode = "default";
@NotNull
private TokenProperties token = new TokenProperties();
@NotNull
private RemoteProperties remote = new RemoteProperties();
@NotNull
private MappingProperties mapping = new MappingProperties();
@NotNull
private CorsProperties cors = new CorsProperties();
@Data
@Accessors(chain = true)
public static class TokenProperties {
/**
* 用于解密外部令牌的共享密钥。
*/
@NotBlank(message = "external-sso.token.secret 不能为空")
private String secret;
/**
* 加密算法,默认使用 AES。
*/
@NotBlank
private String algorithm = "AES";
/**
* 校验签发时间允许的最大时间偏移(秒)。
*/
@Min(0)
private long allowedClockSkewSeconds = 60;
/**
* 当令牌未包含过期时间时允许的最大生存时间(秒)。
*/
@Min(1)
private long maxAgeSeconds = Duration.ofMinutes(5).getSeconds();
/**
* 是否要求令牌中必须包含一次性随机数nonce
*/
private boolean requireNonce = true;
/**
* 是否启用基于 nonce 的重放校验。
*/
private boolean replayProtectionEnabled = true;
/**
* nonce 在缓存中的有效期(秒)。
*/
@Min(1)
private long nonceTtlSeconds = Duration.ofMinutes(10).getSeconds();
/**
* 可选的签名密钥,用于验证解密后的载荷签名。
*/
private String signatureSecret;
/**
* 签名算法,例如 HMAC-SHA256。为空表示不进行签名校验。
*/
private String signatureAlgorithm;
}
@Data
@Accessors(chain = true)
public static class RemoteProperties {
/**
* 上游接口的基础地址,例如 https://partner.example.com。
*/
@NotBlank(message = "external-sso.remote.base-url 不能为空")
private String baseUrl = "http://10.1.7.110";
/**
* 用户信息接口路径,例如 /api/user/info。
*/
@NotBlank
private String userInfoPath = "/service/S_BF_CS_01";
/**
* 调用上游接口所使用的 HTTP 方法。
*/
@NotNull
private HttpMethod method = HttpMethod.POST;
/**
* 发送请求时附加的静态请求头。
*/
@NotNull
private Map<String, String> headers = new LinkedHashMap<>();
/**
* 发送请求时附加的静态查询参数。
*/
@NotNull
private Map<String, String> queryParams = new LinkedHashMap<>();
/**
* POST/PUT 请求使用的请求体模板,支持简单占位符替换。
*/
@NotNull
private Map<String, String> body = new LinkedHashMap<>();
/**
* 连接超时时间,单位毫秒。
*/
@Min(1)
private int connectTimeoutMillis = (int) Duration.ofSeconds(5).toMillis();
/**
* 读取超时时间,单位毫秒。
*/
@Min(1)
private int readTimeoutMillis = (int) Duration.ofSeconds(10).toMillis();
/**
* 响应中表示业务状态码的字段路径。
*/
private String codeField = "__sys__.status";
/**
* 上游系统约定的成功状态码。
*/
private String successCode = "1";
/**
* 上游返回的提示信息字段路径。
*/
private String messageField = "message";
/**
* 包裹实际数据载荷的字段路径。
*/
private String dataField = "data";
/** 外部用户唯一标识所在的字段路径。 */
private String userIdField = "sub";
}
public enum MatchField {
USERNAME,
MOBILE
}
@Data
@Accessors(chain = true)
public static class MappingProperties {
/** 匹配策略的执行顺序。 */
@NotEmpty
private List<MatchField> order = new LinkedList<>(List.of(MatchField.USERNAME, MatchField.MOBILE));
/** 当上游未提供租户信息时使用的默认租户。 */
private Long defaultTenantId;
/** 字符串字段比较时是否忽略大小写。 */
private boolean ignoreCase = true;
/** 是否在每次登录成功后同步昵称、邮箱、手机号等资料。 */
private boolean updateProfileOnLogin = true;
}
@Data
@Accessors(chain = true)
public static class CorsProperties {
/** 允许访问 SSO 校验接口的来源域名。 */
@NotNull
private List<String> allowedOrigins = new ArrayList<>();
/** 允许的 HTTP 方法。 */
@NotNull
private List<String> allowedMethods = new ArrayList<>(List.of("OPTIONS", "GET", "POST"));
/** 允许携带的请求头。 */
@NotNull
private List<String> allowedHeaders = new ArrayList<>(List.of("Authorization", "Content-Type", "X-Requested-With"));
/** 是否允许携带凭证信息Cookie、授权头等。 */
private boolean allowCredentials = true;
/** 预检请求的缓存时长。 */
@Min(0)
private long maxAge = Duration.ofMinutes(30).getSeconds();
}
}

View File

@@ -17,6 +17,7 @@ import com.zt.plat.module.system.dal.mysql.dept.DeptMapper;
import com.zt.plat.module.system.dal.mysql.userdept.UserDeptMapper;
import com.zt.plat.module.system.dal.redis.RedisKeyConstants;
import com.zt.plat.module.system.enums.dept.DeptSourceEnum;
import org.apache.seata.spring.annotation.GlobalTransactional;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
@@ -58,6 +59,8 @@ public class DeptServiceImpl implements DeptService {
.thenComparing(DeptDO::getId, Comparator.nullsLast(Comparator.naturalOrder()));
@Override
@GlobalTransactional(rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
@CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST,
allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存
public Long createDept(DeptSaveReqVO createReqVO) {

View File

@@ -0,0 +1,21 @@
package com.zt.plat.module.system.service.integration.iwork;
import com.zt.plat.framework.common.exception.ErrorCode;
/**
* iWork 集成相关的错误码常量。
*/
public interface IWorkIntegrationErrorCodeConstants {
ErrorCode IWORK_DISABLED = new ErrorCode(1_010_200_001, "iWork 集成未启用,请先完成配置");
ErrorCode IWORK_BASE_URL_MISSING = new ErrorCode(1_010_200_002, "iWork 集成未配置网关地址");
ErrorCode IWORK_CONFIGURATION_INVALID = new ErrorCode(1_010_200_003,
"iWork 集成缺少必填配置appId/clientPublicKey/userId/workflowId");
ErrorCode IWORK_REGISTER_FAILED = new ErrorCode(1_010_200_004, "iWork 注册授权失败");
ErrorCode IWORK_APPLY_TOKEN_FAILED = new ErrorCode(1_010_200_005, "iWork 令牌申请失败");
ErrorCode IWORK_REMOTE_REQUEST_FAILED = new ErrorCode(1_010_200_006, "iWork 接口请求失败");
ErrorCode IWORK_USER_IDENTIFIER_MISSING = new ErrorCode(1_010_200_007, "缺少用户识别信息,无法调用 iWork 接口");
ErrorCode IWORK_OPERATOR_USER_MISSING = new ErrorCode(1_010_200_008, "缺少 iWork 操作人用户编号");
ErrorCode IWORK_WORKFLOW_ID_MISSING = new ErrorCode(1_010_200_009, "缺少 iWork 流程模板编号");
}

View File

@@ -0,0 +1,28 @@
package com.zt.plat.module.system.service.integration.iwork;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
/**
* 对外暴露统一 iWork 流程能力的门面接口。
*/
public interface IWorkIntegrationService {
/**
* 根据外部标识解析 iWork 内部用户编号。
*/
IWorkUserInfoRespVO resolveUserId(IWorkUserInfoReqVO reqVO);
/**
* 在 iWork 中发起新流程。
*/
IWorkOperationRespVO createWorkflow(IWorkWorkflowCreateReqVO reqVO);
/**
* 在 iWork 中对已有流程执行作废或干预。
*/
IWorkOperationRespVO voidWorkflow(IWorkWorkflowVoidReqVO reqVO);
}

View File

@@ -0,0 +1,641 @@
package com.zt.plat.module.system.service.integration.iwork.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.zt.plat.framework.common.exception.ErrorCode;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailRecordVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkDetailTableVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkFormFieldVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkOperationRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkUserInfoRespVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowCreateReqVO;
import com.zt.plat.module.system.controller.admin.integration.iwork.vo.IWorkWorkflowVoidReqVO;
import com.zt.plat.module.system.framework.integration.iwork.config.IWorkProperties;
import com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static com.zt.plat.module.system.service.integration.iwork.IWorkIntegrationErrorCodeConstants.*;
/**
* {@link IWorkIntegrationService} 的默认实现,负责编排远程 iWork 调用。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IWorkIntegrationServiceImpl implements IWorkIntegrationService {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private final IWorkProperties properties;
private final ObjectMapper objectMapper;
private final WebClient.Builder webClientBuilder;
private final Cache<SessionKey, IWorkSession> sessionCache = Caffeine.newBuilder()
.maximumSize(256)
.build();
private final Cache<String, PublicKey> publicKeyCache = Caffeine.newBuilder()
.maximumSize(64)
.build();
private volatile WebClient cachedWebClient;
@Override
public IWorkUserInfoRespVO resolveUserId(IWorkUserInfoReqVO reqVO) {
assertEnabled();
String appId = resolveAppId();
String clientPublicKey = resolveClientPublicKey();
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
ensureIdentifier(reqVO.getIdentifierKey(), reqVO.getIdentifierValue());
IWorkSession session = ensureSession(appId, clientPublicKey, operatorUserId, Boolean.TRUE.equals(reqVO.getForceRefreshToken()));
Map<String, Object> payload = buildUserPayload(reqVO);
String responseBody = executeJsonRequest(properties.getPaths().getUserInfo(), reqVO.getQueryParams(), appId, session, payload);
return buildUserInfoResponse(responseBody);
}
@Override
public IWorkOperationRespVO createWorkflow(IWorkWorkflowCreateReqVO reqVO) {
assertEnabled();
String appId = resolveAppId();
String clientPublicKey = resolveClientPublicKey();
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
IWorkSession session = ensureSession(appId, clientPublicKey, operatorUserId, Boolean.TRUE.equals(reqVO.getForceRefreshToken()));
MultiValueMap<String, String> formData = buildCreateForm(reqVO);
appendFormExtras(formData, reqVO.getFormExtras());
String responseBody = executeFormRequest(properties.getPaths().getCreateWorkflow(), appId, session, formData);
return buildOperationResponse(responseBody);
}
@Override
public IWorkOperationRespVO voidWorkflow(IWorkWorkflowVoidReqVO reqVO) {
assertEnabled();
String appId = resolveAppId();
String clientPublicKey = resolveClientPublicKey();
String operatorUserId = resolveOperatorUserId(reqVO.getOperatorUserId());
if (!StringUtils.hasText(reqVO.getRequestId())) {
throw ServiceExceptionUtil.exception(IWORK_USER_IDENTIFIER_MISSING);
}
IWorkSession session = ensureSession(appId, clientPublicKey, operatorUserId, Boolean.TRUE.equals(reqVO.getForceRefreshToken()));
MultiValueMap<String, String> formData = buildVoidForm(reqVO);
appendFormExtras(formData, reqVO.getFormExtras());
String responseBody = executeFormRequest(properties.getPaths().getVoidWorkflow(), appId, session, formData);
return buildOperationResponse(responseBody);
}
private void assertEnabled() {
if (!properties.isEnabled()) {
throw ServiceExceptionUtil.exception(IWORK_DISABLED);
}
if (!StringUtils.hasText(properties.getBaseUrl())) {
throw ServiceExceptionUtil.exception(IWORK_BASE_URL_MISSING);
}
if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getClientPublicKey())) {
throw ServiceExceptionUtil.exception(IWORK_CONFIGURATION_INVALID);
}
}
private String resolveOperatorUserId(String requestValue) {
if (StringUtils.hasText(requestValue)) {
return requestValue.trim();
}
if (StringUtils.hasText(properties.getUserId())) {
return properties.getUserId().trim();
}
throw ServiceExceptionUtil.exception(IWORK_OPERATOR_USER_MISSING);
}
private String resolveAppId() {
String value = properties.getAppId();
if (!StringUtils.hasText(value)) {
throw ServiceExceptionUtil.exception(IWORK_CONFIGURATION_INVALID);
}
return StringUtils.trimWhitespace(value);
}
private String resolveClientPublicKey() {
String value = properties.getClientPublicKey();
if (!StringUtils.hasText(value)) {
throw ServiceExceptionUtil.exception(IWORK_CONFIGURATION_INVALID);
}
return StringUtils.trimWhitespace(value);
}
private void ensureIdentifier(String identifierKey, String identifierValue) {
if (!StringUtils.hasText(identifierKey) || !StringUtils.hasText(identifierValue)) {
throw ServiceExceptionUtil.exception(IWORK_USER_IDENTIFIER_MISSING);
}
}
private IWorkSession ensureSession(String appId, String clientPublicKey, String operatorUserId, boolean forceRefresh) {
SessionKey key = new SessionKey(appId, operatorUserId);
Instant now = Instant.now();
if (!forceRefresh) {
IWorkSession cached = sessionCache.getIfPresent(key);
if (cached != null && cached.isValid(now, properties.getToken().getRefreshAheadSeconds())) {
return cached;
}
}
synchronized (key.intern()) {
IWorkSession cached = sessionCache.getIfPresent(key);
if (!forceRefresh && cached != null && cached.isValid(now, properties.getToken().getRefreshAheadSeconds())) {
return cached;
}
IWorkSession session = createSession(appId, clientPublicKey, operatorUserId);
sessionCache.put(key, session);
return session;
}
}
private IWorkSession createSession(String appId, String clientPublicKey, String operatorUserId) {
RegistrationResult registration = register(appId, clientPublicKey);
String encryptedSecret = encryptWithPublicKey(registration.secret(), registration.spk());
String encryptedUserId = encryptWithPublicKey(operatorUserId, registration.spk());
String token = applyToken(appId, encryptedSecret);
Instant expiresAt = Instant.now().plusSeconds(Math.max(1L, properties.getToken().getTtlSeconds()));
return new IWorkSession(token, encryptedUserId, expiresAt, registration.spk());
}
private RegistrationResult register(String appId, String clientPublicKey) {
String responseBody;
try {
responseBody = webClient()
.post()
.uri(properties.getPaths().getRegister())
.headers(headers -> {
headers.set(properties.getHeaders().getAppId(), appId);
headers.set(properties.getHeaders().getClientPublicKey(), clientPublicKey);
})
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] register failed. status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] register failed", ex);
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, ex.getMessage());
}
JsonNode node = parseJson(responseBody, IWORK_REGISTER_FAILED);
String secret = textValue(node, "secret");
String spk = textValue(node, "spk");
if (!StringUtils.hasText(secret) || !StringUtils.hasText(spk)) {
throw ServiceExceptionUtil.exception(IWORK_REGISTER_FAILED, "返回缺少 secret 或 spk");
}
return new RegistrationResult(secret, spk);
}
private String applyToken(String appId, String encryptedSecret) {
String responseBody;
try {
responseBody = webClient()
.post()
.uri(properties.getPaths().getApplyToken())
.headers(headers -> {
headers.set(properties.getHeaders().getAppId(), appId);
headers.set(properties.getHeaders().getSecret(), encryptedSecret);
headers.set(properties.getHeaders().getTime(), String.valueOf(properties.getToken().getTtlSeconds()));
})
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] apply token failed. status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] apply token failed", ex);
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, ex.getMessage());
}
JsonNode node = parseJson(responseBody, IWORK_APPLY_TOKEN_FAILED);
String token = textValue(node, "token");
if (!StringUtils.hasText(token)) {
throw ServiceExceptionUtil.exception(IWORK_APPLY_TOKEN_FAILED, "返回缺少 token");
}
return token;
}
private String executeJsonRequest(String path,
Map<String, Object> queryParams,
String appId,
IWorkSession session,
Map<String, Object> payload) {
try {
return webClient()
.post()
.uri(uriBuilder -> {
uriBuilder.path(path);
if (queryParams != null) {
queryParams.forEach((key, value) -> {
if (value != null) {
uriBuilder.queryParam(key, value);
}
});
}
return uriBuilder.build();
})
.headers(headers -> setAuthHeaders(headers, appId, session))
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload == null ? Collections.emptyMap() : payload)
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] request {} failed. status={}, body={}", path, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] request {} failed", path, ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getMessage());
}
}
private String executeFormRequest(String path,
String appId,
IWorkSession session,
MultiValueMap<String, String> formData) {
try {
return webClient()
.post()
.uri(path)
.headers(headers -> setAuthHeaders(headers, appId, session))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(String.class)
.block();
} catch (WebClientResponseException ex) {
log.error("[iWork] form request {} failed. status={}, body={}", path, ex.getStatusCode(), ex.getResponseBodyAsString(), ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getStatusCode().value(), ex.getResponseBodyAsString());
} catch (Exception ex) {
log.error("[iWork] form request {} failed", path, ex);
throw ServiceExceptionUtil.exception(IWORK_REMOTE_REQUEST_FAILED, ex.getMessage());
}
}
private void setAuthHeaders(org.springframework.http.HttpHeaders headers,
String appId,
IWorkSession session) {
headers.set(properties.getHeaders().getAppId(), appId);
headers.set(properties.getHeaders().getToken(), session.getToken());
headers.set(properties.getHeaders().getUserId(), session.getEncryptedUserId());
}
private Map<String, Object> buildUserPayload(IWorkUserInfoReqVO reqVO) {
Map<String, Object> payload = new HashMap<>();
if (reqVO.getPayload() != null) {
payload.putAll(reqVO.getPayload());
}
payload.put(reqVO.getIdentifierKey(), reqVO.getIdentifierValue());
return payload;
}
private MultiValueMap<String, String> buildCreateForm(IWorkWorkflowCreateReqVO reqVO) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("requestName", reqVO.getRequestName());
formData.add("workflowId", String.valueOf(resolveWorkflowId(reqVO.getWorkflowId())));
formData.add("mainData", toJsonString(convertFormFields(reqVO.getMainFields())));
if (reqVO.getDetailTables() != null && !reqVO.getDetailTables().isEmpty()) {
formData.add("detailData", toJsonString(convertDetailTables(reqVO.getDetailTables())));
}
if (reqVO.getOtherParams() != null && !reqVO.getOtherParams().isEmpty()) {
formData.add("otherParams", toJsonString(reqVO.getOtherParams()));
}
return formData;
}
private long resolveWorkflowId(Long requestWorkflowId) {
if (requestWorkflowId != null) {
return requestWorkflowId;
}
if (properties.getWorkflowId() != null) {
return properties.getWorkflowId();
}
throw ServiceExceptionUtil.exception(IWORK_WORKFLOW_ID_MISSING);
}
private MultiValueMap<String, String> buildVoidForm(IWorkWorkflowVoidReqVO reqVO) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("requestId", reqVO.getRequestId());
if (StringUtils.hasText(reqVO.getReason())) {
formData.add("remark", reqVO.getReason());
}
if (reqVO.getExtraParams() != null && !reqVO.getExtraParams().isEmpty()) {
reqVO.getExtraParams().forEach((key, value) -> {
if (value != null) {
formData.add(key, String.valueOf(value));
}
});
}
return formData;
}
private void appendFormExtras(MultiValueMap<String, String> formData, Map<String, String> extras) {
if (extras == null || extras.isEmpty()) {
return;
}
extras.forEach((key, value) -> {
if (StringUtils.hasText(key) && value != null) {
formData.add(key, value);
}
});
}
private List<Map<String, Object>> convertFormFields(List<IWorkFormFieldVO> fields) {
return fields.stream().map(field -> {
Map<String, Object> map = new HashMap<>(2);
map.put("fieldName", field.getFieldName());
map.put("fieldValue", field.getFieldValue());
return map;
}).toList();
}
private List<Map<String, Object>> convertDetailTables(List<IWorkDetailTableVO> tables) {
return tables.stream().map(table -> {
Map<String, Object> tableMap = new HashMap<>(2);
tableMap.put("tableDBName", table.getTableDBName());
List<Map<String, Object>> records = table.getRecords().stream().map(record -> {
Map<String, Object> recordMap = new HashMap<>(2);
if (record.getRecordOrder() != null) {
recordMap.put("recordOrder", record.getRecordOrder());
}
recordMap.put("workflowRequestTableFields", convertFormFields(record.getFields()));
return recordMap;
}).toList();
tableMap.put("workflowRequestTableRecords", records);
return tableMap;
}).toList();
}
private IWorkUserInfoRespVO buildUserInfoResponse(String responseBody) {
IWorkUserInfoRespVO respVO = new IWorkUserInfoRespVO();
respVO.setRawBody(responseBody);
if (!StringUtils.hasText(responseBody)) {
return respVO;
}
JsonNode node = parseJson(responseBody, IWORK_REMOTE_REQUEST_FAILED);
Map<String, Object> payload = objectMapper.convertValue(node, MAP_TYPE);
respVO.setPayload(payload);
respVO.setSuccess(isSuccess(node));
respVO.setMessage(resolveMessage(node));
respVO.setUserId(extractUserId(node));
return respVO;
}
private IWorkOperationRespVO buildOperationResponse(String responseBody) {
IWorkOperationRespVO respVO = new IWorkOperationRespVO();
respVO.setRawBody(responseBody);
if (!StringUtils.hasText(responseBody)) {
return respVO;
}
JsonNode node = parseJson(responseBody, IWORK_REMOTE_REQUEST_FAILED);
respVO.setPayload(objectMapper.convertValue(node, MAP_TYPE));
respVO.setSuccess(isSuccess(node));
respVO.setMessage(resolveMessage(node));
return respVO;
}
private boolean isSuccess(JsonNode node) {
if (node == null) {
return false;
}
return checkSuccessByField(node, "code")
|| checkSuccessByField(node, "status")
|| checkSuccessByField(node, "success")
|| checkSuccessByField(node, "errno");
}
private boolean checkSuccessByField(JsonNode node, String field) {
if (!node.has(field)) {
return false;
}
JsonNode value = node.get(field);
if (value.isBoolean()) {
return value.booleanValue();
}
String text = value.asText();
return Objects.equals("0", text) || Objects.equals("1", text) || Objects.equals("success", text);
}
private String resolveMessage(JsonNode node) {
if (node == null) {
return null;
}
if (node.has("msg")) {
return node.get("msg").asText();
}
if (node.has("message")) {
return node.get("message").asText();
}
if (node.has("errmsg")) {
return node.get("errmsg").asText();
}
return null;
}
private String extractUserId(JsonNode node) {
if (node == null) {
return null;
}
if (node.has("userid")) {
return node.get("userid").asText();
}
if (node.has("userId")) {
return node.get("userId").asText();
}
if (node.has("data")) {
JsonNode data = node.get("data");
if (data.has("userid")) {
return data.get("userid").asText();
}
if (data.has("userId")) {
return data.get("userId").asText();
}
if (data.isArray() && data.size() > 0) {
JsonNode first = data.get(0);
if (first.has("userid")) {
return first.get("userid").asText();
}
if (first.has("userId")) {
return first.get("userId").asText();
}
}
}
return null;
}
private JsonNode parseJson(String responseBody, ErrorCode errorCode) {
try {
return objectMapper.readTree(responseBody);
} catch (JsonProcessingException ex) {
log.error("[iWork] failed to parse JSON body: {}", responseBody, ex);
throw ServiceExceptionUtil.exception(errorCode, "响应不是合法 JSON");
}
}
private String textValue(JsonNode node, String fieldName) {
return node != null && node.has(fieldName) ? node.get(fieldName).asText() : null;
}
private String toJsonString(Object payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException ex) {
throw new ServiceException(IWORK_REMOTE_REQUEST_FAILED.getCode(), "序列化 JSON 失败: " + ex.getMessage());
}
}
private String encryptWithPublicKey(String plainText, String base64PublicKey) {
if (!StringUtils.hasText(plainText)) {
return plainText;
}
try {
PublicKey publicKey = publicKeyCache.get(base64PublicKey, this::loadPublicKey);
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception ex) {
log.error("[iWork] RSA encryption failed", ex);
throw new ServiceException(IWORK_REMOTE_REQUEST_FAILED.getCode(), "RSA 加密失败: " + ex.getMessage());
}
}
private PublicKey loadPublicKey(String base64PublicKey) {
try {
byte[] decoded = Base64.getDecoder().decode(base64PublicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decoded);
return KeyFactory.getInstance("RSA").generatePublic(keySpec);
} catch (Exception ex) {
throw new ServiceException(IWORK_REMOTE_REQUEST_FAILED.getCode(), "加载公钥失败: " + ex.getMessage());
}
}
private WebClient webClient() {
WebClient client = cachedWebClient;
if (client != null) {
return client;
}
synchronized (this) {
if (cachedWebClient == null) {
cachedWebClient = buildWebClient();
}
return cachedWebClient;
}
}
private WebClient buildWebClient() {
WebClient.Builder builder = cloneBuilder();
builder.baseUrl(properties.getBaseUrl());
IWorkProperties.Client clientProps = properties.getClient();
if (clientProps != null) {
Duration responseTimeout = clientProps.getResponseTimeout();
if (responseTimeout != null) {
builder.filter((request, next) -> next.exchange(request).timeout(responseTimeout));
}
// 连接超时时间由全局的 HttpClient 自定义器统一配置(若存在)。
}
return builder.build();
}
private WebClient.Builder cloneBuilder() {
try {
return webClientBuilder.clone();
} catch (UnsupportedOperationException ex) {
return WebClient.builder();
}
}
private record RegistrationResult(String secret, String spk) {
}
@Getter
private static final class IWorkSession {
private final String token;
private final String encryptedUserId;
private final Instant expiresAt;
private final String spk;
private IWorkSession(String token, String encryptedUserId, Instant expiresAt, String spk) {
this.token = token;
this.encryptedUserId = encryptedUserId;
this.expiresAt = expiresAt;
this.spk = spk;
}
private boolean isValid(Instant now, long refreshAheadSeconds) {
Instant refreshThreshold = expiresAt.minusSeconds(Math.max(0L, refreshAheadSeconds));
return refreshThreshold.isAfter(now) && StringUtils.hasText(token) && StringUtils.hasText(encryptedUserId);
}
}
@ToString
private static final class SessionKey {
private final String appId;
private final String operatorUserId;
private SessionKey(String appId, String operatorUserId) {
this.appId = appId;
this.operatorUserId = operatorUserId;
}
private String cacheKey() {
return appId + "::" + operatorUserId;
}
private String intern() {
return cacheKey().intern();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof SessionKey that)) {
return false;
}
return Objects.equals(appId, that.appId)
&& Objects.equals(operatorUserId, that.operatorUserId);
}
@Override
public int hashCode() {
return Objects.hash(appId, operatorUserId);
}
}
}

View File

@@ -4,6 +4,10 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.dynamic.datasource.annotation.DSTransactional;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.collect.Sets;
import com.zt.plat.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.util.collection.CollectionUtils;
@@ -23,16 +27,13 @@ import com.zt.plat.module.system.enums.permission.RoleTypeEnum;
import com.zt.plat.module.system.service.dept.DeptService;
import com.zt.plat.module.system.service.user.AdminUserService;
import com.zt.plat.module.system.service.userdept.UserDeptService;
import com.baomidou.dynamic.datasource.annotation.DSTransactional;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.collect.Sets;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -61,7 +62,6 @@ public class PermissionServiceImpl implements PermissionService {
@Resource
private UserRoleMapper userRoleMapper;
@Resource
private RoleService roleService;
@Resource
private MenuService menuService;
@@ -73,8 +73,11 @@ public class PermissionServiceImpl implements PermissionService {
private RoleMenuExclusionMapper roleMenuExclusionMapper;
@Resource
private UserDeptService userDeptService;
@Autowired
private PermissionService permissionService;
public void setRoleService(@Lazy RoleService roleService) {
this.roleService = roleService;
}
@Override
public boolean hasAnyPermissions(Long userId, String... permissions) {
@@ -320,7 +323,7 @@ public class PermissionServiceImpl implements PermissionService {
@Override
public void assignRoleDataScope(Long roleId, Integer dataScope, Set<Long> dataScopeDeptIds) {
RoleDO role = roleService.getRole(roleId);
Set<Long> userRoleIdListByUserId = permissionService.getUserRoleIdListByUserId(getLoginUserId());
Set<Long> userRoleIdListByUserId = getSelf().getUserRoleIdListByUserId(getLoginUserId());
// 如果为标准角色,只允许管理员修改数据权限
if (RoleTypeEnum.NORMAL.getType().equals(role.getType()) && !roleService.hasAnySuperAdmin(userRoleIdListByUserId)) {
throw exception(ROLE_CAN_NOT_UPDATE_NORMAL_TYPE_ROLE);

View File

@@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -52,7 +53,6 @@ import static com.zt.plat.module.system.enums.LogRecordConstants.*;
@Slf4j
public class RoleServiceImpl implements RoleService {
@Resource
private PermissionService permissionService;
@Resource
@@ -60,6 +60,11 @@ public class RoleServiceImpl implements RoleService {
@Autowired
private UserRoleMapper userRoleMapper;
@Autowired
public void setPermissionService(@Lazy PermissionService permissionService) {
this.permissionService = permissionService;
}
@Override
@Transactional(rollbackFor = Exception.class)
@LogRecord(type = SYSTEM_ROLE_TYPE, subType = SYSTEM_ROLE_CREATE_SUB_TYPE, bizNo = "{{#role.id}}",

View File

@@ -0,0 +1,18 @@
package com.zt.plat.module.system.service.sso;
import com.zt.plat.module.system.controller.admin.auth.vo.AuthLoginRespVO;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
/**
* 处理外部单点登录校验的业务接口。
*/
public interface ExternalSsoService {
/**
* 校验外部单点登录令牌并返回本地登录凭证。
*
* @param reqVO 校验请求参数
* @return 登录凭证信息
*/
AuthLoginRespVO verifyToken(ExternalSsoVerifyReqVO reqVO);
}

View File

@@ -0,0 +1,233 @@
package com.zt.plat.module.system.service.sso;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.common.biz.system.logger.dto.OperateLogCreateReqDTO;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.enums.UserTypeEnum;
import com.zt.plat.framework.common.exception.ErrorCode;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.util.json.JsonUtils;
import com.zt.plat.framework.common.util.monitor.TracerUtils;
import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.framework.tenant.core.util.TenantUtils;
import com.zt.plat.module.system.api.logger.dto.LoginLogCreateReqDTO;
import com.zt.plat.module.system.controller.admin.auth.vo.AuthLoginRespVO;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
import com.zt.plat.module.system.convert.auth.AuthConvert;
import com.zt.plat.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.enums.logger.LoginLogTypeEnum;
import com.zt.plat.module.system.enums.logger.LoginResultEnum;
import com.zt.plat.module.system.enums.oauth2.OAuth2ClientConstants;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
import com.zt.plat.module.system.service.logger.LoginLogService;
import com.zt.plat.module.system.service.logger.OperateLogService;
import com.zt.plat.module.system.service.oauth2.OAuth2TokenService;
import com.zt.plat.module.system.service.sso.client.ExternalSsoClientException;
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
import com.zt.plat.module.system.service.sso.strategy.ExternalSsoStrategy;
import com.zt.plat.module.system.service.user.AdminUserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.zt.plat.module.system.enums.ErrorCodeConstants.*;
/**
* {@link ExternalSsoService} 的默认实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExternalSsoServiceImpl implements ExternalSsoService {
private final ExternalSsoProperties properties;
private final List<ExternalSsoStrategy> strategies;
private final AdminUserService adminUserService;
private final LoginLogService loginLogService;
private final OAuth2TokenService oauth2TokenService;
private final OperateLogService operateLogService;
@Override
public AuthLoginRespVO verifyToken(ExternalSsoVerifyReqVO reqVO) {
// 步骤一:检查开关并校验令牌参数
if (!properties.isEnabled()) {
throw fail(EXTERNAL_SSO_DISABLED, LoginResultEnum.TOKEN_INVALID, null, null);
}
if (!StringUtils.hasText(reqVO.getToken())) {
throw fail(EXTERNAL_SSO_TOKEN_MISSING, LoginResultEnum.TOKEN_INVALID, null, null);
}
String normalizedSourceSystem = normalizeSourceSystem(reqVO.getSourceSystem());
reqVO.setSourceSystem(normalizedSourceSystem);
ExternalSsoStrategy strategy = selectStrategy(normalizedSourceSystem);
if (strategy == null) {
throw fail(EXTERNAL_SSO_SOURCE_UNSUPPORTED, LoginResultEnum.REMOTE_SERVICE_ERROR,
null, null, normalizedSourceSystem);
}
// 步骤二:调用外部接口查询用户资料
ExternalSsoUserInfo externalUser = fetchExternalUser(strategy, reqVO);
// 步骤三:匹配本地账号
AdminUserDO user = resolveLocalUser(strategy, externalUser, reqVO);
ensureUserEnabled(user);
// 步骤四:发放本地登录凭证并记审计日志
AuthLoginRespVO respVO = issueLoginToken(user);
recordAuditLog(user, reqVO, externalUser, reqVO.getToken());
return respVO;
}
/**
* 调用外部系统获取用户信息,并兜底补充外部用户标识。
*/
private ExternalSsoUserInfo fetchExternalUser(ExternalSsoStrategy strategy, ExternalSsoVerifyReqVO reqVO) {
try {
return strategy.fetchExternalUser(reqVO);
} catch (ExternalSsoClientException ex) {
log.warn("拉取外部用户信息失败: {}", ex.getMessage());
throw fail(EXTERNAL_SSO_REMOTE_ERROR, LoginResultEnum.REMOTE_SERVICE_ERROR, null,
ex, ex.getMessage());
}
}
private AdminUserDO resolveLocalUser(ExternalSsoStrategy strategy, ExternalSsoUserInfo externalUser,
ExternalSsoVerifyReqVO reqVO) {
AdminUserDO user = strategy.resolveLocalUser(externalUser, reqVO);
if (user != null) {
return user;
}
return handleMissingUser();
}
/**
* 当无法匹配到本地账号时直接返回错误,不再自动建号。
*/
private AdminUserDO handleMissingUser() {
// 明确禁止自动建号,统一返回用户不存在
throw fail(EXTERNAL_SSO_USER_NOT_FOUND, LoginResultEnum.USER_NOT_FOUND, null,
null);
}
private void ensureUserEnabled(AdminUserDO user) {
if (user == null) {
throw fail(EXTERNAL_SSO_USER_NOT_FOUND, LoginResultEnum.USER_NOT_FOUND, null,
null);
}
if (CommonStatusEnum.isDisable(user.getStatus())) {
throw fail(EXTERNAL_SSO_USER_DISABLED, LoginResultEnum.USER_DISABLED, user,
null);
}
}
/**
* 为已通过校验的账号创建访问令牌并返回。
*/
private AuthLoginRespVO issueLoginToken(AdminUserDO user) {
recordLoginLog(user.getId(), user.getUsername(), LoginResultEnum.SUCCESS);
OAuth2AccessTokenDO token = TenantUtils.execute(user.getTenantId(),
() -> oauth2TokenService.createAccessToken(user.getId(), UserTypeEnum.ADMIN.getValue(),
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null));
return AuthConvert.INSTANCE.convert(token);
}
/**
* 记录一次外部单点登录的审计日志,便于后续追踪。
*/
private void recordAuditLog(AdminUserDO user, ExternalSsoVerifyReqVO request,
ExternalSsoUserInfo externalUser, String token) {
try {
Map<String, Object> extra = new LinkedHashMap<>();
extra.put("externalUsername", externalUser.getUsername());
extra.put("externalNickname", externalUser.getNickname());
// extra.put("externalAttributes", attributes);
extra.put("localUserId", user.getId());
extra.put("localUsername", user.getUsername());
// extra.put("tenantId", user.getTenantId());
extra.put("targetUri", request.getTargetUri());
extra.put("sourceSystem", request.getSourceSystem());
OperateLogCreateReqDTO dto = new OperateLogCreateReqDTO();
dto.setTraceId(TracerUtils.getTraceId());
dto.setUserId(user.getId());
dto.setUserType(UserTypeEnum.ADMIN.getValue());
dto.setType("EXTERNAL_SSO");
dto.setSubType("VERIFY");
dto.setBizId(user.getId());
String externalPrincipal = StringUtils.hasText(externalUser.getUsername())
? externalUser.getUsername()
: "";
dto.setAction(StrUtil.format("外部单点登录成功: {} -> {}", externalPrincipal, user.getUsername()));
dto.setExtra(JsonUtils.toJsonString(extra));
dto.setRequestMethod("POST");
dto.setRequestUrl("/system/sso/verify");
dto.setUserIp(ServletUtils.getClientIP());
dto.setUserAgent(ServletUtils.getUserAgent());
operateLogService.createOperateLog(dto);
} catch (Exception ex) {
log.warn("记录外部 SSO 审计日志失败", ex);
}
}
private void recordLoginLog(Long userId, String username, LoginResultEnum result) {
LoginLogCreateReqDTO dto = new LoginLogCreateReqDTO();
dto.setLogType(LoginLogTypeEnum.LOGIN_EXTERNAL_SSO.getType());
dto.setTraceId(TracerUtils.getTraceId());
dto.setUserId(userId);
dto.setUserType(UserTypeEnum.ADMIN.getValue());
dto.setUsername(username);
dto.setResult(result.getResult());
dto.setUserIp(ServletUtils.getClientIP());
dto.setUserAgent(ServletUtils.getUserAgent());
loginLogService.createLoginLog(dto);
if (userId != null && result == LoginResultEnum.SUCCESS) {
adminUserService.updateUserLogin(userId, ServletUtils.getClientIP());
}
}
/**
* 构造业务异常并同步记录登录日志。
*/
private ServiceException fail(ErrorCode errorCode, LoginResultEnum result, AdminUserDO user,
Throwable cause, Object... args) {
Long userId = user != null ? user.getId() : null;
String username = user != null ? user.getUsername() : "登录失败";
recordLoginLog(userId, username, result);
ServiceException ex = exception(errorCode, args);
if (cause != null) {
ex.initCause(cause);
}
return ex;
}
private String normalizeSourceSystem(String sourceSystem) {
String trimmed = StringUtils.hasText(sourceSystem) ? sourceSystem.trim() : null;
if (StringUtils.hasText(trimmed)) {
return trimmed;
}
return properties.getSystemCode();
}
private ExternalSsoStrategy selectStrategy(String sourceSystem) {
if (strategies == null || strategies.isEmpty()) {
return null;
}
return strategies.stream()
.filter(strategy -> {
try {
return strategy.supports(sourceSystem);
} catch (Exception ex) {
log.warn("判定 SSO 策略是否支持来源系统时出现异常: {}", ex.getMessage());
return false;
}
})
.findFirst()
.orElse(null);
}
}

View File

@@ -0,0 +1,294 @@
package com.zt.plat.module.system.service.sso.client;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.MissingNode;
import com.zt.plat.framework.common.util.integration.ShareServiceProperties;
import com.zt.plat.framework.common.util.integration.ShareServiceUtils;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties.RemoteProperties;
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 通过 HTTP 调用外部接口获取用户信息的默认实现。
*/
@Slf4j
@RequiredArgsConstructor
public class DefaultExternalSsoClient implements ExternalSsoClient {
private static final String RESPONSE_CODE_FIELD = "__sys__.status";
private static final String RESPONSE_SUCCESS_CODE = "1";
private static final String RESPONSE_MESSAGE_FIELD = "__sys__.msg";
private static final String RESPONSE_USERNAME_FIELD = "sub";
private static final String RESPONSE_NICKNAME_FIELD = "ucn";
private final ExternalSsoProperties properties;
private final ObjectMapper objectMapper;
private final RestTemplateBuilder restTemplateBuilder;
private final ShareServiceProperties shareServiceProperties;
private final StringRedisTemplate stringRedisTemplate;
private volatile RestTemplate restTemplate;
private volatile RestTemplate shareServiceRestTemplate;
@PostConstruct
public void init() {
this.restTemplate = buildRestTemplate();
}
@Override
public ExternalSsoUserInfo fetchUserInfo(String token, ExternalSsoVerifyReqVO request) {
RemoteProperties remote = properties.getRemote();
RestTemplate template = getUserInfoRestTemplate();
String shareToken = obtainShareServiceToken();
// 构造访问外部用户信息接口的完整地址
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(normalizeBaseUrl(remote.getBaseUrl()))
.path(normalizePath(remote.getUserInfoPath()));
remote.getQueryParams().forEach((key, value) -> {
if (value != null) {
uriBuilder.queryParam(key, value);
}
});
// 组装请求头
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN, MediaType.ALL));
remote.getHeaders().forEach((key, value) -> {
if (value != null) {
headers.set(key, value);
}
});
if (StringUtils.hasText(shareToken)) {
headers.set(shareServiceProperties.getTokenHeaderName(), shareToken);
}
// 组装请求体
HttpEntity<?> entity;
if (remote.getMethod() == HttpMethod.GET) {
entity = new HttpEntity<>(headers);
} else {
Map<String, Object> body = buildRequestBody(remote, token);
if (headers.getContentType() == null) {
headers.setContentType(MediaType.APPLICATION_JSON);
}
entity = new HttpEntity<>(body, headers);
}
// 调用外部接口并处理重试
String responseBody = null;
int attempts = Math.max(1, 0) + 1;
// 简单重试机制
for (int current = 1; current <= attempts; current++) {
try {
ResponseEntity<String> response = template.exchange(uriBuilder.build(true).toUri(), remote.getMethod(), entity, String.class);
responseBody = response.getBody();
break;
} catch (RestClientResponseException ex) {
responseBody = ex.getResponseBodyAsString();
if (current == attempts) {
throw new ExternalSsoClientException("调用外部用户信息接口返回异常: " + ex.getRawStatusCode(), ex, responseBody);
}
log.warn("调用外部 SSO 接口失败({}/{}): status={}, body={}", current, attempts, ex.getRawStatusCode(), StrUtil.maxLength(responseBody, 200));
} catch (Exception ex) {
if (current == attempts) {
throw new ExternalSsoClientException("调用外部用户信息接口失败", ex);
}
log.warn("调用外部 SSO 接口异常({}/{}): {}", current, attempts, ex.getMessage());
}
}
if (!StringUtils.hasText(responseBody)) {
throw new ExternalSsoClientException("外部用户信息接口返回空响应");
}
try {
// 解析外部接口返回结果并抽取关键字段
JsonNode root = objectMapper.readTree(responseBody);
validateResponse(root);
String username = textValue(extractNode(root, RESPONSE_USERNAME_FIELD));
String nickname = textValue(extractNode(root, RESPONSE_NICKNAME_FIELD));
if (!StringUtils.hasText(username)) {
username = token;
}
if (!StringUtils.hasText(nickname)) {
nickname = username;
}
ExternalSsoUserInfo info = new ExternalSsoUserInfo()
.setUsername(username)
.setNickname(nickname);
info.addAttribute("rawResponse", responseBody);
return info;
} catch (ExternalSsoClientException ex) {
throw ex;
} catch (Exception ex) {
throw new ExternalSsoClientException("解析外部用户信息失败", ex, responseBody);
}
}
/**
* 校验外部接口的业务状态码,只有成功码才允许继续解析。
*/
private void validateResponse(JsonNode root) {
JsonNode codeNode = extractNode(root, RESPONSE_CODE_FIELD);
String code = codeNode != null && !codeNode.isNull() ? codeNode.asText() : null;
if (code != null) {
if (!StrUtil.equals(code, RESPONSE_SUCCESS_CODE)) {
String message = textValue(extractNode(root, RESPONSE_MESSAGE_FIELD));
throw new ExternalSsoClientException(StrUtil.format("外部接口返回失败, code={}, message={}", code, message), root.toString());
}
return;
}
// 如果最终既没有配置的 code 字段,则不再强制认为失败,后续解析将尽量从返回体中抽取数据。
}
/**
* 按“a.b.c”路径提取嵌套节点缺失时返回 MissingNode便于后续统一判空。
*/
private JsonNode extractNode(JsonNode root, String path) {
if (!StringUtils.hasText(path)) {
return root;
}
if (root == null || root.isMissingNode()) {
return MissingNode.getInstance();
}
JsonNode current = root;
for (String segment : path.split("\\.")) {
if (!StringUtils.hasText(segment) || current == null) {
return MissingNode.getInstance();
}
current = current.get(segment);
if (current == null) {
return MissingNode.getInstance();
}
}
return current;
}
/**
* 获取字符串值并做去空白处理。
*/
private String textValue(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
String value = node.asText();
return StringUtils.hasText(value) ? value.trim() : null;
}
/**
* 调用共享服务获取访问 token失败时包装成客户端异常。
*/
private String obtainShareServiceToken() {
try {
RestTemplate shareTemplate = getShareServiceRestTemplate();
String token = ShareServiceUtils.getAccessToken(shareTemplate, stringRedisTemplate, shareServiceProperties);
if (!StringUtils.hasText(token)) {
throw new ExternalSsoClientException("获取共享服务访问 token 为空");
}
return token;
} catch (ExternalSsoClientException ex) {
throw ex;
} catch (Exception ex) {
throw new ExternalSsoClientException("获取共享服务访问 token 失败", ex);
}
}
/**
* 懒加载共享服务使用的 RestTemplate减少重复构造。
*/
private RestTemplate getShareServiceRestTemplate() {
RestTemplate existing = shareServiceRestTemplate;
if (existing != null) {
return existing;
}
synchronized (this) {
if (shareServiceRestTemplate == null) {
shareServiceRestTemplate = restTemplateBuilder.build();
}
return shareServiceRestTemplate;
}
}
/**
* 构建具备超时与代理能力的 RestTemplate。
*/
private RestTemplate buildRestTemplate() {
RemoteProperties remote = properties.getRemote();
return restTemplateBuilder.requestFactory(() -> createRequestFactory(remote)).build();
}
/**
* 懒加载外部用户接口使用的 RestTemplate确保多线程安全。
*/
private RestTemplate getUserInfoRestTemplate() {
RestTemplate existing = restTemplate;
if (existing != null) {
return existing;
}
synchronized (this) {
if (restTemplate == null) {
restTemplate = buildRestTemplate();
}
return restTemplate;
}
}
private SimpleClientHttpRequestFactory createRequestFactory(RemoteProperties remote) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(remote.getConnectTimeoutMillis());
factory.setReadTimeout(remote.getReadTimeoutMillis());
return factory;
}
private String normalizeBaseUrl(String baseUrl) {
if (!StringUtils.hasText(baseUrl)) {
return baseUrl;
}
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
private String normalizePath(String path) {
if (!StringUtils.hasText(path)) {
return "";
}
return path.startsWith("/") ? path : "/" + path;
}
/**
* 根据配置与动态上下文构造请求体,并附加必需的 x-token 结构。
*/
private Map<String, Object> buildRequestBody(RemoteProperties remote,
String token) {
Map<String, Object> body = new LinkedHashMap<>();
remote.getBody().forEach((key, value) -> {
if (value != null) {
body.put(key, value);
}
});
body.put("x-token", token);
return body;
}
}

View File

@@ -0,0 +1,19 @@
package com.zt.plat.module.system.service.sso.client;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
/**
* 定义外部身份源拉取用户信息的能力。
*/
public interface ExternalSsoClient {
/**
* 根据外部令牌获取用户详情。
*
* @param token 原始令牌
* @param request 请求参数
* @return 外部用户信息
*/
ExternalSsoUserInfo fetchUserInfo(String token, ExternalSsoVerifyReqVO request);
}

View File

@@ -0,0 +1,29 @@
package com.zt.plat.module.system.service.sso.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.util.integration.ShareServiceProperties;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* 注册外部 SSO 客户端默认实现的配置类,允许业务自行覆盖。
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ShareServiceProperties.class)
public class ExternalSsoClientConfiguration {
@Bean
@ConditionalOnMissingBean(ExternalSsoClient.class)
public ExternalSsoClient externalSsoClient(ExternalSsoProperties properties,
ObjectMapper objectMapper,
RestTemplateBuilder restTemplateBuilder,
ShareServiceProperties shareServiceProperties,
StringRedisTemplate stringRedisTemplate) {
return new DefaultExternalSsoClient(properties, objectMapper, restTemplateBuilder, shareServiceProperties, stringRedisTemplate);
}
}

View File

@@ -0,0 +1,33 @@
package com.zt.plat.module.system.service.sso.client;
/**
* 外部 SSO 客户端在获取用户信息失败时抛出的异常。
*/
public class ExternalSsoClientException extends RuntimeException {
private final String responseBody;
public ExternalSsoClientException(String message) {
super(message);
this.responseBody = null;
}
public ExternalSsoClientException(String message, Throwable cause) {
super(message, cause);
this.responseBody = null;
}
public ExternalSsoClientException(String message, String responseBody) {
super(message);
this.responseBody = responseBody;
}
public ExternalSsoClientException(String message, Throwable cause, String responseBody) {
super(message, cause);
this.responseBody = responseBody;
}
public String getResponseBody() {
return responseBody;
}
}

View File

@@ -0,0 +1,30 @@
package com.zt.plat.module.system.service.sso.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.HashMap;
import java.util.Map;
/**
* 外部系统返回的用户信息标准化模型。
*/
@Data
@Accessors(chain = true)
public class ExternalSsoUserInfo {
private String username;
private String nickname;
private String email;
private String mobile;
private Long tenantId;
private Map<String, Object> attributes = new HashMap<>();
public ExternalSsoUserInfo addAttribute(String key, Object value) {
if (attributes == null) {
attributes = new HashMap<>();
}
attributes.put(key, value);
return this;
}
}

View File

@@ -0,0 +1,103 @@
package com.zt.plat.module.system.service.sso.strategy;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.tenant.core.util.TenantUtils;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties.MatchField;
import com.zt.plat.module.system.framework.sso.config.ExternalSsoProperties.MappingProperties;
import com.zt.plat.module.system.service.sso.client.ExternalSsoClient;
import com.zt.plat.module.system.service.sso.client.ExternalSsoClientException;
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
import com.zt.plat.module.system.service.user.AdminUserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 默认的外部单点登录策略,基于 {@link ExternalSsoProperties} 配置实现。
*/
@Slf4j
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
@RequiredArgsConstructor
public class DefaultExternalSsoStrategy implements ExternalSsoStrategy {
private final ExternalSsoProperties properties;
private final ExternalSsoClient externalSsoClient;
private final AdminUserService adminUserService;
@Override
public boolean supports(String sourceSystem) {
String expected = properties.getSystemCode();
if (!StringUtils.hasText(sourceSystem)) {
return true;
}
if (!StringUtils.hasText(expected)) {
return true;
}
return StrUtil.equalsIgnoreCase(sourceSystem.trim(), expected);
}
@Override
public ExternalSsoUserInfo fetchExternalUser(ExternalSsoVerifyReqVO reqVO) {
ExternalSsoUserInfo info = externalSsoClient.fetchUserInfo(reqVO.getToken(), reqVO);
if (info == null) {
throw new ExternalSsoClientException("外部接口未返回用户信息");
}
return info;
}
@Override
public AdminUserDO resolveLocalUser(ExternalSsoUserInfo externalUser, ExternalSsoVerifyReqVO reqVO) {
MappingProperties mapping = properties.getMapping();
List<MatchField> fields = mapping.getOrder();
if (CollectionUtils.isEmpty(fields)) {
fields = List.of(MatchField.USERNAME, MatchField.MOBILE);
}
AdminUserDO user = null;
for (MatchField field : fields) {
switch (field) {
case USERNAME -> user = findUserByUsername(externalUser.getUsername(), mapping.isIgnoreCase());
case MOBILE -> user = findUserByMobile(externalUser.getMobile());
default -> {
}
}
if (user != null) {
break;
}
}
return user;
}
private AdminUserDO findUserByUsername(String username, boolean ignoreCase) {
if (!StringUtils.hasText(username)) {
return null;
}
AdminUserDO user = adminUserService.getUserByUsername(username);
if (user == null && ignoreCase) {
AdminUserDO candidate = TenantUtils.executeIgnore(() -> {
List<AdminUserDO> list = adminUserService.getUserListByNickname(username);
return list.stream().filter(item -> StrUtil.equalsIgnoreCase(item.getUsername(), username)).findFirst().orElse(null);
});
if (candidate != null) {
user = candidate;
}
}
return user;
}
private AdminUserDO findUserByMobile(String mobile) {
if (!StringUtils.hasText(mobile)) {
return null;
}
return adminUserService.getUserByMobile(mobile);
}
}

View File

@@ -0,0 +1,37 @@
package com.zt.plat.module.system.service.sso.strategy;
import com.zt.plat.module.system.controller.admin.sso.vo.ExternalSsoVerifyReqVO;
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.service.sso.dto.ExternalSsoUserInfo;
import org.springframework.lang.Nullable;
/**
* 定义外部单点登录在不同来源系统下的处理策略。
*/
public interface ExternalSsoStrategy {
/**
* 判断当前策略是否适用于指定来源系统。
*
* @param sourceSystem 来源系统标识,可能为空
* @return 是否支持
*/
boolean supports(@Nullable String sourceSystem);
/**
* 拉取并构造外部用户信息。
*
* @param reqVO 请求参数
* @return 外部用户信息,不能为空
*/
ExternalSsoUserInfo fetchExternalUser(ExternalSsoVerifyReqVO reqVO);
/**
* 根据外部用户信息匹配本地账号。
*
* @param externalUser 外部用户信息
* @param reqVO 请求参数
* @return 匹配到的用户,找不到时返回 {@code null}
*/
AdminUserDO resolveLocalUser(ExternalSsoUserInfo externalUser, ExternalSsoVerifyReqVO reqVO);
}

View File

@@ -58,6 +58,15 @@ public class UserSyncServiceImpl implements UserSyncService {
saveReqVO.setPassword("Zgty@9527");
// 设置为同步用户
saveReqVO.setUserSource(UserSourceEnum.SYNC.getSource());
// 处理岗位名称字段
if (StrUtil.isNotBlank(requestVO.getPostName())) {
Long postId = postService.getOrCreatePostByName(requestVO.getPostName());
if (postId != null) {
Set<Long> postIds = saveReqVO.getPostIds() != null ? new HashSet<>(saveReqVO.getPostIds()) : new HashSet<>();
postIds.add(postId);
saveReqVO.setPostIds(postIds);
}
}
Long userId = adminUserService.createUser(saveReqVO);
UserCreateResponseVO resp = new UserCreateResponseVO();
resp.setUid(String.valueOf(userId));

View File

@@ -4,6 +4,10 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.google.common.annotations.VisibleForTesting;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.pojo.CompanyDeptInfo;
import com.zt.plat.framework.common.pojo.PageResult;
@@ -23,16 +27,13 @@ import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.dal.dataobject.userdept.UserDeptDO;
import com.zt.plat.module.system.dal.mysql.dept.UserPostMapper;
import com.zt.plat.module.system.dal.mysql.user.AdminUserMapper;
import com.zt.plat.module.system.enums.user.UserSourceEnum;
import com.zt.plat.module.system.service.dept.DeptService;
import com.zt.plat.module.system.service.dept.PostService;
import com.zt.plat.module.system.service.permission.PermissionService;
import com.zt.plat.module.system.service.tenant.TenantService;
import com.zt.plat.module.system.service.userdept.UserDeptService;
import com.zt.plat.module.system.enums.user.UserSourceEnum;
import com.google.common.annotations.VisibleForTesting;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import org.apache.seata.spring.annotation.GlobalTransactional;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
@@ -93,6 +94,7 @@ public class AdminUserServiceImpl implements AdminUserService {
private UserDeptService userDeptService;
@Override
@GlobalTransactional(rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
@LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_CREATE_SUB_TYPE, bizNo = "{{#user.id}}",
success = SYSTEM_USER_CREATE_SUCCESS)
@@ -323,12 +325,18 @@ public class AdminUserServiceImpl implements AdminUserService {
@Override
public AdminUserDO getUserByUsername(String username) {
AdminUserDO user = userMapper.selectByUsername(username);
if (user != null) {
fillUserDeptInfo(Collections.singletonList(user));
}
return user;
}
@Override
public AdminUserDO getUserByMobile(String mobile) {
AdminUserDO user = userMapper.selectByMobile(mobile);
if (user != null) {
fillUserDeptInfo(Collections.singletonList(user));
}
return user;
}
@@ -386,7 +394,9 @@ public class AdminUserServiceImpl implements AdminUserService {
}
// 查询用户信息
Set<Long> userIds = convertSet(validUserDeptListByDeptIds, UserDeptDO::getUserId);
return userMapper.selectList("id", userIds);
List<AdminUserDO> users = userMapper.selectList("id", userIds);
fillUserDeptInfo(users);
return users;
}
@Override
@@ -399,6 +409,7 @@ public class AdminUserServiceImpl implements AdminUserService {
return Collections.emptyList();
}
List<AdminUserDO> users = userMapper.selectBatchIds(userIds);
fillUserDeptInfo(users);
return users;
}
@@ -408,6 +419,7 @@ public class AdminUserServiceImpl implements AdminUserService {
return Collections.emptyList();
}
List<AdminUserDO> users = userMapper.selectListByIds(ids);
fillUserDeptInfo(users);
return users;
}
@@ -434,6 +446,7 @@ public class AdminUserServiceImpl implements AdminUserService {
@Override
public List<AdminUserDO> getUserListByNickname(String nickname) {
List<AdminUserDO> users = userMapper.selectListByNickname(nickname);
fillUserDeptInfo(users);
return users;
}
@@ -603,6 +616,7 @@ public class AdminUserServiceImpl implements AdminUserService {
@Override
public List<AdminUserDO> getUserListByStatus(Integer status) {
List<AdminUserDO> users = userMapper.selectListByStatus(status);
fillUserDeptInfo(users);
return users;
}

View File

@@ -103,6 +103,28 @@ spring:
easy-trans:
is-enable-global: true # 启用全局翻译(拦截所有 SpringMVC ResponseBody 进行自动翻译 )。如果对于性能要求很高可关闭此配置,或通过 @IgnoreTrans 忽略某个接口
--- #################### iWork 集成配置 ####################
iwork:
enabled: true
base-url: http://172.16.36.233:8080
app-id:
client-public-key:
user-id:
workflow-id:
paths:
register: /api/ec/dev/auth/regist
apply-token: /api/ec/dev/auth/applytoken
user-info: /api/workflow/paService/getUserInfo
create-workflow: /api/workflow/paService/doCreateRequest
void-workflow: /api/workflow/paService/doCancelRequest
token:
ttl-seconds: 3600
refresh-ahead-seconds: 60
client:
connect-timeout: 5s
response-timeout: 30s
--- #################### RPC 远程调用相关配置 ####################
--- #################### 消息队列相关 ####################
@@ -236,7 +258,7 @@ sync:
eplat:
share:
url-prefix: https://10.1.7.110
url-prefix: http://10.1.7.110
client-id: ztjgj5gsJ2uU20900h9j
client-secret: DC82AD38EA764719B6DC7D71AAB4856C
scope: read