diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/TenantAdminApplication.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/TenantAdminApplication.java index 4ddd909..ab99caa 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/TenantAdminApplication.java +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/TenantAdminApplication.java @@ -4,8 +4,10 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.EnableScheduling; /** @@ -26,4 +28,10 @@ public class TenantAdminApplication { } + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + System.out.println("\n=============================================="); + System.out.println("============= 项目启动成功 SUCCESS ============"); + System.out.println("==============================================\n"); + } } diff --git a/common/common-core/pom.xml b/common/common-core/pom.xml new file mode 100644 index 0000000..5662a73 --- /dev/null +++ b/common/common-core/pom.xml @@ -0,0 +1,136 @@ + + + + apelet + common + 1.0.0 + + 4.0.0 + + common-core + 1.0.1 + common-core + jar + + + + + org.springframework.boot + spring-boot-starter-web + + + + com.google.guava + guava + ${guava.version} + + + org.apache.commons + commons-lang3 + + + commons-io + commons-io + ${commons-io.version} + + + joda-time + joda-time + ${joda-time.version} + + + org.apache.commons + commons-collections4 + ${commons-collections4.version} + + + org.apache.commons + commons-csv + ${common-csv.version} + + + cn.hutool + hutool-all + ${hutool.version} + + + io.jsonwebtoken + jjwt + ${jjwt.version} + + + com.alibaba + fastjson + ${fastjson.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + cn.jimmyshi + bean-query + ${bean.query.version} + + + + org.apache.poi + poi-ooxml + ${poi-ooxml.version} + + + + mysql + mysql-connector-java + 8.0.22 + + + com.alibaba + druid-spring-boot-starter + ${druid.version} + + + com.sun + jconsole + + + com.sun + tools + + + + + com.baomidou + mybatis-plus-boot-starter + ${mybatisplus.version} + + + com.github.pagehelper + pagehelper-spring-boot-starter + ${pagehelper.version} + + + cn.afterturn + easypoi-annotation + 4.4.0 + compile + + + + com.github.krrr + mcp-java8-sdk + 0.8.1 + + + + + + jitpack.io + https://jitpack.io + true + true + + + diff --git a/common/common-core/src/main/java/apelet/common/core/advice/MyControllerAdvice.java b/common/common-core/src/main/java/apelet/common/core/advice/MyControllerAdvice.java new file mode 100644 index 0000000..ce9e84c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/advice/MyControllerAdvice.java @@ -0,0 +1,31 @@ +package apelet.common.core.advice; + +import apelet.common.core.util.MyDateUtil; +import org.springframework.beans.propertyeditors.CustomDateEditor; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.InitBinder; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Controller的环绕拦截类。 + * + * @author guifc + * @date 2023-08-04 + */ +@ControllerAdvice +public class MyControllerAdvice { + + /** + * 转换前端传入的日期变量参数为指定格式。 + * + * @param binder 数据绑定参数。 + */ + @InitBinder + public void initBinder(WebDataBinder binder) { + binder.registerCustomEditor(Date.class, + new CustomDateEditor(new SimpleDateFormat(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT), false)); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/advice/MyExceptionHandler.java b/common/common-core/src/main/java/apelet/common/core/advice/MyExceptionHandler.java new file mode 100644 index 0000000..09b668c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/advice/MyExceptionHandler.java @@ -0,0 +1,167 @@ +package apelet.common.core.advice; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.exception.*; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.util.ContextUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.exceptions.PersistenceException; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.PermissionDeniedDataAccessException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.concurrent.TimeoutException; + +/** + * 业务层的异常处理类,这里只是给出最通用的Exception的捕捉,今后可以根据业务需要, + * 用不同的函数,处理不同类型的异常。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +@RestControllerAdvice("apelet") +public class MyExceptionHandler { + + /** + * 通用异常处理方法。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = Exception.class) + public ResponseResult exceptionHandle(Exception ex, HttpServletRequest request) { + log.error("Unhandled exception from URL [" + request.getRequestURI() + "]", ex); + ContextUtil.getHttpResponse().setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return ResponseResult.error(ErrorCodeEnum.UNHANDLED_EXCEPTION, ex.getMessage()); + } + + /** + * 无效的实体对象异常。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = InvalidDataModelException.class) + public ResponseResult invalidDataModelExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("InvalidDataModelException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.INVALID_DATA_MODEL); + } + + /** + * 无效的实体对象字段异常。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = InvalidDataFieldException.class) + public ResponseResult invalidDataFieldExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("InvalidDataFieldException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.INVALID_DATA_FIELD); + } + + /** + * 无效类字段异常。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = InvalidClassFieldException.class) + public ResponseResult invalidClassFieldExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("InvalidClassFieldException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.INVALID_CLASS_FIELD); + } + + /** + * 重复键异常处理方法。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = DuplicateKeyException.class) + public ResponseResult duplicateKeyExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("DuplicateKeyException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.DUPLICATED_UNIQUE_KEY); + } + + /** + * 数据访问失败异常处理方法。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = DataAccessException.class) + public ResponseResult dataAccessExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("DataAccessException exception from URL [" + request.getRequestURI() + "]", ex); + if (ex.getCause() instanceof PersistenceException + && ex.getCause().getCause() instanceof PermissionDeniedDataAccessException) { + return ResponseResult.error(ErrorCodeEnum.DATA_PERM_ACCESS_FAILED); + } + return ResponseResult.error(ErrorCodeEnum.DATA_ACCESS_FAILED); + } + + /** + * 操作不存在或已逻辑删除数据的异常处理方法。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = NoDataAffectException.class) + public ResponseResult noDataEffectExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("NoDataAffectException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + + /** + * 数据权限异常。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = NoDataPermException.class) + public ResponseResult noDataPermExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("NoDataPermException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.DATA_PERM_ACCESS_FAILED, ex.getMessage()); + } + + /** + * 自定义运行时异常。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = MyRuntimeException.class) + public ResponseResult myRuntimeExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("MyRuntimeException exception from URL [" + request.getRequestURI() + "]", ex); + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, ex.getMessage()); + } + + /** + * Redis缓存访问异常处理方法。 + * + * @param ex 异常对象。 + * @param request http请求。 + * @return 应答对象。 + */ + @ExceptionHandler(value = RedisCacheAccessException.class) + public ResponseResult redisCacheAccessExceptionHandle(Exception ex, HttpServletRequest request) { + log.error("RedisCacheAccessException exception from URL [" + request.getRequestURI() + "]", ex); + if (ex.getCause() instanceof TimeoutException) { + return ResponseResult.error(ErrorCodeEnum.REDIS_CACHE_ACCESS_TIMEOUT); + } + return ResponseResult.error(ErrorCodeEnum.REDIS_CACHE_ACCESS_STATE_ERROR); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/DeptFilterColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/DeptFilterColumn.java new file mode 100644 index 0000000..722d37e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/DeptFilterColumn.java @@ -0,0 +1,16 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 主要用于标记数据权限中基于DeptId进行过滤的字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DeptFilterColumn { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/DisableDataFilter.java b/common/common-core/src/main/java/apelet/common/core/annotation/DisableDataFilter.java new file mode 100644 index 0000000..69f3c2a --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/DisableDataFilter.java @@ -0,0 +1,17 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 作为DisableDataFilterAspect的切点。 + * 该注解标记的方法内所有的查询语句,均不会被Mybatis拦截器过滤数据。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DisableDataFilter { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/DisableTenantFilter.java b/common/common-core/src/main/java/apelet/common/core/annotation/DisableTenantFilter.java new file mode 100644 index 0000000..e512fc2 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/DisableTenantFilter.java @@ -0,0 +1,28 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 仅用于微服务的多租户项目。 + * 用于注解DAO层Mapper对象的租户过滤规则。被包含的方法将不会进行租户Id的过滤。 + * 对于tk mapper和mybatis plus中的内置方法,可以直接指定方法名即可,如:selectOne。 + * 需要说明的是,在大多数场景下,只要在实体对象中指定了租户Id字段,基于该主表的绝大部分增删改操作, + * 都需要经过租户Id过滤,仅当查询非常复杂,或者主表不在SQL语句之中的时候,可以通过该注解禁用该SQL, + * 并根据需求通过手动的方式实现租户过滤。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DisableTenantFilter { + + /** + * 包含的方法名称数组。该值不能为空,因为如想取消所有方法的租户过滤, + * 可以通过在实体对象中不指定租户Id字段注解的方式实现。 + * + * @return 被包括的方法名称数组。 + */ + String[] includeMethodName(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/EnableDataPerm.java b/common/common-core/src/main/java/apelet/common/core/annotation/EnableDataPerm.java new file mode 100644 index 0000000..d46386c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/EnableDataPerm.java @@ -0,0 +1,35 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 用于注解DAO层Mapper对象的数据权限规则。 + * 由于框架使用了tk.mapper,所以并非所有的Mapper接口均在当前Mapper对象中定义,有一部分被tk.mapper封装,如selectAll等。 + * 如果需要排除tk.mapper中的方法,可以直接使用tk.mapper基类所声明的方法名称即可。 + * 另外,比较特殊的场景是,因为tk.mapper是通用框架,所以同样的selectAll方法,可以获取不同的数据集合,因此在service中如果 + * 出现两个不同的方法调用Mapper的selectAll方法,但是一个需要参与过滤,另外一个不需要参与,那么就需要修改当前类的Mapper方法, + * 将其中一个方法重新定义一个具体的接口方法,并重新设定其是否参与数据过滤。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface EnableDataPerm { + + /** + * 排除的方法名称数组。如果为空,所有的方法均会被Mybaits拦截注入权限过滤条件。 + * + * @return 被排序的方法名称数据。 + */ + String[] excluseMethodName() default {}; + + /** + * 必须包含能看用户自己数据的数据过滤条件,如果当前用户的数据过滤中,没有DataPermRuleType.TYPE_USER_ONLY, + * 在进行数据权限过滤时,会自动包含该权限。 + * + * @return 是否必须包含DataPermRuleType.TYPE_USER_ONLY类型的数据权限。 + */ + boolean mustIncludeUserRule() default false; +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/FlowLatestApprovalStatusColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/FlowLatestApprovalStatusColumn.java new file mode 100644 index 0000000..6b6f313 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/FlowLatestApprovalStatusColumn.java @@ -0,0 +1,16 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 业务表中记录流程最后审批状态标记的字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface FlowLatestApprovalStatusColumn { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/FlowStatusColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/FlowStatusColumn.java new file mode 100644 index 0000000..d66bd15 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/FlowStatusColumn.java @@ -0,0 +1,16 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 业务表中记录流程实例结束标记的字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface FlowStatusColumn { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/JobUpdateTimeColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/JobUpdateTimeColumn.java new file mode 100644 index 0000000..a145db8 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/JobUpdateTimeColumn.java @@ -0,0 +1,16 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 主要用于标记Job实体对象的更新时间字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface JobUpdateTimeColumn { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/MaskField.java b/common/common-core/src/main/java/apelet/common/core/annotation/MaskField.java new file mode 100644 index 0000000..b008847 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/MaskField.java @@ -0,0 +1,50 @@ +package apelet.common.core.annotation; + +import apelet.common.core.constant.MaskFieldTypeEnum; +import apelet.common.core.util.MaskFieldHandler; + +import java.lang.annotation.*; + +/** + * 脱敏字段注解。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MaskField { + + /** + * 脱敏类型。 + * + * @return 脱敏类型。 + */ + MaskFieldTypeEnum maskType(); + /** + * 掩码符号。 + * + * @return 掩码符号。 + */ + char maskChar() default '*'; + /** + * 前面noMaskPrefix数量的字符不被掩码。 + * 掩码类型为MaskFieldTypeEnum.ID_CARD时可用。 + * + * @return 从1开始计算,前面不被掩码的字符数。 + */ + int noMaskPrefix() default 1; + /** + * 末尾noMaskSuffix数量的字符不被掩码。 + * 掩码类型为MaskFieldTypeEnum.ID_CARD时可用。 + * + * @return 从1开始计算,末尾不被掩码的字符数。 + */ + int noMaskSuffix() default 1; + /** + * 自定义脱敏处理器接口的Class。 + * @return 自定义脱敏处理器接口的Class。 + */ + Class handler() default MaskFieldHandler.class; +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/MultiDatabaseWriteMethod.java b/common/common-core/src/main/java/apelet/common/core/annotation/MultiDatabaseWriteMethod.java new file mode 100644 index 0000000..c33cb1b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/MultiDatabaseWriteMethod.java @@ -0,0 +1,18 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 该注解通常标记于Service中的事务方法,并且会和@Transactional注解同时存在。 + * 被注解标注的方法内代码,通常通过mybatis,并在同一个事务内访问数据库。与此同时还会存在基于 + * JDBC的跨库操作。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MultiDatabaseWriteMethod { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/MyDataSource.java b/common/common-core/src/main/java/apelet/common/core/annotation/MyDataSource.java new file mode 100644 index 0000000..a8841aa --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/MyDataSource.java @@ -0,0 +1,21 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 主要用于标记Service所依赖的数据源类型。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MyDataSource { + + /** + * 标注的数据源类型 + * @return 当前标注的数据源类型。 + */ + int value(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/MyDataSourceResolver.java b/common/common-core/src/main/java/apelet/common/core/annotation/MyDataSourceResolver.java new file mode 100644 index 0000000..fda3c97 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/MyDataSourceResolver.java @@ -0,0 +1,35 @@ +package apelet.common.core.annotation; + +import apelet.common.core.util.DataSourceResolver; + +import java.lang.annotation.*; + +/** + * 基于自定义解析规则的多数据源注解。主要用于标注Service的实现类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MyDataSourceResolver { + + /** + * 多数据源路由键解析接口的Class。 + * @return 多数据源路由键解析接口的Class。 + */ + Class resolver(); + + /** + * DataSourceResolver.resovle方法的入参。 + * @return DataSourceResolver.resovle方法的入参。 + */ + String arg() default ""; + + /** + * 数值型参数。 + * @return DataSourceResolver.resovle方法的入参。 + */ + int intArg() default -1; +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/MyRequestBody.java b/common/common-core/src/main/java/apelet/common/core/annotation/MyRequestBody.java new file mode 100644 index 0000000..ffea50f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/MyRequestBody.java @@ -0,0 +1,23 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 标记Controller中的方法参数,参数解析器会根据该注解将请求中的JSON数据,映射到参数中的绑定字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MyRequestBody { + + /** + * 是否必须出现的参数。 + */ + boolean required() default false; + /** + * 解析时用到的JSON的key。 + */ + String value() default ""; +} \ No newline at end of file diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/NoAuthInterface.java b/common/common-core/src/main/java/apelet/common/core/annotation/NoAuthInterface.java new file mode 100644 index 0000000..966bb42 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/NoAuthInterface.java @@ -0,0 +1,15 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 主要用于标记无需Token验证的接口 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface NoAuthInterface { +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationConstDict.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationConstDict.java new file mode 100644 index 0000000..5cebea7 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationConstDict.java @@ -0,0 +1,29 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 标识Model和常量字典之间的关联关系。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationConstDict { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 被关联的常量字典的Class对象。 + * + * @return 关联的常量字典的Class对象。 + */ + Class constantDictClass(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationDict.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationDict.java new file mode 100644 index 0000000..d4a46b1 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationDict.java @@ -0,0 +1,77 @@ +package apelet.common.core.annotation; + +import apelet.common.core.object.DummyClass; + +import java.lang.annotation.*; + +/** + * 标识Model之间的字典关联关系。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationDict { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 被关联Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class slaveModelClass(); + + /** + * 被关联Model对象的关联Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String slaveIdField(); + + /** + * 被关联Model对象的关联Name字段名称。 + * + * @return 被关联Model对象的关联Name字段名称。 + */ + String slaveNameField(); + + /** + * 被关联的远程调用对象的Class对象。 + * + * @return 被关联远程调用对象的Class对象。 + */ + Class slaveClientClass() default DummyClass.class; + + /** + * 被关联的本地Service对象名称。 + * 该参数的优先级高于 slaveService(),如果定义了该值,会优先使用加载service的bean对象。 + * + * @return 被关联的本地Service对象名称。 + */ + String slaveServiceName() default ""; + + /** + * 被关联的本地Service对象CLass类型。 + * + * @return 被关联的本地Service对象CLass类型。 + */ + Class slaveServiceClass() default DummyClass.class; + + /** + * 在同一个实体对象中,如果有一对一关联和字典关联,都是基于相同的主表字段,并关联到 + * 相同关联表的同一关联字段时,可以在字典关联的注解中引用被一对一注解标准的对象属性。 + * 从而在数据整合时,当前字典的数据可以直接取自"equalOneToOneRelationField"指定 + * 的字段,从而避免一次没必要的数据库查询操作,提升了加载显示的效率。 + * + * @return 与该字典字段引用关系完全相同的一对一关联属性名称。 + */ + String equalOneToOneRelationField() default ""; +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationGlobalDict.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationGlobalDict.java new file mode 100644 index 0000000..358256f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationGlobalDict.java @@ -0,0 +1,29 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 全局字典关联。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationGlobalDict { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 全局字典编码。 + * + * @return 全局字典编码。空表示为不使用全局字典。 + */ + String dictCode(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationManyToMany.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationManyToMany.java new file mode 100644 index 0000000..bdc5520 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationManyToMany.java @@ -0,0 +1,39 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 标注多对多的Model关系。 + * 重要提示:由于多对多关联表数据,很多时候都不需要跟随主表数据返回,所以该注解不会在 + * 生成的时候自动添加到实体类字段上,需要的时候,用户可自行手动添加。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationManyToMany { + + /** + * 多对多中间表的Mapper对象名称。 + * 如果是空字符串,BaseService会自动拼接为 relationModelClass().getSimpleName() + "Mapper"。 + * + * @return 被关联的本地Service对象名称。 + */ + String relationMapperName() default ""; + + /** + * 多对多关联表Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class relationModelClass(); + + /** + * 多对多关联表Model对象中与主表关联的Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String relationMasterIdField(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationManyToManyAggregation.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationManyToManyAggregation.java new file mode 100644 index 0000000..ce29dcb --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationManyToManyAggregation.java @@ -0,0 +1,102 @@ +package apelet.common.core.annotation; + +import apelet.common.core.object.DummyClass; + +import java.lang.annotation.*; + +/** + * 主要用于多对多的Model关系。标注通过从表关联字段或者关联表关联字段计算主表聚合计算字段的规则。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationManyToManyAggregation { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 被关联的本地Service对象名称。 + * 该参数的优先级高于 slaveService(),如果定义了该值,会优先使用加载service的bean对象。 + * + * @return 被关联的本地Service对象名称。 + */ + String slaveServiceName() default ""; + + /** + * 被关联的本地Service对象CLass类型。 + * + * @return 被关联的本地Service对象CLass类型。 + */ + Class slaveServiceClass() default DummyClass.class; + + /** + * 多对多从表Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class slaveModelClass(); + + /** + * 多对多从表Model对象的关联Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String slaveIdField(); + + /** + * 被关联远程调用对象的Class对象。如果为DummyClass.class,通常表示是本地关联。 + * + * @return 被关联远程调用对象的Class对象。 + */ + Class slaveClientClass() default DummyClass.class; + + /** + * 多对多关联表Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class relationModelClass(); + + /** + * 多对多关联表Model对象中与主表关联的Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String relationMasterIdField(); + + /** + * 多对多关联表Model对象中与从表关联的Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String relationSlaveIdField(); + + /** + * 聚合计算所在的Model。 + * + * @return 聚合计算所在Model的Class。 + */ + Class aggregationModelClass(); + + /** + * 聚合类型。具体数值参考AggregationType对象。 + * + * @return 聚合类型。 + */ + int aggregationType(); + + /** + * 聚合计算所在Model的字段名称。 + * + * @return 聚合计算所在Model的字段名称。 + */ + String aggregationField(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToMany.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToMany.java new file mode 100644 index 0000000..6baba7f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToMany.java @@ -0,0 +1,53 @@ +package apelet.common.core.annotation; + +import apelet.common.core.object.DummyClass; + +import java.lang.annotation.*; + +/** + * 标识Model之间的一对多关联关系。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationOneToMany { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 被关联Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class slaveModelClass(); + + /** + * 被关联Model对象的关联Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String slaveIdField(); + + /** + * 被关联的本地Service对象名称。 + * 该参数的优先级高于 slaveService(),如果定义了该值,会优先使用加载service的bean对象。 + * + * @return 被关联的本地Service对象名称。 + */ + String slaveServiceName() default ""; + + /** + * 被关联的本地Service对象CLass类型。 + * + * @return 被关联的本地Service对象CLass类型。 + */ + Class slaveServiceClass() default DummyClass.class; +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToManyAggregation.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToManyAggregation.java new file mode 100644 index 0000000..fa96db5 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToManyAggregation.java @@ -0,0 +1,74 @@ +package apelet.common.core.annotation; + +import apelet.common.core.object.DummyClass; + +import java.lang.annotation.*; + +/** + * 主要用于一对多的Model关系。标注通过从表关联字段计算主表聚合计算字段的规则。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationOneToManyAggregation { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 被关联的本地Service对象名称。 + * 该参数的优先级高于 slaveService(),如果定义了该值,会优先使用加载service的bean对象。 + * + * @return 被关联的本地Service对象名称。 + */ + String slaveServiceName() default ""; + + /** + * 被关联的本地Service对象CLass类型。 + * + * @return 被关联的本地Service对象CLass类型。 + */ + Class slaveServiceClass() default DummyClass.class; + + /** + * 被关联Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class slaveModelClass(); + + /** + * 被关联Model对象的关联Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String slaveIdField(); + + /** + * 被关联远程调用对象的Class对象。如果为DummyClass.class,通常表示是本地关联。 + * + * @return 被关联远程调用对象的Class对象。 + */ + Class slaveClientClass() default DummyClass.class; + + /** + * 被关联Model对象中参与计算的聚合类型。具体数值参考AggregationType对象。 + * + * @return 被关联Model对象中参与计算的聚合类型。 + */ + int aggregationType(); + + /** + * 被关联Model对象中参与聚合计算的字段名称。 + * + * @return 被关联Model对象中参与计算字段的名称。 + */ + String aggregationField(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToOne.java b/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToOne.java new file mode 100644 index 0000000..7c27a4e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/RelationOneToOne.java @@ -0,0 +1,67 @@ +package apelet.common.core.annotation; + +import apelet.common.core.object.DummyClass; + +import java.lang.annotation.*; + +/** + * 标识Model之间的一对一关联关系。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RelationOneToOne { + + /** + * 当前对象的关联Id字段名称。 + * + * @return 当前对象的关联Id字段名称。 + */ + String masterIdField(); + + /** + * 被关联Model对象的Class对象。 + * + * @return 被关联Model对象的Class对象。 + */ + Class slaveModelClass(); + + /** + * 被关联Model对象的关联Id字段名称。 + * + * @return 被关联Model对象的关联Id字段名称。 + */ + String slaveIdField(); + + /** + * 被关联远程调用对象的Class对象。 + * + * @return 被关联远程调用对象的Class对象。 + */ + Class slaveClientClass() default DummyClass.class; + + /** + * 被关联的本地Service对象名称。 + * 该参数的优先级高于 slaveService(),如果定义了该值,会优先使用加载service的bean对象。 + * + * @return 被关联的本地Service对象名称。 + */ + String slaveServiceName() default ""; + + /** + * 被关联的本地Service对象CLass类型。 + * + * @return 被关联的本地Service对象CLass类型。 + */ + Class slaveServiceClass() default DummyClass.class; + + /** + * 在一对一关联时,是否加载从表的字典关联。 + * + * @return 是否加载从表的字典关联。true关联,false则只返回从表自身数据。 + */ + boolean loadSlaveDict() default true; +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/TenantFilterColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/TenantFilterColumn.java new file mode 100644 index 0000000..fdfda79 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/TenantFilterColumn.java @@ -0,0 +1,16 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 主要用于标记通过租户Id进行过滤的字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface TenantFilterColumn { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/UploadFlagColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/UploadFlagColumn.java new file mode 100644 index 0000000..6a50392 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/UploadFlagColumn.java @@ -0,0 +1,24 @@ +package apelet.common.core.annotation; + +import apelet.common.core.upload.UploadStoreTypeEnum; + +import java.lang.annotation.*; + +/** + * 用于标记支持数据上传和下载的字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface UploadFlagColumn { + + /** + * 上传数据存储类型。 + * + * @return 上传数据存储类型。 + */ + UploadStoreTypeEnum storeType(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/annotation/UserFilterColumn.java b/common/common-core/src/main/java/apelet/common/core/annotation/UserFilterColumn.java new file mode 100644 index 0000000..1e593aa --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/annotation/UserFilterColumn.java @@ -0,0 +1,16 @@ +package apelet.common.core.annotation; + +import java.lang.annotation.*; + +/** + * 主要用于标记数据权限中基于UserId进行过滤的字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface UserFilterColumn { + +} diff --git a/common/common-core/src/main/java/apelet/common/core/aop/DataSourceAspect.java b/common/common-core/src/main/java/apelet/common/core/aop/DataSourceAspect.java new file mode 100644 index 0000000..7991a3a --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/aop/DataSourceAspect.java @@ -0,0 +1,48 @@ +package apelet.common.core.aop; + +import apelet.common.core.annotation.MyDataSource; +import apelet.common.core.config.DataSourceContextHolder; +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.annotation.Pointcut; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 多数据源AOP切面处理类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Aspect +@Component +@Order(1) +@Slf4j +public class DataSourceAspect { + + /** + * 所有配置MyDataSource注解的Service实现类。 + */ + @Pointcut("execution(public * apelet..service..*(..)) " + + "&& @target(apelet.common.core.annotation.MyDataSource)") + public void datasourcePointCut() { + // 空注释,避免sonar警告 + } + + @Around("datasourcePointCut()") + public Object around(ProceedingJoinPoint point) throws Throwable { + Class clazz = point.getTarget().getClass(); + MyDataSource ds = clazz.getAnnotation(MyDataSource.class); + // 通过判断 DataSource 中的值来判断当前方法应用哪个数据源 + Integer originalType = DataSourceContextHolder.setDataSourceType(ds.value()); + log.debug("set datasource is " + ds.value()); + try { + return point.proceed(); + } finally { + DataSourceContextHolder.unset(originalType); + log.debug("unset datasource is " + originalType); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/aop/DataSourceResolveAspect.java b/common/common-core/src/main/java/apelet/common/core/aop/DataSourceResolveAspect.java new file mode 100644 index 0000000..212e4e4 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/aop/DataSourceResolveAspect.java @@ -0,0 +1,59 @@ +package apelet.common.core.aop; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.config.DataSourceContextHolder; +import apelet.common.core.util.ApplicationContextHolder; +import apelet.common.core.util.DataSourceResolver; +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.annotation.Pointcut; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于自定义解析规则的多数据源AOP切面处理类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Aspect +@Component +@Order(1) +@Slf4j +public class DataSourceResolveAspect { + + private final Map, DataSourceResolver> resolverMap = new ConcurrentHashMap<>(); + + /** + * 所有配置MyDataSourceResovler注解的Service实现类。 + */ + @Pointcut("execution(public * apelet..service..*(..)) " + + "&& @target(apelet.common.core.annotation.MyDataSourceResolver)") + public void datasourceResolverPointCut() { + // 空注释,避免sonar警告 + } + + @Around("datasourceResolverPointCut()") + public Object around(ProceedingJoinPoint point) throws Throwable { + Class clazz = point.getTarget().getClass(); + MyDataSourceResolver dsr = clazz.getAnnotation(MyDataSourceResolver.class); + Class resolverClass = dsr.resolver(); + DataSourceResolver resolver = + resolverMap.computeIfAbsent(resolverClass, ApplicationContextHolder::getBean); + int type = resolver.resolve(dsr.arg(), dsr.intArg(), point.getArgs()); + // 通过判断 DataSource 中的值来判断当前方法应用哪个数据源 + Integer originalType = DataSourceContextHolder.setDataSourceType(type); + log.debug("set datasource is " + type); + try { + return point.proceed(); + } finally { + DataSourceContextHolder.unset(originalType); + log.debug("unset datasource is " + originalType); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/client/BaseClient.java b/common/common-core/src/main/java/apelet/common/core/base/client/BaseClient.java new file mode 100644 index 0000000..1ca09b9 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/client/BaseClient.java @@ -0,0 +1,188 @@ +package apelet.common.core.base.client; + +import apelet.common.core.object.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 远程调用接口。 + * + * @param 主DomainDto域数据对象类型。 + * @param 主DomainVo域数据对象类型。 + * @param 主键类型。 + * @author guifc + * @date 2023-08-04 + */ +public interface BaseClient { + + /** + * 基于主键的(in list)获取远程数据接口。 + * + * @param filterIds 主键Id集合。 + * @param withDict 是否包含字典关联。 + * @return 应答结果对象,包含主对象集合。 + */ + ResponseResult> listByIds(Set filterIds, Boolean withDict); + + /** + * 基于主键Id,获取远程对象。 + * + * @param id 主键Id。 + * @param withDict 是否包含字典关联。 + * @return 应答结果对象,包含主对象数据。 + */ + ResponseResult getById(K id, Boolean withDict); + + /** + * 判断参数列表中指定的主键Id,是否全部存在。 + * + * @param filterIds 主键Id集合。 + * @return 应答结果对象,包含true全部存在,否则false。 + */ + ResponseResult existIds(Set filterIds); + + /** + * 给定主键Id是否存在。 + * + * @param id 主键Id。 + * @return 应答结果对象,包含true表示存在,否则false。 + */ + ResponseResult existId(K id); + + /** + * 保存或更新数据。 + * + * @param data 主键Id为null时表示新增数据,否则更新数据。 + * @return 应答结果对象,主键Id。 + */ + default ResponseResult saveNewOrUpdate(D data) { + throw new UnsupportedOperationException(); + } + + /** + * 批量新增或保存数据列表。 + * + * @param dataList 数据列表。主键Id为null时表示新增数据,否则更新数据。 + * @return 应答结果对象。 + */ + default ResponseResult saveNewOrUpdateBatch(List dataList) { + throw new UnsupportedOperationException(); + } + + /** + * 验证指定数据的关联Id数据是否存合法。 + * + * @param data 数据对象。 + * @return 应答结果对象。 + */ + default ResponseResult verifyRelatedData(D data) { + throw new UnsupportedOperationException(); + } + + /** + * 验证指定数据列表的关联Id数据是否存合法。 + * + * @param dataList 数据对象列表。 + * @return 应答结果对象。 + */ + default ResponseResult verifyRelatedDataList(List dataList) { + throw new UnsupportedOperationException(); + } + + /** + * 删除主键Id关联的对象。 + * + * @param id 主键Id。 + * @return 应答结果对象。 + */ + default ResponseResult deleteById(K id) { + throw new UnsupportedOperationException(); + } + + /** + * 删除符合过滤条件的数据。 + * + * @param filter 过滤对象。 + * @return 应答结果对象,包含删除数量。 + */ + default ResponseResult deleteBy(D filter) { + throw new UnsupportedOperationException(); + } + + /** + * 获取远程主对象中符合查询条件的数据列表。 + * 缺省实现是因为字典类型的远程调用客户端中,不需要实现该方法,因此尽早抛出异常,用户可自行修改。 + * + * @param queryParam 查询参数。 + * @return 分页数据集合对象。如MyQueryParam参数的分页属性为空,则不会执行分页操作,只是基于MyPageData对象返回数据结果。 + */ + default ResponseResult> listBy(MyQueryParam queryParam) { + throw new UnsupportedOperationException(); + } + + /** + * 获取远程主对象中符合查询条件的单条数据对象。 + * 缺省实现是因为字典类型的远程调用客户端中,不需要实现该方法,因此尽早抛出异常,用户可自行修改。 + * + * @param queryParam 查询参数。 + * @return 应答结果对象,包含主对象集合。 + */ + default ResponseResult getBy(MyQueryParam queryParam) { + throw new UnsupportedOperationException(); + } + + /** + * 获取远程主对象中符合查询条件的数据列表。 + * 缺省实现是因为字典类型的远程调用客户端中,不需要实现该方法,因此尽早抛出异常,用户可自行修改。 + * + * @param queryParam 查询参数。 + * @return 分页数据集合对象。如MyQueryParam参数的分页属性为空,则不会执行分页操作,只是基于MyPageData对象返回数据结果。 + */ + default ResponseResult>> listMapBy(MyQueryParam queryParam) { + throw new UnsupportedOperationException(); + } + + /** + * 获取远程主对象中符合查询条件的数据数量。 + * 缺省实现是因为字典类型的远程调用客户端中,不需要实现该方法,因此尽早抛出异常,用户可自行修改。 + * + * @param queryParam 查询参数。 + * @return 应答结果对象,包含结果数量。 + */ + default ResponseResult countBy(MyQueryParam queryParam) { + throw new UnsupportedOperationException(); + } + + /** + * 获取远程主对象中符合过滤条件的分组聚合数据。 + * 缺省实现是因为字典类型的远程调用客户端中,不需要实现该方法,因此尽早抛出异常,用户可自行修改。 + * + * @param aggregationParam 聚合参数。 + * @return 应答结果对象,包含聚合计算后的数据列表。 + */ + default ResponseResult>> aggregateBy(MyAggregationParam aggregationParam) { + throw new UnsupportedOperationException(); + } + + /** + * 根据主键Id及其列表数据(not in list)进行过滤,返回给定的数据。返回的对象数据中,仅仅包含实体对象自己的数据,以及配置的字典关联数据。 + * + * @param queryParam 查询参数。 + * @return 应答结果对象,包含分页查询数据列表。 + */ + default ResponseResult> listByNotInList(MyQueryParam queryParam) { + throw new UnsupportedOperationException(); + } + + /** + * 根据过滤字段和过滤集合,返回不存在的数据。 + * + * @param queryParam 查询参数。 + * @return filterSet中,在从表中不存在的数据集合。 + */ + default ResponseResult> notExist(MyQueryParam queryParam) { + throw new UnsupportedOperationException(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/client/BaseFallbackFactory.java b/common/common-core/src/main/java/apelet/common/core/base/client/BaseFallbackFactory.java new file mode 100644 index 0000000..99350a5 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/client/BaseFallbackFactory.java @@ -0,0 +1,110 @@ +package apelet.common.core.base.client; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.openfeign.FallbackFactory; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * FeignClient 熔断降级处理对象。 + * + * @param 主DomainDto域数据对象类型。 + * @param 主DomainVo域数据对象类型。 + * @param 主键类型。 + * @param Feign客户端对象类型。 + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public abstract class BaseFallbackFactory> + implements FallbackFactory, BaseClient { + + @Override + public ResponseResult> listByIds(Set idSet, Boolean withDict) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult getById(K id, Boolean withDict) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult existIds(Set idSet) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult existId(K id) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult saveNewOrUpdate(D data) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult saveNewOrUpdateBatch(List dataList) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult verifyRelatedData(D data) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult verifyRelatedDataList(List dataList) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult deleteById(K id) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult deleteBy(D filter) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult> listBy(MyQueryParam queryParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult getBy(MyQueryParam queryParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult>> listMapBy(MyQueryParam queryParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult countBy(MyQueryParam queryParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult>> aggregateBy(MyAggregationParam aggregationParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult> listByNotInList(MyQueryParam queryParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } + + @Override + public ResponseResult> notExist(MyQueryParam queryParam) { + return ResponseResult.error(ErrorCodeEnum.RPC_DATA_ACCESS_FAILED); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/controller/BaseController.java b/common/common-core/src/main/java/apelet/common/core/base/controller/BaseController.java new file mode 100644 index 0000000..d0bf5ec --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/controller/BaseController.java @@ -0,0 +1,520 @@ +package apelet.common.core.base.controller; + +import apelet.common.core.base.mapper.BaseModelMapper; +import apelet.common.core.base.service.IBaseService; +import apelet.common.core.config.CoreProperties; +import apelet.common.core.constant.AggregationKind; +import apelet.common.core.constant.AggregationType; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.exception.RemoteDataBuildException; +import apelet.common.core.object.*; +import apelet.common.core.util.MyCommonUtil; +import apelet.common.core.util.MyModelUtil; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.annotation.TableId; +import com.github.pagehelper.Page; +import com.github.pagehelper.page.PageMethod; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.Serializable; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 控制器Controller的基类。 + * + * @param 主Model实体对象类型。 + * @param 主Model的DomainVO域对象类型。 + * @param 主键类型。 + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public abstract class BaseController { + + @Autowired + private CoreProperties coreProperties; + /** + * 当前Service关联的主Model实体对象的Class。 + */ + private final Class modelClass; + /** + * 当前Service关联的主model的VO对象的Class。 + */ + private final Class domainVoClass; + /** + * 当前Service关联的主Model对象主键字段名称。 + */ + private String idFieldName; + + /** + * 获取子类中注入的IBaseService接口。 + * + * @return 子类中注入的BaseService类。 + */ + protected abstract IBaseService service(); + + /** + * 构造函数。 + */ + @SuppressWarnings("unchecked") + protected BaseController() { + modelClass = (Class) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0]; + domainVoClass = (Class) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[1]; + Field[] fields = ReflectUtil.getFields(modelClass); + for (Field field : fields) { + if (null != field.getAnnotation(TableId.class)) { + idFieldName = field.getName(); + break; + } + } + } + + /** + * 根据主键Id集合,获取数据对象集合。仅限于微服务间远程接口调用。 + * + * @param filterIds 主键Id集合。 + * @param withDict 是否包含字典关联。 + * @param modelMapper 对象映射函数对象。如果为空,则使用MyModelUtil中的缺省转换函数。 + * @return 应答结果对象,包含主对象集合。 + * @throws RemoteDataBuildException buildRelationForDataList会抛出该异常。 + */ + public ResponseResult> baseListByIds( + Set filterIds, Boolean withDict, BaseModelMapper modelMapper) { + if (MyCommonUtil.existBlankArgument(filterIds, withDict)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + List resultList = service().getInList(idFieldName, filterIds); + List resultVoList = null; + if (CollUtil.isEmpty(resultList)) { + return ResponseResult.success(resultVoList); + } + if (Boolean.TRUE.equals(withDict)) { + service().buildRelationForDataList(resultList, MyRelationParam.dictOnly()); + } + resultVoList = convertToVoList(resultList, modelMapper); + return ResponseResult.success(resultVoList); + } + + /** + * 根据主键Id,获取数据对象。仅限于微服务间远程接口调用。 + * + * @param id 主键Id。 + * @param withDict 是否包含字典关联。 + * @param modelMapper 对象映射函数对象。如果为空,则使用MyModelUtil中的缺省转换函数。 + * @return 应答结果对象,包含主对象数据。 + * @throws RemoteDataBuildException buildRelationForData会抛出此异常。 + */ + public ResponseResult baseGetById(K id, Boolean withDict, BaseModelMapper modelMapper) { + if (MyCommonUtil.existBlankArgument(id, withDict)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + M resultObject = service().getById(id); + V resultVoObject = null; + if (resultObject == null) { + return ResponseResult.success(resultVoObject); + } + if (Boolean.TRUE.equals(withDict)) { + service().buildRelationForData(resultObject, MyRelationParam.dictOnly()); + } + resultVoObject = this.convertToVo(resultObject, modelMapper); + return ResponseResult.success(resultVoObject); + } + + /** + * 判断参数列表中指定的主键Id集合,是否全部存在。仅限于微服务间远程接口调用。 + * + * @param filterIds 主键Id集合。 + * @return 应答结果对象,包含true全部存在,否则false。 + */ + public ResponseResult baseExistIds(Set filterIds) { + return ResponseResult.success(CollUtil.isNotEmpty(filterIds) + && service().existUniqueKeyList(idFieldName, filterIds)); + } + + /** + * 判断参数列表中指定的主键Id集合,是否全部存在。仅限于微服务间远程接口调用。 + * + * @param id 主键Id。 + * @return 应答结果对象,包含true全部存在,否则false。 + */ + public ResponseResult baseExistId(K id) { + return ResponseResult.success( + !MyCommonUtil.existBlankArgument(id) && service().getById(id) != null); + } + + /** + * 根据最新对象列表和原有对象的数据对比,判断关联的字典数据和多对一主表数据是否都是合法数据。仅限于微服务间远程接口调用。 + * + * @param data 数据对象。 + * 主键有值是视为更新操作的数据比对,因此仅当关联Id变化时才会验证。 + * 主键为空视为新增操作的数据比对,所有关联Id都会被验证。 + * @param idGetter 获取主键值的函数对象。 + * @return 应答结果对象。 + */ + public ResponseResult baseVerifyRelatedData(M data, Function idGetter) { + CallResult result; + K id = idGetter.apply(data); + if (id == null) { + result = service().verifyRelatedData(data, null); + } else { + M originalData = service().getById(id); + if (originalData == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + result = service().verifyRelatedData(data, originalData); + } + return !result.isSuccess() ? ResponseResult.errorFrom(result) : ResponseResult.success(); + } + + /** + * 根据最新对象列表和原有对象列表的数据对比,判断关联的字典数据和多对一主表数据是否都是合法数据。 + * + * @param dataList 数据对象列表。 + * @param idGetter 获取主键值的函数对象。 + * @return 应答结果对象。 + */ + public ResponseResult baseVerifyRelatedDataList(List dataList, Function idGetter) { + if (CollUtil.isEmpty(dataList)) { + return ResponseResult.success(); + } + // 1. 先过滤出数据列表中的主键Id集合。 + Set idList = dataList.stream() + .filter(c -> idGetter.apply(c) != null).map(idGetter).collect(Collectors.toSet()); + // 2. 列表中,我们目前仅支持全部是更新数据,或全部新增数据,不能混着。如果有主键值,说明当前全是更新数据。 + if (CollUtil.isNotEmpty(idList)) { + // 3. 这里是批量读取的优化,用一个主键值得in list查询,一步获取全部原有数据。然后再在内存中基于Map排序。 + List originalList = service().getInList(idList); + Map originalMap = originalList.stream().collect(Collectors.toMap(idGetter, c2 -> c2)); + // 迭代列表,传入当前最新数据和更新前数据进行比对,如果关联数据变化了,就对新数据进行合法性验证。 + for (M data : dataList) { + CallResult result = service().verifyRelatedData(data, originalMap.get(idGetter.apply(data))); + if (!result.isSuccess()) { + return ResponseResult.errorFrom(result); + } + } + } else { + // 4. 迭代列表,传入当前最新数据,对关联数据进行合法性验证。 + for (M model : dataList) { + CallResult result = service().verifyRelatedData(model, null); + if (!result.isSuccess()) { + return ResponseResult.errorFrom(result); + } + } + } + return ResponseResult.success(); + } + + /** + * 删除符合过滤条件的数据。 + * + * @param filter 过滤对象。 + * @return 删除数量。 + */ + public ResponseResult baseDeleteBy(M filter) { + return ResponseResult.success(service().removeBy(filter)); + } + + /** + * 自定义过滤条件、显示字段和排序字段的单表查询。主要用于微服务间远程过程调用。 + * NOTE: 和baseListMapBy方法的差别只是返回的数据形式不同,该接口以对象列表的形式返回数据。 + * + * @param queryParam 查询参数。 + * @param modelMapper 对象映射函数对象。如果为空,则使用MyModelUtil中的缺省转换函数。 + * @return 分页数据集合对象。如MyQueryParam参数的分页属性为空,则不会执行分页操作,只是基于MyPageData对象返回数据结果。 + * @throws RemoteDataBuildException buildRelationForDataList会抛出此异常。 + */ + public ResponseResult> baseListBy(MyQueryParam queryParam, BaseModelMapper modelMapper) { + boolean dataFilterEnabled = GlobalThreadLocal.setDataFilter(queryParam.getUseDataFilter()); + if (CollUtil.isNotEmpty(queryParam.getSelectFieldList())) { + for (String fieldName : queryParam.getSelectFieldList()) { + String columnName = MyModelUtil.mapToColumnName(fieldName, modelClass); + if (columnName == null) { + String errorMessage = "数据验证失败,实体对象 [" + + modelClass.getSimpleName() + "] 中不存在字段 [" + fieldName + "]!"; + return ResponseResult.error(ErrorCodeEnum.INVALID_DATA_FIELD, errorMessage); + } + } + } + M filter = queryParam.getFilterDto(modelClass); + if (StrUtil.isNotBlank(queryParam.getInFilterField()) + && CollUtil.isNotEmpty(queryParam.getInFilterValues())) { + if (queryParam.getCriteriaList() == null) { + queryParam.setCriteriaList(new LinkedList<>()); + } + MyWhereCriteria whereCriteria = new MyWhereCriteria(); + whereCriteria.setFieldName(queryParam.getInFilterField()); + whereCriteria.setOperatorType(MyWhereCriteria.OPERATOR_IN); + whereCriteria.setValue(queryParam.getInFilterValues()); + queryParam.getCriteriaList().add(whereCriteria); + } + String whereClause = MyWhereCriteria.makeCriteriaString(queryParam.getCriteriaList(), modelClass); + whereClause = this.makeupSearchCriteria(queryParam, whereClause); + String orderBy = MyOrderParam.buildOrderBy(queryParam.getOrderParam(), modelClass); + MyPageParam pageParam = queryParam.getPageParam(); + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + List resultList = service().getListByCondition( + queryParam.getSelectFieldList(), filter, whereClause, orderBy); + if (CollUtil.isEmpty(resultList)) { + return ResponseResult.success(MyPageData.emptyPageData()); + } + long totalCount; + if (resultList instanceof Page) { + totalCount = ((Page) resultList).getTotal(); + } else { + totalCount = resultList.size(); + } + if (BooleanUtil.isTrue(queryParam.getWithDict())) { + service().buildRelationForDataList(resultList, MyRelationParam.dictOnly()); + } + service().maskFieldDataList(resultList, queryParam.getIgnoreMaskFieldSet()); + List resultVoList = convertToVoList(resultList, modelMapper); + GlobalThreadLocal.setDataFilter(dataFilterEnabled); + return ResponseResult.success(new MyPageData<>(resultVoList, totalCount)); + } + + /** + * 自定义过滤条件、显示字段和排序字段的单表查询。主要用于微服务间远程过程调用。 + * NOTE: 和baseListBy方法的差别只是返回的数据形式不同,该接口以Map列表的形式返回数据。 + * + * @param queryParam 查询参数。 + * @param modelMapper 对象映射函数对象。如果为空,则使用MyModelUtil中的缺省转换函数。 + * @return 分页数据集合对象。如MyQueryParam参数的分页属性为空,则不会执行分页操作,只是基于MyPageData对象返回数据结果。 + */ + public ResponseResult>> baseListMapBy( + MyQueryParam queryParam, BaseModelMapper modelMapper) { + ResponseResult> result = this.baseListBy(queryParam, modelMapper); + if (!result.isSuccess()) { + return ResponseResult.errorFrom(result); + } + List> resultMapList = + result.getData().getDataList().stream().map(BeanUtil::beanToMap).collect(Collectors.toList()); + return ResponseResult.success(new MyPageData<>(resultMapList, result.getData().getTotalCount())); + } + + /** + * 自定义过滤条件、显示字段,并返回单条记录。主要用于微服务间远程过程调用。 + * + * @param queryParam 查询参数。 + * @param modelMapper 对象映射函数对象。如果为空,则使用MyModelUtil中的缺省转换函数。 + * @return 应答结果对象,包含符合查询过滤条件的单条实体对象。 + */ + public ResponseResult baseGetBy(MyQueryParam queryParam, BaseModelMapper modelMapper) { + ResponseResult> result = baseListBy(queryParam, modelMapper); + if (!result.isSuccess()) { + return ResponseResult.errorFrom(result); + } + List dataList = result.getData().getDataList(); + V data = null; + if (CollUtil.isNotEmpty(dataList)) { + data = dataList.get(0); + } + return ResponseResult.success(data); + } + + /** + * 自定义过滤条件的记录数量统计。主要用于微服务间远程过程调用。 + * + * @param queryParam 查询参数。 + * @return 应答结果对象,包含符合查询过滤条件的记录数量。 + */ + public ResponseResult baseCountBy(MyQueryParam queryParam) { + boolean dataFilterEnabled = GlobalThreadLocal.setDataFilter(queryParam.getUseDataFilter()); + String whereClause = MyWhereCriteria.makeCriteriaString(queryParam.getCriteriaList(), modelClass); + Integer count = service().getCountByCondition(whereClause); + GlobalThreadLocal.setDataFilter(dataFilterEnabled); + return ResponseResult.success(count); + } + + /** + * 获取远程对象中符合查询条件的分组聚合计算Map列表。 + * + * @param param 聚合参数。 + * @return 应该结果对象,包含聚合计算后的分组Map列表。 + */ + public ResponseResult>> baseAggregateBy(MyAggregationParam param) { + boolean dataFilterEnabled = GlobalThreadLocal.setDataFilter(param.getUseDataFilter()); + // 完成一些共同性规则的验证。 + VerifyAggregationInfo verifyInfo = this.verifyAndParseAggregationParam(param); + if (!verifyInfo.isSuccess) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, verifyInfo.errorMsg); + } + // 构建SelectList + StringBuilder selectList = new StringBuilder(64); + // 一对多场景相对比较简单,直接基于从表的关联键进行group,同时对聚合字段进行聚合计算即可。 + // SQL返回的数据,可直接返回给调用端服务。 + if (param.getAggregationKind() == AggregationKind.ONE_TO_MANY) { + selectList.append(verifyInfo.groupColumn).append(" ").append(MyAggregationParam.KEY_NAME).append(", "); + } + selectList.append(AggregationType.getAggregationFunction(verifyInfo.aggregationType)) + .append("(") + .append(verifyInfo.aggregationColumn) + .append(") ") + .append(MyAggregationParam.VALUE_NAME); + String whereClause = MyWhereCriteria.makeCriteriaString(param.getWhereCriteriaList(), modelClass); + List> resultMapList = null; + // 一对多场景直接返回分组查询计算结果即可。 + if (param.getAggregationKind() == AggregationKind.ONE_TO_MANY) { + resultMapList = service().getGroupedListByCondition( + selectList.toString(), whereClause, verifyInfo.groupColumn); + } else if (param.getAggregationKind() == AggregationKind.MANY_TO_MANY) { + boolean stringKey = true; + if (param.getGroupedInFilterValues().entrySet().iterator().next().getKey() instanceof Number) { + stringKey = false; + } + resultMapList = new LinkedList<>(); + // 迭代分组map,通过多次查询的方式进行数据分组。通过该方式可以避免多次rpc调用,以提升性能。 + for (Map.Entry> entry : param.getGroupedInFilterValues().entrySet()) { + StringBuilder groupedSelectList = new StringBuilder(64); + if (stringKey) { + groupedSelectList.append("'").append(entry.getKey()).append("' as "); + } else { + groupedSelectList.append(entry.getKey()).append(" as "); + } + groupedSelectList.append(MyAggregationParam.KEY_NAME).append(", ").append(selectList); + MyWhereCriteria criteria = new MyWhereCriteria(); + criteria.setModelClazz(modelClass); + criteria.setCriteria(param.getInFilterField(), MyWhereCriteria.OPERATOR_IN, entry.getValue()); + StringBuilder groupedClause = new StringBuilder(128); + groupedClause.append(criteria.makeCriteriaString()); + if (StrUtil.isNotBlank(whereClause)) { + groupedClause.append(" AND ").append(whereClause); + } + List> subResultMapList = service().getGroupedListByCondition( + groupedSelectList.toString(), groupedClause.toString(), null); + resultMapList.addAll(subResultMapList); + } + } + GlobalThreadLocal.setDataFilter(dataFilterEnabled); + return ResponseResult.success(resultMapList); + } + + /** + * 将Model实体对象的集合转换为DomainVO域对象的集合。 + * 如果Model存在该实体的ModelMapper,就用该ModelMapper转换,否则使用缺省的基于字段反射的copy。 + * + * @param modelList 实体对象列表。 + * @param modelMapper 从实体对象到VO对象的映射对象。 + * @return 转换后的VO域对象列表。 + */ + protected List convertToVoList(List modelList, BaseModelMapper modelMapper) { + List resultVoList; + if (modelMapper != null) { + resultVoList = modelMapper.fromModelList(modelList); + } else { + resultVoList = MyModelUtil.copyCollectionTo(modelList, domainVoClass); + } + return resultVoList; + } + + /** + * 将Model实体对象转换为DomainVO域对象。 + * 如果Model存在该实体的ModelMapper,就用该ModelMapper转换,否则使用缺省的基于字段反射的copy。 + * + * @param model 实体对象。 + * @param modelMapper 从实体对象到VO对象的映射对象。 + * @return 转换后的VO域对象。 + */ + protected V convertToVo(M model, BaseModelMapper modelMapper) { + V resultVo; + if (modelMapper != null) { + resultVo = modelMapper.fromModel(model); + } else { + resultVo = MyModelUtil.copyTo(model, domainVoClass); + } + return resultVo; + } + + private String makeupSearchCriteria(MyQueryParam queryParam, String whereClause) { + if (CollUtil.isEmpty(queryParam.getSearchStringFieldList()) + || StrUtil.isBlank(queryParam.getSearchStringValue())) { + return whereClause; + } + String tableName = MyModelUtil.mapToTableName(modelClass); + StringBuilder sb = new StringBuilder(128); + if (StrUtil.isNotBlank(whereClause)) { + sb.append(" AND "); + } + sb.append(" CONCAT("); + for (int i = 0; i < queryParam.getSearchStringFieldList().size(); i++) { + String fieldName = queryParam.getSearchStringFieldList().get(i); + String columnName = MyModelUtil.mapToColumnInfo(fieldName, modelClass).getFirst(); + if (coreProperties.isMySql() || coreProperties.isDm()) { + sb.append("IFNULL("); + } else if (coreProperties.isPostgresql() || coreProperties.isOpenGauss()) { + sb.append("COALESCE("); + } else if (coreProperties.isOracle() || coreProperties.isKingbase()) { + sb.append("NVL("); + } + sb.append(tableName).append(".").append(columnName).append(", '')"); + if (i != queryParam.getSearchStringFieldList().size() - 1) { + sb.append(", "); + } + } + sb.append(") LIKE ").append("'").append(queryParam.getSearchStringValue()).append("'"); + if (StrUtil.isNotBlank(whereClause)) { + whereClause = whereClause + sb.toString(); + } + return whereClause; + } + + private VerifyAggregationInfo verifyAndParseAggregationParam(MyAggregationParam param) { + VerifyAggregationInfo verifyInfo = new VerifyAggregationInfo(); + if (!AggregationKind.isValid(param.getAggregationKind())) { + verifyInfo.errorMsg = "参数验证失败,聚合类别 [MyAggregationParam.AggregationKind] 数值无效!"; + return verifyInfo; + } + Integer aggregationType = param.getAggregationType(); + if (!AggregationType.isValid(aggregationType)) { + verifyInfo.errorMsg = "参数验证失败,聚合类型 [MyAggregationParam.AggregationType] 数值无效!"; + return verifyInfo; + } + String aggregationColumn = MyModelUtil.mapToColumnName(param.getAggregationField(), modelClass); + if (StrUtil.isBlank(aggregationColumn)) { + verifyInfo.errorMsg = "参数验证失败,聚合字段 [MyAggregationParam.AggregationField] 为非法值!"; + return verifyInfo; + } + // 一对多场景相对比较简单,直接基于从表的关联键进行group,同时对聚合字段进行聚合计算即可。 + // SQL返回的数据,可直接返回给调用端服务。 + if (param.getAggregationKind() == AggregationKind.ONE_TO_MANY) { + verifyInfo.groupColumn = MyModelUtil.mapToColumnName(param.getGroupField(), modelClass); + if (StrUtil.isBlank(verifyInfo.groupColumn)) { + verifyInfo.errorMsg = "参数验证失败,一对多聚合 [MyAggregationParam.GroupField] 分组字段为非法值!"; + return verifyInfo; + } + } else { + String inFilterColumn = MyModelUtil.mapToColumnName(param.getInFilterField(), modelClass); + if (StrUtil.isBlank(inFilterColumn)) { + verifyInfo.errorMsg = "参数验证失败,多对多 [MyAggregationParam.InfilterField] 过滤字段为非法值!"; + return verifyInfo; + } + if (MapUtil.isEmpty(param.getGroupedInFilterValues())) { + verifyInfo.errorMsg = "参数验证失败,多对多 [MyAggregationParam.GroupedInFilterValues] 数据集合不能为空!"; + return verifyInfo; + } + } + verifyInfo.isSuccess = true; + verifyInfo.aggregationType = aggregationType; + verifyInfo.aggregationColumn = aggregationColumn; + return verifyInfo; + } + + private static final class VerifyAggregationInfo { + private boolean isSuccess = false; + private String errorMsg; + private Integer aggregationType; + private String aggregationColumn; + private String groupColumn; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/dao/BaseDaoMapper.java b/common/common-core/src/main/java/apelet/common/core/base/dao/BaseDaoMapper.java new file mode 100644 index 0000000..c15db9d --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/dao/BaseDaoMapper.java @@ -0,0 +1,87 @@ +package apelet.common.core.base.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 数据访问对象的基类。 + * + * @param 主Model实体对象。 + * @author guifc + * @date 2023-08-04 + */ +public interface BaseDaoMapper extends BaseMapper { + + /** + * 根据指定的表名、显示字段列表、过滤条件字符串和分组字段,返回聚合计算后的查询结果。 + * + * @param selectTable 表名称。 + * @param selectFields 返回字段列表,逗号分隔。 + * @param whereClause SQL常量形式的条件从句。 + * @param groupBy 分组字段列表,逗号分隔。 + * @return 对象可选字段Map列表。 + */ + @Select("") + List> getGroupedListByCondition( + @Param("selectTable") String selectTable, + @Param("selectFields") String selectFields, + @Param("whereClause") String whereClause, + @Param("groupBy") String groupBy); + + /** + * 根据指定的表名、显示字段列表、过滤条件字符串和排序字符串,返回查询结果。 + * + * @param selectTable 表名称。 + * @param selectFields 选择的字段列表。 + * @param whereClause 过滤字符串。 + * @param orderBy 排序字符串。 + * @return 查询结果。 + */ + @Select("") + List> getListByCondition( + @Param("selectTable") String selectTable, + @Param("selectFields") String selectFields, + @Param("whereClause") String whereClause, + @Param("orderBy") String orderBy); + + /** + * 用指定过滤条件,计算记录数量。 + * + * @param selectTable 表名称。 + * @param whereClause 过滤字符串。 + * @return 返回过滤后的数据数量。 + */ + @Select("") + int getCountByCondition(@Param("selectTable") String selectTable, @Param("whereClause") String whereClause); +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/mapper/BaseModelMapper.java b/common/common-core/src/main/java/apelet/common/core/base/mapper/BaseModelMapper.java new file mode 100644 index 0000000..5b78424 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/mapper/BaseModelMapper.java @@ -0,0 +1,126 @@ +package apelet.common.core.base.mapper; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Model对象到Domain类型对象的相互转换。实现类通常声明在Model实体类中。 + * + * @param Domain域对象类型。 + * @param Model实体对象类型。 + * @author guifc + * @date 2023-08-04 + */ +public interface BaseModelMapper { + + /** + * 转换Model实体对象到Domain域对象。 + * + * @param model Model实体对象。 + * @return Domain域对象。 + */ + D fromModel(M model); + + /** + * 转换Model实体对象列表到Domain域对象列表。 + * + * @param modelList Model实体对象列表。 + * @return Domain域对象列表。 + */ + List fromModelList(List modelList); + + /** + * 转换Domain域对象到Model实体对象。 + * + * @param domain Domain域对象。 + * @return Model实体对象。 + */ + M toModel(D domain); + + /** + * 转换Domain域对象列表到Model实体对象列表。 + * + * @param domainList Domain域对象列表。 + * @return Model实体对象列表。 + */ + List toModelList(List domainList); + + /** + * 转换bean到map + * + * @param bean bean对象。 + * @param ignoreNullValue 值为null的字段是否转换到Map。 + * @param bean类型。 + * @return 转换后的map对象。 + */ + default Map beanToMap(T bean, boolean ignoreNullValue) { + return BeanUtil.beanToMap(bean, false, ignoreNullValue); + } + + /** + * 转换bean集合到map集合 + * + * @param dataList bean对象集合。 + * @param ignoreNullValue 值为null的字段是否转换到Map。 + * @param bean类型。 + * @return 转换后的map对象集合。 + */ + default List> beanToMap(List dataList, boolean ignoreNullValue) { + if (CollUtil.isEmpty(dataList)) { + return new LinkedList<>(); + } + return dataList.stream() + .map(o -> BeanUtil.beanToMap(o, false, ignoreNullValue)) + .collect(Collectors.toList()); + } + + /** + * 转换map到bean。 + * + * @param map map对象。 + * @param beanClazz bean的Class对象。 + * @param bean类型。 + * @return 转换后的bean对象。 + */ + default T mapToBean(Map map, Class beanClazz) { + return BeanUtil.toBeanIgnoreError(map, beanClazz); + } + + /** + * 转换map集合到bean集合。 + * + * @param mapList map对象集合。 + * @param beanClazz bean的Class对象。 + * @param bean类型。 + * @return 转换后的bean对象集合。 + */ + default List mapToBean(List> mapList, Class beanClazz) { + if (CollUtil.isEmpty(mapList)) { + return new LinkedList<>(); + } + return mapList.stream() + .map(m -> BeanUtil.toBeanIgnoreError(m, beanClazz)) + .collect(Collectors.toList()); + } + + /** + * 对于Map字段到Map字段的映射场景,MapStruct会根据方法签名自动选择该函数 + * 作为对象copy的函数。由于该函数是直接返回的,因此没有对象copy,效率更高。 + * 如果没有该函数,MapStruct会生成如下代码: + * Map map = courseDto.getTeacherIdDictMap(); + * if ( map != null ) { + * course.setTeacherIdDictMap( new HashMap( map ) ); + * } + * + * @param map map对象。 + * @return 直接返回的map。 + */ + default Map mapToMap(Map map) { + return map; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/mapper/DummyModelMapper.java b/common/common-core/src/main/java/apelet/common/core/base/mapper/DummyModelMapper.java new file mode 100644 index 0000000..dfe818d --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/mapper/DummyModelMapper.java @@ -0,0 +1,58 @@ +package apelet.common.core.base.mapper; + +import java.util.List; + +/** + * 哑元占位对象。Model实体对象和Domain域对象相同的场景下使用。 + * 由于没有实际的数据转换,因此同时保证了代码统一和执行效率。 + * + * @param 数据类型。 + * @author guifc + * @date 2023-08-04 + */ +public class DummyModelMapper implements BaseModelMapper { + + /** + * 不转换直接返回。 + * + * @param model Model实体对象。 + * @return Domain域对象。 + */ + @Override + public M fromModel(M model) { + return model; + } + + /** + * 不转换直接返回。 + * + * @param modelList Model实体对象列表。 + * @return Domain域对象列表。 + */ + @Override + public List fromModelList(List modelList) { + return modelList; + } + + /** + * 不转换直接返回。 + * + * @param domain Domain域对象。 + * @return Model实体对象。 + */ + @Override + public M toModel(M domain) { + return domain; + } + + /** + * 不转换直接返回。 + * + * @param domainList Domain域对象列表。 + * @return Model实体对象列表。 + */ + @Override + public List toModelList(List domainList) { + return domainList; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/model/BaseModel.java b/common/common-core/src/main/java/apelet/common/core/base/model/BaseModel.java new file mode 100644 index 0000000..02adf05 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/model/BaseModel.java @@ -0,0 +1,40 @@ +package apelet.common.core.base.model; + +import com.baomidou.mybatisplus.annotation.TableField; +import lombok.Data; + +import java.util.Date; + +/** + * 实体对象的公共基类,所有子类均必须包含基类定义的数据表字段和实体对象字段。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class BaseModel { + + /** + * 创建者Id。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + + /** + * 更新者Id。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; + + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/service/BaseDictService.java b/common/common-core/src/main/java/apelet/common/core/base/service/BaseDictService.java new file mode 100644 index 0000000..b302e20 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/service/BaseDictService.java @@ -0,0 +1,230 @@ +package apelet.common.core.base.service; + +import apelet.common.core.cache.DictionaryCache; +import apelet.common.core.constant.GlobalDeletedFlag; +import apelet.common.core.object.TokenData; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ReflectUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.io.Serializable; +import java.util.List; +import java.util.Set; + +/** + * 带有缓存功能的字典Service基类,需要留意的是,由于缓存基于Key/Value方式存储, + * 目前仅支持基于主键字段的缓存查找,其他条件的查找仍然从数据源获取。 + * + * @param Model实体对象的类型。 + * @param Model对象主键的类型。 + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public abstract class BaseDictService + extends BaseService implements IBaseDictService { + + /** + * 缓存池对象。 + */ + protected DictionaryCache dictionaryCache; + + /** + * 构造函数使用缺省缓存池对象。 + */ + protected BaseDictService() { + super(); + } + + /** + * 重新加载数据库中所有当前表数据到系统内存。 + * + * @param force true则强制刷新,如果false,当缓存中存在数据时不刷新。 + */ + @Override + public void reloadCachedData(boolean force) { + // 在非强制刷新情况下。 + // 先行判断缓存中是否存在数据,如果有就不加载了。 + if (!force && dictionaryCache.getCount() > 0) { + return; + } + List allList = super.getAllList(); + dictionaryCache.reload(allList, force); + } + + /** + * 保存新增对象。 + * + * @param data 新增对象。 + * @return 返回新增对象。 + */ + @Transactional(rollbackFor = Exception.class) + @Override + public M saveNew(M data) { + // 清空全部缓存 + dictionaryCache.invalidateAll(); + if (deletedFlagFieldName != null) { + ReflectUtil.setFieldValue(data, deletedFlagFieldName, GlobalDeletedFlag.NORMAL); + } + if (tenantIdField != null) { + ReflectUtil.setFieldValue(data, tenantIdField, TokenData.takeFromRequest().getTenantId()); + } + mapper().insert(data); + return data; + } + + /** + * 更新数据对象。 + * + * @param data 更新的对象。 + * @param originalData 原有数据对象。 + * @return 成功返回true,否则false。 + */ + @Transactional(rollbackFor = Exception.class) + @Override + public boolean update(M data, M originalData) { + dictionaryCache.invalidateAll(); + if (tenantIdField != null) { + ReflectUtil.setFieldValue(data, tenantIdField, TokenData.takeFromRequest().getTenantId()); + } + return mapper().updateById(data) == 1; + } + + /** + * 删除指定数据。 + * + * @param id 主键Id。 + * @return 成功返回true,否则false。 + */ + @Transactional(rollbackFor = Exception.class) + @Override + public boolean remove(K id) { + dictionaryCache.invalidateAll(); + return mapper().deleteById(id) == 1; + } + + /** + * 直接从缓存池中获取主键Id关联的数据。如果缓存中不存在,再从数据库中取出并回写到缓存。 + * + * @param id 主键Id。 + * @return 主键关联的数据,不存在返回null。 + */ + @SuppressWarnings("unchecked") + @Override + public M getById(Serializable id) { + M data = dictionaryCache.get((K) id); + if (data != null) { + return data; + } + if (dictionaryCache.getCount() != 0) { + return data; + } + this.reloadCachedData(true); + return dictionaryCache.get((K) id); + } + + /** + * 直接从缓存池中获取所有数据。 + * + * @return 返回所有数据。 + */ + @Override + public List getAllListFromCache() { + List resultList = dictionaryCache.getAll(); + if (CollUtil.isNotEmpty(resultList)) { + return resultList; + } + this.reloadCachedData(true); + return dictionaryCache.getAll(); + } + + /** + * 直接从缓存池中返回符合主键 in (idValues) 条件的所有数据。 + * 对于缓存中不存在的数据,从数据库中获取并回写入缓存。 + * + * @param idValues 主键值列表。 + * @return 检索后的数据列表。 + */ + @Override + public List getInList(Set idValues) { + List resultList = dictionaryCache.getInList(idValues); + // 如果从缓存中获取与请求的id完全相同就直接返回。 + if (resultList.size() == idValues.size()) { + return resultList; + } + // 如果此时缓存中存在数据,说明有部分id是不存在的。也可以直接返回了。 + if (dictionaryCache.getCount() != 0) { + return resultList; + } + // 执行到这里,说明缓存是空的,所有需要重新加载并再次从缓存中读取并返回。 + this.reloadCachedData(true); + return dictionaryCache.getInList(idValues); + } + + @Override + public List getListByParentId(K parentId) { + List resultList = dictionaryCache.getListByParentId(parentId); + // 如果包含数据就直接返回了 + if (CollUtil.isNotEmpty(resultList)) { + return resultList; + } + // 如果缓存中存在该字典数据,说明该parentId下子对象列表为空,也可以直接返回了。 + if (this.getCachedCount() != 0) { + return resultList; + } + // 执行到这里就需要重新加载全部缓存了。 + this.reloadCachedData(true); + return dictionaryCache.getListByParentId(parentId); + } + + /** + * 返回符合 inFilterField in (inFilterValues) 条件的所有数据。属性property是主键,则从缓存中读取。 + * + * @param inFilterField 参与(In-list)过滤的Java字段。 + * @param inFilterValues 参与(In-list)过滤的Java字段值集合。 + * @return 检索后的数据列表。 + */ + @SuppressWarnings("unchecked") + @Override + public List getInList(String inFilterField, Set inFilterValues) { + if (inFilterField.equals(this.idFieldName)) { + return this.getInList((Set) inFilterValues); + } + return super.getInList(inFilterField, inFilterValues); + } + + /** + * 判断参数值列表中的所有数据,是否全部存在。另外,keyName字段在数据表中必须是唯一键值,否则返回结果会出现误判。 + * + * @param inFilterField 待校验的数据字段,这里使用Java对象中的属性,如courseId,而不是数据字段名course_id。 + * @param inFilterValues 数据值集合。 + * @return 全部存在返回true,否则false。 + */ + @SuppressWarnings("unchecked") + @Override + public boolean existUniqueKeyList(String inFilterField, Set inFilterValues) { + if (CollUtil.isEmpty(inFilterValues)) { + return true; + } + if (inFilterField.equals(this.idFieldName)) { + List dataList = this.getInList((Set) inFilterValues); + return dataList.size() == inFilterValues.size(); + } + String columnName = this.safeMapToColumnName(inFilterField); + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.in(columnName, inFilterValues); + return mapper().selectCount(queryWrapper) == inFilterValues.size(); + } + + /** + * 获取缓存中的数据数量。 + * + * @return 缓存中的数据总量。 + */ + @Override + public int getCachedCount() { + return dictionaryCache.getCount(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/service/BaseService.java b/common/common-core/src/main/java/apelet/common/core/base/service/BaseService.java new file mode 100644 index 0000000..0278b0b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/service/BaseService.java @@ -0,0 +1,3243 @@ +package apelet.common.core.base.service; + +import apelet.common.core.annotation.*; +import apelet.common.core.base.client.BaseClient; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.constant.*; +import apelet.common.core.exception.InvalidDataFieldException; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.exception.RemoteDataBuildException; +import apelet.common.core.object.*; +import apelet.common.core.util.*; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.text.StrFormatter; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import static java.util.stream.Collectors.*; + +/** + * 所有Service的基类。 + * + * @param Model对象的类型。 + * @param Model对象主键的类型。 + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public abstract class BaseService extends ServiceImpl, M> implements IBaseService { + + /** + * 当前Service关联的主Model实体对象的Class。 + */ + @Getter + protected final Class modelClass; + /** + * 当前Service关联的主Model实体对象主键字段的Class。 + */ + protected final Class idFieldClass; + /** + * 当前Service关联的主Model对象的实际表名称。 + */ + protected final String tableName; + /** + * 当前Service关联的主Model对象主键字段名称。 + */ + protected String idFieldName; + /** + * 当前Service关联的主数据表中主键列名称。 + */ + protected String idColumnName; + /** + * 当前Service关联的主Model对象逻辑删除字段名称。 + */ + protected String deletedFlagFieldName; + /** + * 当前Service关联的主数据表中逻辑删除字段名称。 + */ + protected String deletedFlagColumnName; + /** + * 当前Service关联的主Model对象租户Id字段。 + */ + protected Field tenantIdField; + /** + * 流程实例状态字段。 + */ + protected Field flowStatusField; + /** + * 流程最后审批状态字段 + */ + protected Field flowLatestApprovalStatusField; + /** + * 脱敏字段列表。 + */ + protected List maskFieldList; + /** + * 当前Service关联的主Model对象租户Id字段名称。 + */ + protected String tenantIdFieldName; + /** + * 当前Service关联的主数据表中租户Id列名称。 + */ + protected String tenantIdColumnName; + /** + * 当前Job服务源主表Model对象的最后更新时间字段名称。 + */ + protected String jobUpdateTimeFieldName; + /** + * 当前Job服务源主表Model对象的最后更新时间列名称。 + */ + protected String jobUpdateTimeColumnName; + /** + * 当前业务服务源主表Model对象最后更新时间字段名称。 + */ + protected String updateTimeFieldName; + /** + * 当前业务服务源主表Model对象最后更新时间列名称。 + */ + protected String updateTimeColumnName; + /** + * 当前业务服务源主表Model对象最后更新用户Id字段名称。 + */ + protected String updateUserIdFieldName; + /** + * 当前业务服务源主表Model对象最后更新用户Id列名称。 + */ + protected String updateUserIdColumnName; + /** + * 当前Service关联的主Model对象主键字段赋值方法的反射对象。 + */ + protected Method setIdFieldMethod; + /** + * 当前Service关联的主Model对象主键字段访问方法的反射对象。 + */ + protected Method getIdFieldMethod; + /** + * 当前Service关联的主Model对象逻辑删除字段赋值方法的反射对象。 + */ + protected Method setDeletedFlagMethod; + /** + * 当前Service关联的全局字典对象的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List relationGlobalDictStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有本地服务常量字典关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List relationConstDictStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有本地服务字典关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List localRelationDictStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有本地服务一对一关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List localRelationOneToOneStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有本地服务一对多关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List localRelationOneToManyStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有多对多关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List localRelationManyToManyStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有本地服务一对多聚合关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List localRelationOneToManyAggrStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有本地服务多对多聚合关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List localRelationManyToManyAggrStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有远程字典关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List remoteRelationDictStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有远程一对一关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List remoteRelationOneToOneStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有远程一对多聚合关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List remoteRelationOneToManyAggrStructList = new LinkedList<>(); + /** + * 当前Service关联的主Model对象的所有远程多对多聚合关联的结构列表,该字段在系统启动阶段一次性预加载,提升运行时效率。 + */ + protected final List remoteRelationManyToManyAggrStructList = new LinkedList<>(); + /** + * 基础表的实体对象及表信息。 + */ + protected final TableModelInfo tableModelInfo = new TableModelInfo(); + private final Map, MaskFieldHandler> maskFieldHandlerMap = new ConcurrentHashMap<>(); + + private static final String AND_OP = " AND "; + private static final String ORDER_BY = " ORDER BY "; + + @Override + public BaseDaoMapper getBaseMapper() { + return mapper(); + } + + /** + * 构造函数,在实例化的时候,一次性完成所有有关主Model对象信息的加载。 + */ + @SuppressWarnings("unchecked") + protected BaseService() { + Class type = getClass(); + while (!(type.getGenericSuperclass() instanceof ParameterizedType)) { + type = type.getSuperclass(); + } + modelClass = (Class) ((ParameterizedType) type.getGenericSuperclass()).getActualTypeArguments()[0]; + idFieldClass = (Class) ((ParameterizedType) type.getGenericSuperclass()).getActualTypeArguments()[1]; + this.tableName = modelClass.getAnnotation(TableName.class).value(); + Field[] fields = ReflectUtil.getFields(modelClass); + for (Field field : fields) { + initializeField(field); + } + tableModelInfo.setModelName(modelClass.getSimpleName()); + tableModelInfo.setTableName(this.tableName); + tableModelInfo.setKeyFieldName(idFieldName); + tableModelInfo.setKeyColumnName(idColumnName); + } + + @Override + public TableModelInfo getTableModelInfo() { + return this.tableModelInfo; + } + + private void initializeField(Field field) { + if (idFieldName == null && null != field.getAnnotation(TableId.class)) { + idFieldName = field.getName(); + TableId c = field.getAnnotation(TableId.class); + idColumnName = c == null ? idFieldName : c.value(); + setIdFieldMethod = ReflectUtil.getMethod( + modelClass, "set" + StrUtil.upperFirst(idFieldName), idFieldClass); + getIdFieldMethod = ReflectUtil.getMethod( + modelClass, "get" + StrUtil.upperFirst(idFieldName)); + } + if (null != field.getAnnotation(JobUpdateTimeColumn.class)) { + jobUpdateTimeFieldName = field.getName(); + jobUpdateTimeColumnName = this.safeMapToColumnName(jobUpdateTimeFieldName); + } + if (null != field.getAnnotation(TableLogic.class)) { + deletedFlagFieldName = field.getName(); + deletedFlagColumnName = this.safeMapToColumnName(deletedFlagFieldName); + setDeletedFlagMethod = ReflectUtil.getMethod( + modelClass, "set" + StrUtil.upperFirst(deletedFlagFieldName), Integer.class); + } + if (null != field.getAnnotation(TenantFilterColumn.class)) { + tenantIdField = field; + tenantIdFieldName = field.getName(); + tenantIdColumnName = this.safeMapToColumnName(tenantIdFieldName); + } + if (null != field.getAnnotation(FlowStatusColumn.class)) { + flowStatusField = field; + } + if (null != field.getAnnotation(FlowLatestApprovalStatusColumn.class)) { + flowLatestApprovalStatusField = field; + } + if (null != field.getAnnotation(MaskField.class)) { + if (maskFieldList == null) { + maskFieldList = new LinkedList<>(); + } + maskFieldList.add(field); + } + } + + /** + * 获取子类中注入的Mapper类 + * + * @return 子类中注入的Mapper类 + */ + protected abstract BaseDaoMapper mapper(); + + /** + * 该方法为BaseService类buildXXX方法中使用到的模板方法,子类实现方法中提供具体的实现, + * 可根据配置项决定是否忽略远程调用异常。由于来自于配置项,因此可在运行时动态修改。 + * 是否忽略获取远程查询数据过程中出现的任何错误。包括各种逻辑错误,系统错误等。 + * 通常建议在开发阶段设置为false,以便及时发现问题。 + * + * @return true忽略,否则抛出RemoteDataBuildException异常。 + */ + protected boolean ignoreRpcError() { + return false; + } + + @SuppressWarnings("unchecked") + @Override + public void saveNewOrUpdate(M data, Consumer saveNew, BiConsumer update) { + if (data == null) { + return; + } + K id = (K) ReflectUtil.getFieldValue(data, idFieldName); + if (id == null) { + saveNew.accept(data); + } else { + update.accept(data, this.getById(id)); + } + } + + @SuppressWarnings("unchecked") + @Override + public void saveNewOrUpdateBatch(List dataList, Consumer> saveNewBatch, BiConsumer update) { + if (CollUtil.isEmpty(dataList)) { + return; + } + List saveNewDataList = dataList.stream() + .filter(c -> ReflectUtil.getFieldValue(c, idFieldName) == null).collect(toList()); + if (CollUtil.isNotEmpty(saveNewDataList)) { + saveNewBatch.accept(saveNewDataList); + } + List updateDataList = dataList.stream() + .filter(c -> ReflectUtil.getFieldValue(c, idFieldName) != null).collect(toList()); + if (CollUtil.isNotEmpty(updateDataList)) { + for (M data : updateDataList) { + K id = (K) ReflectUtil.getFieldValue(data, idFieldName); + update.accept(data, this.getById(id)); + } + } + } + + /** + * 根据过滤条件删除数据。 + * + * @param filter 过滤对象。 + * @return 删除数量。 + */ + @Transactional(rollbackFor = Exception.class) + @Override + public Integer removeBy(M filter) { + return mapper().delete(new QueryWrapper<>(filter)); + } + + @Transactional(rollbackFor = Exception.class) + public boolean remove(K id) { + return mapper().deleteById(id) > 0; + } + + @Transactional(rollbackFor = Exception.class) + @Override + public void updateBatchOneToManyRelation( + String relationFieldName, + Object relationFieldValue, + String updateUserIdFieldName, + String updateTimeFieldName, + List dataList, + Consumer> batchInserter) { + // 删除在现有数据列表dataList中不存在的从表数据。 + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.eq(this.safeMapToColumnName(relationFieldName), relationFieldValue); + if (CollUtil.isNotEmpty(dataList)) { + Set keptIdSet = dataList.stream() + .filter(c -> ReflectUtil.getFieldValue(c, idFieldName) != null) + .map(c -> ReflectUtil.getFieldValue(c, idFieldName)).collect(toSet()); + if (CollUtil.isNotEmpty(keptIdSet)) { + queryWrapper.notIn(idColumnName, keptIdSet); + } + } + mapper().delete(queryWrapper); + if (CollUtil.isEmpty(dataList)) { + return; + } + // 没有包含主键的对象被视为新对象,为了效率最优化,这里执行批量插入。 + List newDataList = dataList.stream() + .filter(c -> ReflectUtil.getFieldValue(c, idFieldName) == null).collect(toList()); + if (CollUtil.isNotEmpty(newDataList)) { + newDataList.forEach(o -> ReflectUtil.setFieldValue(o, relationFieldName, relationFieldValue)); + batchInserter.accept(newDataList); + } + // 对于主键已经存在的数据,我们视为已存在数据,这里执行逐条更新操作。 + List updateDataList = + dataList.stream().filter(c -> ReflectUtil.getFieldValue(c, idFieldName) != null).collect(toList()); + for (M updateData : updateDataList) { + // 如果前端将更新用户Id置空,这里使用当前用户更新该字段。 + if (updateUserIdFieldName != null) { + ReflectUtil.setFieldValue(updateData, updateUserIdFieldName, TokenData.takeFromRequest().getUserId()); + } + // 如果前端将更新时间置空,这里使用当前时间更新该字段。 + if (updateTimeFieldName != null) { + ReflectUtil.setFieldValue(updateData, updateTimeFieldName, new Date()); + } + if (this.tenantIdField != null) { + ReflectUtil.setFieldValue(updateData, tenantIdField, TokenData.takeFromRequest().getTenantId()); + } + if (this.deletedFlagFieldName != null) { + ReflectUtil.setFieldValue(updateData, deletedFlagFieldName, GlobalDeletedFlag.NORMAL); + } + @SuppressWarnings("unchecked") + K id = (K) ReflectUtil.getFieldValue(updateData, idFieldName); + this.compareAndSetMaskFieldData(updateData, id); + mapper().updateById(updateData); + } + } + + /** + * 判断主键Id关联的数据是否存在。 + * + * @param id 主键Id + * @return 存在返回true,否则false + */ + @Override + public boolean existId(K id) { + return this.getById(id) != null; + } + + @Override + public M getOne(M filter) { + return mapper().selectOne(new QueryWrapper<>(filter)); + } + + /** + * 判断指定字段的数据是否存在,且仅仅存在一条记录。 + * 如果是基于主键的过滤,会直接调用existId过滤函数,提升性能。在有缓存的场景下,也可以利用缓存。 + * + * @param fieldName 待过滤的字段名(Java 字段)。 + * @param fieldValue 字段值。 + * @return 存在且仅存在一条返回true,否则false。 + */ + @SuppressWarnings("unchecked") + @Override + public boolean existOne(String fieldName, Object fieldValue) { + if (fieldName.equals(this.idFieldName)) { + return this.existId((K) fieldValue); + } + String columnName = MyModelUtil.mapToColumnName(fieldName, modelClass); + return mapper().selectCount(new QueryWrapper().eq(columnName, fieldValue)) == 1; + } + + /** + * 返回符合 filterField = filterValue 条件的一条数据。 + * + * @param filterField 过滤的Java字段。 + * @param filterValue 过滤的Java字段值。 + * @return 查询后的数据对象。 + */ + @SuppressWarnings("unchecked") + @Override + public M getOne(String filterField, Object filterValue) { + if (filterField.equals(idFieldName)) { + return this.getById((K) filterValue); + } + String columnName = this.safeMapToColumnName(filterField); + QueryWrapper queryWrapper = new QueryWrapper().eq(columnName, filterValue); + return mapper().selectOne(queryWrapper); + } + + /** + * 获取主表的查询结果,以及主表关联的字典数据和一对一从表数据,以及一对一从表的字典数据。 + * + * @param id 主表主键Id。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 查询结果对象。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + @Override + public M getByIdWithRelation(K id, MyRelationParam relationParam) { + M dataObject = this.getById(id); + this.buildRelationForData(dataObject, relationParam); + return dataObject; + } + + /** + * 获取所有数据。单表查询,不进行任何数据关联。 + * + * @return 返回所有数据。 + */ + @Override + public List getAllList() { + return mapper().selectList(Wrappers.emptyWrapper()); + } + + /** + * 获取排序后所有数据。单表查询,不进行任何数据关联。 + * + * @param orderByProperties 需要排序的字段属性,这里使用Java对象中的属性名,而不是数据库字段名。 + * @return 返回排序后所有数据。 + */ + @Override + public List getAllListByOrder(String... orderByProperties) { + List columns = new ArrayList<>(orderByProperties.length); + for (String orderByProperty : orderByProperties) { + columns.add(this.safeMapToColumnName(orderByProperty)); + } + return mapper().selectList(new QueryWrapper().orderByAsc(columns)); + } + + /** + * 判断参数值主键集合中的所有数据,是否全部存在 + * + * @param idSet 待校验的主键集合。 + * @return 全部存在返回true,否则false。 + */ + @Override + public boolean existAllPrimaryKeys(Set idSet) { + if (CollUtil.isEmpty(idSet)) { + return true; + } + return this.existUniqueKeyList(idFieldName, idSet); + } + + /** + * 判断参数值列表中的所有数据,是否全部存在。另外,keyName字段在数据表中必须是唯一键值,否则返回结果会出现误判。 + * + * @param inFilterField 待校验的数据字段,这里使用Java对象中的属性,如courseId,而不是数据字段名course_id。 + * @param inFilterValues 数据值集合。 + * @return 全部存在返回true,否则false。 + */ + @Override + public boolean existUniqueKeyList(String inFilterField, Set inFilterValues) { + if (CollUtil.isEmpty(inFilterValues)) { + return true; + } + String column = this.safeMapToColumnName(inFilterField); + return mapper().selectCount(new QueryWrapper().in(column, inFilterValues)) == inFilterValues.size(); + } + + @Override + public List notExist(String filterField, Set filterSet, boolean findFirst) { + List notExistIdList = new LinkedList<>(); + int start = 0; + int count = 1000; + if (filterSet.size() > count) { + do { + int end = Math.min(filterSet.size(), start + count); + List subFilterList = CollUtil.sub(filterSet, start, end); + doNotExistQuery(filterField, subFilterList, findFirst, notExistIdList); + if ((findFirst && CollUtil.isNotEmpty(notExistIdList)) || end == filterSet.size()) { + break; + } + start += count; + } while (true); + } else { + doNotExistQuery(filterField, filterSet, findFirst, notExistIdList); + } + return notExistIdList; + } + + private void doNotExistQuery( + String filterField, Collection filterSet, boolean findFirst, List notExistIdList) { + String columnName = this.safeMapToColumnName(filterField); + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.in(columnName, filterSet); + queryWrapper.select(columnName); + Set existIdSet = mapper().selectList(queryWrapper).stream() + .map(c -> ReflectUtil.getFieldValue(c, filterField)).collect(toSet()); + for (R filterData : filterSet) { + if (!existIdSet.contains(filterData)) { + notExistIdList.add(filterData); + if (findFirst) { + break; + } + } + } + } + + @Override + public List getInList(Set idValues) { + return this.getInList(idFieldName, idValues, null); + } + + @Override + public List getInList(String inFilterField, Set inFilterValues) { + return this.getInList(inFilterField, inFilterValues, null); + } + + @Override + public List getInList(String inFilterField, Set inFilterValues, String orderBy) { + if (CollUtil.isEmpty(inFilterValues)) { + return new LinkedList<>(); + } + String column = this.safeMapToColumnName(inFilterField); + QueryWrapper queryWrapper = new QueryWrapper().in(column, inFilterValues); + if (StrUtil.isNotBlank(orderBy)) { + queryWrapper.last(ORDER_BY + orderBy); + } + return mapper().selectList(queryWrapper); + } + + @Override + public List getInListWithRelation(Set idValues, MyRelationParam relationParam) { + List resultList = this.getInList(idValues); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + @Override + public List getInListWithRelation(String inFilterField, Set inFilterValues, MyRelationParam relationParam) { + List resultList = this.getInList(inFilterField, inFilterValues); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + @Override + public List getInListWithRelation( + String inFilterField, Set inFilterValues, String orderBy, MyRelationParam relationParam) { + List resultList = this.getInList(inFilterField, inFilterValues, orderBy); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + @Override + public List getNotInList(Set idValues) { + return this.getNotInList(idFieldName, idValues, null); + } + + @Override + public List getNotInList(String inFilterField, Set inFilterValues) { + return this.getNotInList(inFilterField, inFilterValues, null); + } + + @Override + public List getNotInList(String inFilterField, Set inFilterValues, String orderBy) { + QueryWrapper queryWrapper; + if (CollUtil.isEmpty(inFilterValues)) { + queryWrapper = new QueryWrapper<>(); + } else { + String column = this.safeMapToColumnName(inFilterField); + queryWrapper = new QueryWrapper().notIn(column, inFilterValues); + } + if (StrUtil.isNotBlank(orderBy)) { + queryWrapper.last(ORDER_BY + orderBy); + } + return mapper().selectList(queryWrapper); + } + + @Override + public List getNotInListWithRelation(Set idValues, MyRelationParam relationParam) { + List resultList = this.getNotInList(idValues); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + @Override + public List getNotInListWithRelation( + String inFilterField, Set inFilterValues, MyRelationParam relationParam) { + List resultList = this.getNotInList(inFilterField, inFilterValues); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + @Override + public List getNotInListWithRelation( + String inFilterField, Set inFilterValues, String orderBy, MyRelationParam relationParam) { + List resultList = this.getNotInList(inFilterField, inFilterValues, orderBy); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + @Override + public long getCountByFilter(M filter) { + return mapper().selectCount(new QueryWrapper<>(filter)); + } + + @Override + public boolean existByFilter(M filter) { + return this.getCountByFilter(filter) > 0; + } + + @Override + public List getListByFilter(M filter) { + return mapper().selectList(new QueryWrapper<>(filter)); + } + + @Override + public List getListWithRelationByFilter(M filter, String orderBy, MyRelationParam relationParam) { + QueryWrapper queryWrapper = new QueryWrapper<>(filter); + if (StrUtil.isNotBlank(orderBy)) { + queryWrapper.last(ORDER_BY + orderBy); + } + List resultList = mapper().selectList(queryWrapper); + this.buildRelationForDataList(resultList, relationParam); + return resultList; + } + + /** + * 获取父主键Id下的所有子数据列表。单表查询,不进行任何数据关联。 + * + * @param parentIdFieldName 父主键字段名字,如"courseId"。 + * @param parentId 父主键的值。 + * @return 父主键Id下的所有子数据列表。 + */ + @Override + public List getListByParentId(String parentIdFieldName, K parentId) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + String parentIdColumn = this.safeMapToColumnName(parentIdFieldName); + if (parentId != null) { + queryWrapper.eq(parentIdColumn, parentId); + } else { + queryWrapper.isNull(parentIdColumn); + } + return mapper().selectList(queryWrapper); + } + + /** + * 根据指定的显示字段列表、过滤条件字符串和分组字符串,返回聚合计算后的查询结果。(基本是内部框架使用,不建议外部接口直接使用)。 + * + * @param selectFields 选择的字段列表,多个字段逗号分隔。 + * NOTE: 如果数据表字段和Java对象字段名字不同,Java对象字段应该以别名的形式出现。 + * 如: table_column_name modelFieldName。否则无法被反射回Bean对象。 + * @param whereClause SQL常量形式的条件从句。 + * @param groupBy SQL常量形式分组字段列表,逗号分隔。 + * @return 聚合计算后的数据结果集。 + */ + @Override + public List> getGroupedListByCondition( + String selectFields, String whereClause, String groupBy) { + return mapper().getGroupedListByCondition(tableName, selectFields, whereClause, groupBy); + } + + /** + * 根据指定的显示字段列表、过滤条件字符串和排序字符串,返回查询结果。(基本是内部框架使用,不建议外部接口直接使用)。 + * + * @param selectList 选择的Java字段列表。如果为空表示返回全部字段。 + * @param filter 过滤对象。 + * @param whereClause SQL常量形式的条件从句。 + * @param orderBy SQL常量形式排序字段列表,逗号分隔。 + * @return 查询结果。 + */ + @Override + public List getListByCondition(List selectList, M filter, String whereClause, String orderBy) { + QueryWrapper queryWrapper = new QueryWrapper<>(filter); + if (CollUtil.isNotEmpty(selectList)) { + String[] columns = new String[selectList.size()]; + for (int i = 0; i < selectList.size(); i++) { + columns[i] = this.safeMapToColumnName(selectList.get(i)); + } + queryWrapper.select(columns); + } + if (StrUtil.isNotBlank(whereClause)) { + queryWrapper.apply(whereClause); + } + if (StrUtil.isNotBlank(orderBy)) { + queryWrapper.last(ORDER_BY + orderBy); + } + return mapper().selectList(queryWrapper); + } + + /** + * 用指定过滤条件,计算记录数量。(基本是内部框架使用,不建议外部接口直接使用)。 + * + * @param whereClause SQL常量形式的条件从句。 + * @return 返回过滤后的数据数量。 + */ + @Override + public Integer getCountByCondition(String whereClause) { + return mapper().getCountByCondition(this.tableName, whereClause); + } + + @Override + public void maskFieldData(M data, Set ignoreFieldSet) { + if (data != null) { + this.maskFieldDataList(CollUtil.newArrayList(data), ignoreFieldSet); + } + } + + @Override + public void maskFieldDataList(List dataList, Set ignoreFieldSet) { + if (CollUtil.isEmpty(maskFieldList)) { + return; + } + for (Field maskField : maskFieldList) { + if (!CollUtil.contains(ignoreFieldSet, maskField.getName())) { + MaskField anno = maskField.getAnnotation(MaskField.class); + for (M data : dataList) { + Object maskedValue = this.doMaskFieldData(data, maskField, anno); + ReflectUtil.setFieldValue(data, maskField, maskedValue); + } + } + } + } + + @Override + public void compareAndSetMaskFieldData(M data, M originalData) { + if (CollUtil.isEmpty(maskFieldList)) { + return; + } + for (Field maskField : maskFieldList) { + Object value = ReflectUtil.getFieldValue(data, maskField); + if (value == null) { + continue; + } + MaskField anno = maskField.getAnnotation(MaskField.class); + String maskChar = String.valueOf(anno.maskChar()); + // 如果此时包含了掩码字符,说明数据没有变化,就要和原字段值脱敏后的结果比对。 + // 如果一致就用脱敏前的原值,覆盖当前提交的(包含掩码的)值,否则说明进行了部分 + // 修改,但是字段值中仍然含有掩码字符,这是不允许的。 + if (value.toString().contains(maskChar)) { + Object maskedOriginalValue = this.doMaskFieldData(originalData, maskField, anno); + if (ObjectUtil.notEqual(value, maskedOriginalValue)) { + throw new MyRuntimeException("数据验证失败,不能仅修改部分脱敏数据!"); + } + Object originalValue = ReflectUtil.getFieldValue(originalData, maskField); + ReflectUtil.setFieldValue(data, maskField, originalValue); + } + } + } + + @Override + public void verifyMaskFieldData(M data) { + if (CollUtil.isEmpty(maskFieldList)) { + return; + } + for (Field field : maskFieldList) { + Object value = ReflectUtil.getFieldValue(data, field); + if (value != null) { + String maskChar = String.valueOf(field.getAnnotation(MaskField.class).maskChar()); + if (value.toString().contains(maskChar)) { + throw new MyRuntimeException("数据验证失败,字段 [" + field.getName() + "] 数据存在脱敏掩码字符!"); + } + } + } + } + + @Override + public CallResult verifyRelatedData(M data, M originalData) { + return CallResult.ok(); + } + + @SuppressWarnings("unchecked") + @Override + public CallResult verifyRelatedData(M data) { + if (data == null) { + return CallResult.ok(); + } + Object id = ReflectUtil.getFieldValue(data, idFieldName); + if (id == null) { + return this.verifyRelatedData(data, null); + } + M originalData = this.getById((K) id); + if (originalData == null) { + return CallResult.error("数据验证失败,源数据不存在!"); + } + return this.verifyRelatedData(data, originalData); + } + + @SuppressWarnings("unchecked") + @Override + public CallResult verifyRelatedData(List dataList) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 1. 先过滤出数据列表中的主键Id集合。 + Set idList = dataList.stream() + .filter(c -> ReflectUtil.getFieldValue(c, idFieldName) != null) + .map(c -> (K) ReflectUtil.getFieldValue(c, idFieldName)).collect(toSet()); + // 2. 列表中,我们目前仅支持全部是更新数据,或全部新增数据,不能混着。如果有主键值,说明当前全是更新数据。 + if (CollUtil.isNotEmpty(idList)) { + // 3. 这里是批量读取的优化,用一个主键值得in list查询,一步获取全部原有数据。然后再在内存中基于Map排序。 + List originalList = this.getInList(idList); + Map originalMap = originalList.stream() + .collect(toMap(c -> ReflectUtil.getFieldValue(c, idFieldName), c2 -> c2)); + // 迭代列表,传入当前最新数据和更新前数据进行比对,如果关联数据变化了,就对新数据进行合法性验证。 + for (M data : dataList) { + CallResult result = this.verifyRelatedData( + data, originalMap.get(ReflectUtil.getFieldValue(data, idFieldName))); + if (!result.isSuccess()) { + return result; + } + } + } else { + // 4. 迭代列表,传入当前最新数据,对关联数据进行合法性验证。 + for (M data : dataList) { + CallResult result = this.verifyRelatedData(data, null); + if (!result.isSuccess()) { + return result; + } + } + } + return CallResult.ok(); + } + + @Override + public CallResult verifyRemoteRelatedData(M data, M originalData) { + return CallResult.ok(); + } + + @SuppressWarnings("unchecked") + @Override + public CallResult verifyRemoteRelatedData(List dataList) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 1. 先过滤出数据列表中的主键Id集合。 + Set idList = dataList.stream() + .filter(c -> ReflectUtil.getFieldValue(c, idFieldName) != null) + .map(c -> (K) ReflectUtil.getFieldValue(c, idFieldName)).collect(toSet()); + // 2. 列表中,我们目前仅支持全部是更新数据,或全部新增数据,不能混着。如果有主键值,说明当前全是更新数据。 + if (CollUtil.isNotEmpty(idList)) { + // 3. 这里是批量读取的优化,用一个主键值得in list查询,一步获取全部原有数据。然后再在内存中基于Map排序。 + List originalList = this.getInList(idList); + Map originalMap = originalList.stream() + .collect(toMap(c -> ReflectUtil.getFieldValue(c, idFieldName), c2 -> c2)); + // 迭代列表,传入当前最新数据和更新前数据进行比对,如果关联数据变化了,就对新数据进行合法性验证。 + for (M data : dataList) { + CallResult result = this.verifyRemoteRelatedData( + data, originalMap.get(ReflectUtil.getFieldValue(data, idFieldName))); + if (!result.isSuccess()) { + return result; + } + } + } else { + // 4. 迭代列表,传入当前最新数据,对关联数据进行合法性验证。 + for (M data : dataList) { + CallResult result = this.verifyRemoteRelatedData(data, null); + if (!result.isSuccess()) { + return result; + } + } + } + return CallResult.ok(); + } + + @Override + public CallResult verifyAllRelatedData(M data, M originalData) { + CallResult verifyResult = this.verifyRelatedData(data, originalData); + if (!verifyResult.isSuccess()) { + return verifyResult; + } + return this.verifyRemoteRelatedData(data, originalData); + } + + @SuppressWarnings("unchecked") + @Override + public CallResult verifyAllRelatedData(M data) { + if (data == null) { + return CallResult.ok(); + } + Object id = ReflectUtil.getFieldValue(data, idFieldName); + if (id == null) { + return this.verifyAllRelatedData(data, null); + } + M originalData = this.getById((K) id); + if (originalData == null) { + return CallResult.error("数据验证失败,源数据不存在!"); + } + return this.verifyAllRelatedData(data, originalData); + } + + @Override + public CallResult verifyAllRelatedData(List dataList) { + CallResult verifyResult = this.verifyRelatedData(dataList); + if (!verifyResult.isSuccess()) { + return verifyResult; + } + return this.verifyRemoteRelatedData(dataList); + } + + @Override + public CallResult verifyImportForConstDict(List dataList, String fieldName, Function idGetter) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 这里均为内部调用方法,因此出现任何错误均为代码BUG,所以我们会及时抛出异常。 + Field field = ReflectUtil.getField(modelClass, fieldName); + if (field == null) { + String errorMessage = StrFormatter.format("FieldName [{}] does not exist", fieldName); + throw new MyRuntimeException(errorMessage); + } + RelationConstDict relationConstDict = field.getAnnotation(RelationConstDict.class); + if (relationConstDict == null) { + String errorMessage = StrFormatter.format("FieldName [{}] doesn't have RelationConstDict.", fieldName); + throw new MyRuntimeException(errorMessage); + } + Method m = ReflectUtil.getMethodByName(relationConstDict.constantDictClass(), "isValid"); + for (M data : dataList) { + R id = idGetter.apply(data); + if (id != null) { + boolean ok = ReflectUtil.invokeStatic(m, id); + if (!ok) { + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的常量字典值 [%s]!", + relationConstDict.masterIdField(), id); + return CallResult.error(errorMessage, data); + } + } + } + return CallResult.ok(); + } + + @Override + public CallResult verifyImportForGlobalDict(List dataList, String fieldName, Function idGetter) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 这里均为内部调用方法,因此出现任何错误均为代码BUG,所以我们会及时抛出异常。 + Field field = ReflectUtil.getField(modelClass, fieldName); + if (field == null) { + throw new MyRuntimeException(StrFormatter.format("FieldName [{}] does not exist.", fieldName)); + } + RelationGlobalDict relationGlobalDict = field.getAnnotation(RelationGlobalDict.class); + if (relationGlobalDict == null) { + throw new MyRuntimeException( + StrFormatter.format("FieldName [{}] doesn't have RelationGlobalDict.", fieldName)); + } + LocalRelationStruct relationStruct = this.relationGlobalDictStructList.stream() + .filter(c -> c.relationField.getName().equals(fieldName)).findFirst().orElse(null); + Assert.notNull(relationStruct, "GlobalDictRelationStruct for [" + fieldName + "] can't be NULL"); + Map dictMap = ReflectUtil.invoke( + relationStruct.localService, + relationStruct.globalDictMethd, + relationStruct.relationGlobalDict.dictCode(), null); + for (M data : dataList) { + R id = idGetter.apply(data); + if (id != null && !dictMap.containsKey(id.toString())) { + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的全局编码字典值 [%s]!", + relationGlobalDict.masterIdField(), id); + return CallResult.error(errorMessage, data); + } + } + return CallResult.ok(); + } + + @Override + public CallResult verifyImportForDict(List dataList, String fieldName, Function idGetter) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 这里均为内部调用方法,因此出现任何错误均为代码BUG,所以我们会及时抛出异常。 + Field field = ReflectUtil.getField(modelClass, fieldName); + if (field == null) { + throw new MyRuntimeException(StrFormatter.format("FieldName [{}] doesn't exist.", fieldName)); + } + RelationDict relationDict = field.getAnnotation(RelationDict.class); + if (relationDict == null) { + throw new MyRuntimeException( + StrFormatter.format("FieldName [{}] doesn't have RelationDict.", fieldName)); + } + if (relationDict.slaveClientClass().equals(DummyClass.class)) { + BaseService service = ApplicationContextHolder.getBean( + this.getNormalizedSlaveServiceName(relationDict.slaveServiceName(), relationDict.slaveModelClass())); + Set dictIdSet = service.getAllList().stream() + .map(c -> ReflectUtil.getFieldValue(c, relationDict.slaveIdField())).collect(toSet()); + for (M data : dataList) { + R id = idGetter.apply(data); + if (id != null && !dictIdSet.contains(id)) { + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的字典表字典值 [%s]!", + relationDict.masterIdField(), id); + return CallResult.error(errorMessage, data); + } + } + return CallResult.ok(); + } + Set masterIdSet = dataList.stream().map(idGetter).filter(Objects::nonNull).collect(toSet()); + @SuppressWarnings("unchecked") + BaseClient client = (BaseClient) + ApplicationContextHolder.getBean(relationDict.slaveClientClass()); + ResponseResult> responseResult = client.listByIds(masterIdSet, false); + if (!responseResult.isSuccess()) { + String errorMessage = String.format("数据验证失败,字段 [%s] 获取关联数据失败", fieldName); + return CallResult.error(errorMessage); + } + Set dictIdSet = responseResult.getData().stream() + .map(c -> ReflectUtil.getFieldValue(c, relationDict.slaveIdField())).collect(toSet()); + for (M data : dataList) { + R id = idGetter.apply(data); + if (id != null && !dictIdSet.contains(id)) { + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的字典表字典值 [%s]!", + relationDict.masterIdField(), id); + return CallResult.error(errorMessage, data); + } + } + return CallResult.ok(); + } + + @Override + public CallResult verifyImportForDatasourceDict(List dataList, String fieldName, Function idGetter) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 这里均为内部调用方法,因此出现任何错误均为代码BUG,所以我们会及时抛出异常。 + Field field = ReflectUtil.getField(modelClass, fieldName); + if (field == null) { + throw new MyRuntimeException(StrFormatter.format("FieldName [{}] doesn't exist.", fieldName)); + } + RelationDict relationDict = field.getAnnotation(RelationDict.class); + if (relationDict == null) { + throw new MyRuntimeException( + StrFormatter.format("FieldName [{}] doesn't have RelationDict.", fieldName)); + } + // 验证数据源字典Id,由于被依赖的数据表,可能包含大量业务数据,因此还是分批做存在性比对更为高效。 + Set idSet = dataList.stream() + .filter(c -> idGetter.apply(c) != null).map(idGetter).collect(toSet()); + if (CollUtil.isEmpty(idSet)) { + return CallResult.ok(); + } + if (idSet.iterator().next() instanceof String) { + idSet = idSet.stream().filter(c -> StrUtil.isNotBlank((String) c)).collect(toSet()); + } + if (CollUtil.isEmpty(idSet)) { + return CallResult.ok(); + } + if (relationDict.slaveClientClass().equals(DummyClass.class)) { + BaseService slaveService = ApplicationContextHolder.getBean( + this.getNormalizedSlaveServiceName(relationDict.slaveServiceName(), relationDict.slaveModelClass())); + List notExistIdList = slaveService.notExist(relationDict.slaveIdField(), idSet, true); + if (CollUtil.isNotEmpty(notExistIdList)) { + R notExistId = notExistIdList.get(0); + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的数据源表字典值 [%s]!", + relationDict.masterIdField(), notExistId); + M data = dataList.stream() + .filter(c -> ObjectUtil.equals(idGetter.apply(c), notExistId)).findFirst().orElse(null); + return CallResult.error(errorMessage, data); + } + } else { + @SuppressWarnings("unchecked") + BaseClient client = (BaseClient) + ApplicationContextHolder.getBean(relationDict.slaveClientClass()); + MyQueryParam queryParam = new MyQueryParam(); + queryParam.setInFilterField(relationDict.slaveIdField()); + queryParam.setInFilterValues(idSet); + ResponseResult> responseResult = client.notExist(queryParam); + if (!responseResult.isSuccess()) { + String errorMessage = String.format("数据验证失败,字段 [%s] 获取关联数据失败!", fieldName); + return CallResult.error(errorMessage); + } + List notExistIdList = responseResult.getData(); + if (CollUtil.isNotEmpty(notExistIdList)) { + Object notExistId = notExistIdList.get(0); + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的数据源表字典值 [%s]!", + relationDict.masterIdField(), notExistId); + M data = dataList.stream() + .filter(c -> ObjectUtil.equals(idGetter.apply(c), notExistId)).findFirst().orElse(null); + return CallResult.error(errorMessage, data); + } + } + return CallResult.ok(); + } + + @Override + public CallResult verifyImportForOneToOneRelation(List dataList, String fieldName, Function idGetter) { + if (CollUtil.isEmpty(dataList)) { + return CallResult.ok(); + } + // 这里均为内部调用方法,因此出现任何错误均为代码BUG,所以我们会及时抛出异常。 + Field field = ReflectUtil.getField(modelClass, fieldName); + if (field == null) { + throw new MyRuntimeException(StrFormatter.format("FieldName [{}] doesn't exist", fieldName)); + } + RelationOneToOne relationOneToOne = field.getAnnotation(RelationOneToOne.class); + if (relationOneToOne == null) { + throw new MyRuntimeException( + StrFormatter.format("FieldName [{}] doesn't have RelationOneToOne.", fieldName)); + } + // 验证一对一关联Id,由于被依赖的数据表,可能包含大量业务数据,因此还是分批做存在性比对更为高效。 + Set idSet = dataList.stream() + .filter(c -> idGetter.apply(c) != null).map(idGetter).collect(toSet()); + if (CollUtil.isEmpty(idSet)) { + return CallResult.ok(); + } + if (relationOneToOne.slaveClientClass().equals(DummyClass.class)) { + BaseService slaveService = ApplicationContextHolder.getBean( + this.getNormalizedSlaveServiceName(relationOneToOne.slaveServiceName(), relationOneToOne.slaveModelClass())); + List notExistIdList = slaveService.notExist(relationOneToOne.slaveIdField(), idSet, true); + if (CollUtil.isNotEmpty(notExistIdList)) { + R notExistId = notExistIdList.get(0); + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的一对一关联值 [%s]!", + relationOneToOne.masterIdField(), notExistId); + M data = dataList.stream() + .filter(c -> ObjectUtil.equals(idGetter.apply(c), notExistId)).findFirst().orElse(null); + return CallResult.error(errorMessage, data); + } + } else { + @SuppressWarnings("unchecked") + BaseClient client = (BaseClient) + ApplicationContextHolder.getBean(relationOneToOne.slaveClientClass()); + MyQueryParam queryParam = new MyQueryParam(); + queryParam.setInFilterField(relationOneToOne.slaveIdField()); + queryParam.setInFilterValues(idSet); + ResponseResult> responseResult = client.notExist(queryParam); + if (!responseResult.isSuccess()) { + String errorMessage = String.format("数据验证失败,字段 [%s] 获取关联数据失败!", fieldName); + return CallResult.error(errorMessage); + } + List notExistIdList = responseResult.getData(); + if (CollUtil.isNotEmpty(notExistIdList)) { + Object notExistId = notExistIdList.get(0); + String errorMessage = String.format("数据验证失败,字段 [%s] 存在无效的一对一关联值 [%s]!", + relationOneToOne.masterIdField(), notExistId); + M data = dataList.stream() + .filter(c -> ObjectUtil.equals(idGetter.apply(c), notExistId)).findFirst().orElse(null); + return CallResult.error(errorMessage, data); + } + } + return CallResult.ok(); + } + + /** + * 集成所有与主表实体对象相关的关联数据列表。包括本地和远程服务的一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + @Override + public void buildRelationForDataList(List resultList, MyRelationParam relationParam) { + this.buildRelationForDataList(resultList, relationParam, null); + } + + /** + * 集成所有与主表实体对象相关的关联数据列表。包括本地和远程服务的一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + @Override + public void buildRelationForDataList( + List resultList, MyRelationParam relationParam, Set ignoreFields) { + if (relationParam == null || CollUtil.isEmpty(resultList)) { + return; + } + boolean dataFilterValue = GlobalThreadLocal.setDataFilter(false); + try { + // 集成本地一对一和字段级别的数据关联。 + // NOTE: 这里必须要在集成远程一对一之前集成本地一对一。因为远程集成方法中,会为本地一对一从表数据进行远程集成。 + boolean buildOneToOne = relationParam.isBuildOneToOne() || relationParam.isBuildOneToOneWithDict(); + // 这里集成一对一关联。 + if (buildOneToOne) { + this.buildOneToOneForDataList(resultList, relationParam, ignoreFields); + } + // 集成一对多关联 + if (relationParam.isBuildOneToMany()) { + this.buildOneToManyForDataList(resultList, relationParam, ignoreFields); + } + // 这里集成多对多关联。 + if (relationParam.isBuildRelationManyToMany()) { + this.buildManyToManyForDataList(resultList, ignoreFields); + } + // 这里集成字典关联 + if (relationParam.isBuildDict()) { + // 构建全局字典关联关系 + this.buildGlobalDictForDataList(resultList, ignoreFields); + // 构建常量字典关联关系 + this.buildConstDictForDataList(resultList, ignoreFields); + this.buildDictForDataList(resultList, buildOneToOne, ignoreFields); + } + // 集成远程一对一和字段级别的数据关联。 + boolean buildRemoteOneToOne = + relationParam.isBuildRemoteOneToOne() || relationParam.isBuildRemoteOneToOneWithDict(); + if (buildRemoteOneToOne) { + this.buildRemoteOneToOneForDataList(resultList, relationParam, ignoreFields); + } + if (relationParam.isBuildRemoteDict()) { + this.buildRemoteDictForDataList(resultList, buildRemoteOneToOne, ignoreFields); + } + // 组装本地聚合计算关联数据 + if (relationParam.isBuildAggregation()) { + // 处理多一多场景下,根据主表的结果,进行从表聚合数据的计算。 + this.buildOneToManyAggregationForDataList(resultList, buildAggregationAdditionalWhereCriteria(), ignoreFields); + // 处理多对多场景下,根据主表的结果,进行从表聚合数据的计算。 + this.buildManyToManyAggregationForDataList(resultList, buildAggregationAdditionalWhereCriteria(), ignoreFields); + } + // 组装远程聚合计算关联数据 + if (relationParam.isBuildRemoteAggregation()) { + // 一对多场景。 + this.buildRemoteOneToManyAggregationForDataList(resultList, buildAggregationAdditionalWhereCriteria(), ignoreFields); + // 处理多对多场景。 + this.buildRemoteManyToManyAggregationForDataList(resultList, buildAggregationAdditionalWhereCriteria(), ignoreFields); + } + } finally { + GlobalThreadLocal.setDataFilter(dataFilterValue); + } + } + + /** + * 该函数主要用于对查询结果的批量导出。不同于支持分页的列表查询,批量导出没有分页机制, + * 因此在导出数据量较大的情况下,很容易给数据库的内存、CPU和IO带来较大的压力。而通过 + * 我们的分批处理,可以极大的规避该问题的出现几率。调整batchSize的大小,也可以有效的 + * 改善运行效率。 + * 我们目前的处理机制是,先从主表取出所有符合条件的主表数据,这样可以避免分批处理时, + * 后面几批数据,因为skip过多而带来的效率问题。因为是单表过滤,不会给数据库带来过大的压力。 + * 之后再在主表结果集数据上进行分批级联处理。 + * 集成所有与主表实体对象相关的关联数据列表。包括一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param batchSize 每批集成的记录数量。小于等于0时将不做分批处理。 + */ + @Override + public void buildRelationForDataList(List resultList, MyRelationParam relationParam, int batchSize) { + this.buildRelationForDataList(resultList, relationParam, batchSize, null); + } + + /** + * 该函数主要用于对查询结果的批量导出。不同于支持分页的列表查询,批量导出没有分页机制, + * 因此在导出数据量较大的情况下,很容易给数据库的内存、CPU和IO带来较大的压力。而通过 + * 我们的分批处理,可以极大的规避该问题的出现几率。调整batchSize的大小,也可以有效的 + * 改善运行效率。 + * 我们目前的处理机制是,先从主表取出所有符合条件的主表数据,这样可以避免分批处理时, + * 后面几批数据,因为skip过多而带来的效率问题。因为是单表过滤,不会给数据库带来过大的压力。 + * 之后再在主表结果集数据上进行分批级联处理。 + * 集成所有与主表实体对象相关的关联数据列表。包括一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param batchSize 每批集成的记录数量。小于等于0时将不做分批处理。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + @Override + public void buildRelationForDataList( + List resultList, MyRelationParam relationParam, int batchSize, Set ignoreFields) { + if (CollUtil.isEmpty(resultList)) { + return; + } + if (batchSize <= 0) { + this.buildRelationForDataList(resultList, relationParam); + return; + } + int totalCount = resultList.size(); + int fromIndex = 0; + int toIndex = Math.min(batchSize, totalCount); + while (toIndex > fromIndex) { + List subResultList = resultList.subList(fromIndex, toIndex); + this.buildRelationForDataList(subResultList, relationParam); + fromIndex = toIndex; + toIndex = Math.min(batchSize + fromIndex, totalCount); + } + } + + /** + * 集成所有与主表实体对象相关的关联数据对象。包括本地和远程服务的一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param dataObject 主表实体对象。数据集成将直接作用于该对象。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param 实体对象类型。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + @Override + public void buildRelationForData(T dataObject, MyRelationParam relationParam) { + this.buildRelationForData(dataObject, relationParam, null); + } + + /** + * 集成所有与主表实体对象相关的关联数据对象。包括本地和远程服务的一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param dataObject 主表实体对象。数据集成将直接作用于该对象。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @param 实体对象类型。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + @Override + public void buildRelationForData(T dataObject, MyRelationParam relationParam, Set ignoreFields) { + if (dataObject == null || relationParam == null) { + return; + } + boolean dataFilterValue = GlobalThreadLocal.setDataFilter(false); + try { + // 集成本地一对一和字段级别的数据关联。 + boolean buildOneToOne = relationParam.isBuildOneToOne() || relationParam.isBuildOneToOneWithDict(); + if (buildOneToOne) { + this.buildOneToOneForData(dataObject, relationParam, ignoreFields); + } + // 集成一对多关联 + if (relationParam.isBuildOneToMany()) { + this.buildOneToManyForData(dataObject, relationParam, ignoreFields); + } + if (relationParam.isBuildDict()) { + // 构建全局字典关联关系 + this.buildGlobalDictForData(dataObject, ignoreFields); + // 构建常量字典关联关系 + this.buildConstDictForData(dataObject, ignoreFields); + // 构建本地数据字典关联关系。 + this.buildDictForData(dataObject, buildOneToOne, ignoreFields); + } + boolean buildRemoteOneToOne = + relationParam.isBuildRemoteOneToOne() || relationParam.isBuildRemoteOneToOneWithDict(); + if (buildRemoteOneToOne) { + this.buildRemoteOneToOneForData(dataObject, relationParam, ignoreFields); + } + if (relationParam.isBuildRemoteDict()) { + this.buildRemoteDictForData(dataObject, buildRemoteOneToOne, ignoreFields); + } + // 组装本地聚合计算关联数据 + if (relationParam.isBuildAggregation()) { + // 构建一对多场景 + buildOneToManyAggregationForData(dataObject, buildAggregationAdditionalWhereCriteria(), ignoreFields); + // 开始处理多对多场景。 + buildManyToManyAggregationForData(dataObject, buildAggregationAdditionalWhereCriteria(), ignoreFields); + } + // 组装远程聚合计算关联数据 + if (relationParam.isBuildRemoteAggregation()) { + // 处理一对多场景 + this.buildRemoteOneToManyAggregationForData(dataObject, buildAggregationAdditionalWhereCriteria(), ignoreFields); + // 处理多对多场景 + this.buildRemoteManyToManyAggregationForData(dataObject, buildAggregationAdditionalWhereCriteria(), ignoreFields); + } + if (relationParam.isBuildRelationManyToMany()) { + this.buildRelationManyToMany(dataObject, ignoreFields); + } + } finally { + GlobalThreadLocal.setDataFilter(dataFilterValue); + } + } + + protected void buildLocalOneToOneDictOnly(T dataObject) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationOneToOneStructList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationOneToOneStructList) { + BaseService relationService = relationStruct.localService; + Object relationObject = ReflectUtil.getFieldValue(dataObject, relationStruct.relationField); + if (relationObject != null) { + @SuppressWarnings("unchecked") + BaseService proxyTarget = + (BaseService) AopTargetUtil.getTarget(relationService); + // 关联本地字典 + proxyTarget.buildDictForData(relationObject, false, null); + // 关联常量字典 + proxyTarget.buildGlobalDictForData(relationObject, null); + // 关联常量字典 + proxyTarget.buildConstDictForData(relationObject, null); + } + } + } + + /** + * 为参数列表数据集成本地静态字典关联数据。 + * + * @param resultList 主表数据列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildConstDictForDataList(List resultList, Set ignoreFields) { + if (CollUtil.isEmpty(this.relationConstDictStructList) || CollUtil.isEmpty(resultList)) { + return; + } + for (LocalRelationStruct relationStruct : this.relationConstDictStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + for (M dataObject : resultList) { + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + String name = relationStruct.dictMap.get(id); + if (name != null) { + Map dictMap = new HashMap<>(2); + dictMap.put("id", id); + dictMap.put("name", name); + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, dictMap); + } + } + } + } + } + + /** + * 为实体对象参数列表数据集成全局字典关联数据。 + * + * @param resultList 主表数据列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildGlobalDictForDataList(List resultList, Set ignoreFields) { + if (CollUtil.isEmpty(this.relationGlobalDictStructList) || CollUtil.isEmpty(resultList)) { + return; + } + for (LocalRelationStruct relationStruct : this.relationGlobalDictStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isNotEmpty(masterIdSet)) { + Map dictMap = ReflectUtil.invoke( + relationStruct.localService, + relationStruct.globalDictMethd, + relationStruct.relationGlobalDict.dictCode(), masterIdSet); + MyModelUtil.makeGlobalDictRelation( + modelClass, resultList, dictMap, relationStruct.relationField.getName()); + } + } + } + + /** + * 为参数实体对象数据集成本地静态字典关联数据。 + * + * @param dataObject 实体对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildConstDictForData(T dataObject, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.relationConstDictStructList)) { + return; + } + for (LocalRelationStruct relationStruct : this.relationConstDictStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + String name = relationStruct.dictMap.get(id); + if (name != null) { + Map dictMap = new HashMap<>(2); + dictMap.put("id", id); + dictMap.put("name", name); + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, dictMap); + } + } + } + } + + /** + * 为参数实体对象数据集成全局字典关联数据。 + * + * @param dataObject 实体对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildGlobalDictForData(T dataObject, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.relationGlobalDictStructList)) { + return; + } + for (LocalRelationStruct relationStruct : this.relationGlobalDictStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + Map dictMap = ReflectUtil.invoke( + relationStruct.localService, + relationStruct.globalDictMethd, + relationStruct.relationGlobalDict.dictCode(), CollUtil.newHashSet(id)); + String name = dictMap.get(id.toString()); + if (name != null) { + Map reulstDictMap = new HashMap<>(2); + reulstDictMap.put("id", id); + reulstDictMap.put("name", name); + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, reulstDictMap); + } + } + } + } + + /** + * 集成主表和多对多中间表之间的关联关系。 + * + * @param dataObject 关联后的主表数据对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildRelationManyToMany(T dataObject, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationManyToManyStructList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationManyToManyStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Object masterIdValue = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + String masterIdColumn = this.safeMapToColumnName(relationStruct.masterIdField.getName()); + Map filterMap = new HashMap<>(1); + filterMap.put(masterIdColumn, masterIdValue); + List manyToManyList = relationStruct.manyToManyMapper.selectByMap(filterMap); + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, manyToManyList); + } + } + + /** + * 为实体对象参数列表数据集成远程一对一关联数据。 + * + * @param resultList 实体对象数据列表。 + * @param relationParam 从表关联参数对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + private void buildRemoteOneToOneForDataList(List resultList, MyRelationParam relationParam, Set ignoreFields) { + if (CollUtil.isEmpty(this.remoteRelationOneToOneStructList) || CollUtil.isEmpty(resultList)) { + return; + } + boolean withDict = relationParam.isBuildRemoteOneToOneWithDict(); + for (RemoteRelationStruct relationStruct : this.remoteRelationOneToOneStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isNotEmpty(masterIdSet)) { + boolean buildRemoteOneToOneDict = withDict && relationStruct.relationOneToOne.loadSlaveDict(); + MyQueryParam queryParam = new MyQueryParam(buildRemoteOneToOneDict); + queryParam.setUseDataFilter(false); + MyWhereCriteria whereCriteria = new MyWhereCriteria(); + whereCriteria.setCriteria( + relationStruct.relationOneToOne.slaveIdField(), MyWhereCriteria.OPERATOR_IN, masterIdSet); + queryParam.addCriteriaList(whereCriteria); + if (relationParam.getIgnoreMaskFieldMap() != null) { + Set ignoreMaskFieldSet = relationParam.getIgnoreMaskFieldMap() + .get(relationStruct.relationOneToOne.slaveModelClass().getSimpleName()); + queryParam.setIgnoreMaskFieldSet(ignoreMaskFieldSet); + } + ResponseResult> result = relationStruct.remoteClient.listBy(queryParam); + if (result.isSuccess()) { + List relationList = result.getData().getDataList(); + MyModelUtil.makeOneToOneRelation( + modelClass, resultList, relationList, relationStruct.relationField.getName()); + } else { + this.logErrorOrThrowException(result.getErrorMessage()); + } + } + } + } + + /** + * 为实体对象数据集成远程一对一关联数据。 + * + * @param dataObject 实体对象。 + * @param relationParam 从表关联参数对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + private void buildRemoteOneToOneForData(T dataObject, MyRelationParam relationParam, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.remoteRelationOneToOneStructList)) { + return; + } + for (RemoteRelationStruct relationStruct : this.remoteRelationOneToOneStructList) { + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id == null || CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + MyQueryParam queryParam = new MyQueryParam(relationParam.isBuildOneToOneWithDict()); + queryParam.setUseDataFilter(false); + MyWhereCriteria whereCriteria = new MyWhereCriteria(); + whereCriteria.setCriteria( + relationStruct.relationOneToOne.slaveIdField(), MyWhereCriteria.OPERATOR_EQUAL, id); + queryParam.addCriteriaList(whereCriteria); + if (relationParam.getIgnoreMaskFieldMap() != null) { + Set ignoreMaskFieldSet = relationParam.getIgnoreMaskFieldMap() + .get(relationStruct.relationOneToOne.slaveModelClass().getSimpleName()); + queryParam.setIgnoreMaskFieldSet(ignoreMaskFieldSet); + } + ResponseResult result = relationStruct.remoteClient.getBy(queryParam); + if (result.isSuccess()) { + Object relationObject = this.normalizeData( + result.getData(), relationStruct.relationOneToOne.slaveModelClass()); + if (relationObject != null) { + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, relationObject); + } + } else { + this.logErrorOrThrowException(result.getErrorMessage()); + } + } + } + + /** + * 为实体对象参数列表数据集成远程字典关联数据。 + * + * @param resultList 实体对象数据列表。 + * @param hasBuiltOneToOne 性能优化参数。如果该值为true,同时注解参数RelationDict.equalOneToOneRelationField + * 不为空,则直接从已经完成一对一数据关联的从表对象中获取数据,减少一次数据库交互。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + private void buildRemoteDictForDataList(List resultList, boolean hasBuiltOneToOne, Set ignoreFields) { + if (CollUtil.isEmpty(this.remoteRelationDictStructList) || CollUtil.isEmpty(resultList)) { + return; + } + for (RemoteRelationStruct relationStruct : this.remoteRelationDictStructList) { + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isEmpty(masterIdSet) + || CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + List relationList = null; + if (hasBuiltOneToOne && relationStruct.equalOneToOneRelationField != null) { + relationList = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.equalOneToOneRelationField)) + .filter(Objects::nonNull) + .collect(toList()); + } else { + MyQueryParam queryParam = new MyQueryParam(false); + queryParam.setUseDataFilter(false); + MyWhereCriteria whereCriteria = new MyWhereCriteria(); + whereCriteria.setCriteria( + relationStruct.relationDict.slaveIdField(), MyWhereCriteria.OPERATOR_IN, masterIdSet); + queryParam.addCriteriaList(whereCriteria); + ResponseResult> result = relationStruct.remoteClient.listBy(queryParam); + // 成功或者没有数据 + if (result.isSuccess()) { + relationList = result.getData().getDataList(); + } else { + logErrorOrThrowException(result.getErrorMessage()); + } + } + MyModelUtil.makeDictRelation( + modelClass, resultList, relationList, relationStruct.relationField.getName()); + } + } + + /** + * 为实体对象数据集成远程字典关联数据。 + * + * @param dataObject 实体对象。 + * @param hasBuiltOneToOne 性能优化参数。如果该值为true,同时注解参数RelationDict.equalOneToOneRelationField + * 不为空,则直接从已经完成一对一数据关联的从表对象中获取数据,减少一次数据库交互。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @throws RemoteDataBuildException ignoreRpcError()方法返回false,同时远程服务调用出现错误时抛出此异常。 + */ + private void buildRemoteDictForData(T dataObject, boolean hasBuiltOneToOne, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.remoteRelationDictStructList)) { + return; + } + for (RemoteRelationStruct relationStruct : this.remoteRelationDictStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Object relationObject = null; + if (hasBuiltOneToOne && relationStruct.equalOneToOneRelationField != null) { + relationObject = ReflectUtil.getFieldValue(dataObject, relationStruct.equalOneToOneRelationField); + } else { + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + relationObject = this.getRemoteDictData(relationStruct, id); + } + } + MyModelUtil.makeDictRelation( + modelClass, dataObject, relationObject, relationStruct.relationField.getName()); + } + } + + private Object getRemoteDictData(RemoteRelationStruct relationStruct, Object id) { + MyQueryParam queryParam = new MyQueryParam(false); + queryParam.setUseDataFilter(false); + MyWhereCriteria whereCriteria = new MyWhereCriteria(); + whereCriteria.setCriteria( + relationStruct.relationDict.slaveIdField(), MyWhereCriteria.OPERATOR_EQUAL, id); + queryParam.addCriteriaList(whereCriteria); + ResponseResult result = relationStruct.remoteClient.getBy(queryParam); + if (!result.isSuccess()) { + this.logErrorOrThrowException(result.getErrorMessage()); + return null; + } + return this.normalizeData(result.getData(), relationStruct.relationDict.slaveModelClass()); + } + + /** + * 根据实体对象参数列表和过滤条件,集成远程一对多关联聚合计算数据。 + * + * @param resultList 实体对象数据列表。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildRemoteOneToManyAggregationForDataList( + List resultList, Map> criteriaListMap, Set ignoreFields) { + if (CollUtil.isEmpty(this.remoteRelationOneToManyAggrStructList) || CollUtil.isEmpty(resultList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(this.remoteRelationOneToManyAggrStructList.size()); + } + for (RemoteRelationStruct relationStruct : this.remoteRelationOneToManyAggrStructList) { + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isEmpty(masterIdSet) + || CollUtil.contains(ignoreFields, ignoreFields.contains(relationStruct.relationField.getName()))) { + continue; + } + RelationOneToManyAggregation relation = relationStruct.relationOneToManyAggregation; + MyAggregationParam aggregationParam = + createAggregationParam(AggregationKind.ONE_TO_MANY, + relation.aggregationType(), relation.aggregationField(), relation.slaveIdField()); + List criteriaList = + criteriaListMap.get(relationStruct.relationField.getName()); + if (criteriaList == null) { + criteriaList = new LinkedList<>(); + } + MyWhereCriteria criteria = new MyWhereCriteria(); + CallResult result = criteria.setCriteria( + relation.slaveIdField(), MyWhereCriteria.OPERATOR_IN, masterIdSet); + if (!result.isSuccess()) { + log.error("过滤条件设置失败,错误:" + result.getErrorMessage()); + throw new IllegalStateException(result.getErrorMessage()); + } + criteriaList.add(criteria); + aggregationParam.setWhereCriteriaList(criteriaList); + aggregationParam.setUseDataFilter(false); + ResponseResult>> responseResult = + relationStruct.remoteClient.aggregateBy(aggregationParam); + if (responseResult.isSuccess()) { + this.doMakeAggregationData(responseResult.getData(), resultList, relationStruct); + } else { + this.logErrorOrThrowException(result.getErrorMessage()); + } + } + } + + /** + * 根据实体对象和过滤条件,集成远程一对多关联聚合计算数据。 + * + * @param dataObject 实体对象。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildRemoteOneToManyAggregationForData( + T dataObject, Map> criteriaListMap, Set ignoreFields) { + // 处理一对多场景 + if (dataObject == null || CollUtil.isEmpty(this.remoteRelationOneToManyAggrStructList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(this.remoteRelationOneToManyAggrStructList.size()); + } + for (RemoteRelationStruct relationStruct : this.remoteRelationOneToManyAggrStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Object masterIdValue = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (masterIdValue != null) { + RelationOneToManyAggregation relation = relationStruct.relationOneToManyAggregation; + MyAggregationParam aggregationParam = createAggregationParam(AggregationKind.ONE_TO_MANY, + relation.aggregationType(), relation.aggregationField(), relation.slaveIdField()); + List criteriaList = criteriaListMap.get(relationStruct.relationField.getName()); + if (criteriaList == null) { + criteriaList = new LinkedList<>(); + } + MyWhereCriteria criteria = new MyWhereCriteria(); + criteria.setCriteria(relation.slaveModelClass(), + relation.slaveIdField(), MyWhereCriteria.OPERATOR_EQUAL, masterIdValue); + criteriaList.add(criteria); + aggregationParam.setWhereCriteriaList(criteriaList); + aggregationParam.setUseDataFilter(false); + ResponseResult>> result = + relationStruct.remoteClient.aggregateBy(aggregationParam); + if (result.isSuccess()) { + List resultList = new LinkedList<>(); + resultList.add(dataObject); + this.doMakeAggregationData(result.getData(), resultList, relationStruct); + } else { + this.logErrorOrThrowException(result.getErrorMessage()); + } + } + } + } + + /** + * 根据实体对象参数列表和过滤条件,集成远程多对多关联聚合计算数据。 + * + * @param resultList 实体对象数据列表。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildRemoteManyToManyAggregationForDataList( + List resultList, Map> criteriaListMap, Set ignoreFields) { + if (CollUtil.isEmpty(this.remoteRelationManyToManyAggrStructList) || CollUtil.isEmpty(resultList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(this.remoteRelationManyToManyAggrStructList.size()); + } + for (RemoteRelationStruct relationStruct : this.remoteRelationManyToManyAggrStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isNotEmpty(masterIdSet)) { + RelationManyToManyAggregation relation = relationStruct.relationManyToManyAggregation; + // 这里需要拆分出哪些是关联表过滤,哪些是从表过滤。 + RemoteAggregationRelationInfo relationInfo = this.parseRemoteAggregationRelationInfo( + relationStruct, criteriaListMap, masterIdSet, true); + // 先处理聚合字段位于中间表的case。 + if (relation.aggregationModelClass().equals(relation.relationModelClass())) { + this.processRemoteManyToManyAggregationWithRelationModel( + relationInfo, relation, relationStruct, resultList, masterIdSet, true); + } else { + this.processRemoteManyToManyAggregationWithSlaveModel( + relationInfo, relation, relationStruct, resultList); + } + } + } + } + + /** + * 根据实体对象和过滤条件,集成本地多对多关联聚合计算数据。 + * + * @param dataObject 实体对象。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildRemoteManyToManyAggregationForData( + T dataObject, Map> criteriaListMap, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.remoteRelationManyToManyAggrStructList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(this.remoteRelationManyToManyAggrStructList.size()); + } + for (RemoteRelationStruct relationStruct : this.remoteRelationManyToManyAggrStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Object masterIdValue = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (masterIdValue != null) { + RelationManyToManyAggregation relation = relationStruct.relationManyToManyAggregation; + // 这里需要拆分出哪些是关联表过滤,哪些是从表过滤。 + RemoteAggregationRelationInfo relationInfo = this.parseRemoteAggregationRelationInfo( + relationStruct, criteriaListMap, masterIdValue, false); + List resultList = new LinkedList<>(); + resultList.add(dataObject); + // 先处理聚合字段位于中间表的case。 + if (relation.aggregationModelClass().equals(relation.relationModelClass())) { + this.processRemoteManyToManyAggregationWithRelationModel( + relationInfo, relation, relationStruct, resultList, masterIdValue, false); + } else { + this.processRemoteManyToManyAggregationWithSlaveModel( + relationInfo, relation, relationStruct, resultList); + } + } + } + } + + /** + * 为实体对象参数列表数据集成本地一对一关联数据。 + * NOTE: 当参数withDict为true时,由于一对一从表对象可能关联远程字典,如从表是Teacher对象, + * Teacher对象包含gradeId字段,而该字段关联的Grade对象与Teacher对象位于不同的服务。 + * 此时,在关联gradeId远程字典时,就可能产生RPC调用。参数ignoreRpcError应用于该远程 + * 调用。 + * + * @param resultList 实体对象数据列表。 + * @param relationParam 关联从参数对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildOneToOneForDataList(List resultList, MyRelationParam relationParam, Set ignoreFields) { + if (CollUtil.isEmpty(this.localRelationOneToOneStructList) || CollUtil.isEmpty(resultList)) { + return; + } + boolean withDict = relationParam.isBuildOneToOneWithDict(); + for (LocalRelationStruct relationStruct : this.localRelationOneToOneStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isNotEmpty(masterIdSet)) { + BaseService relationService = relationStruct.localService; + List relationList = + relationService.getInList(relationStruct.relationOneToOne.slaveIdField(), masterIdSet); + Set igoreMaskFieldSet = null; + if (relationParam.getIgnoreMaskFieldMap() != null) { + igoreMaskFieldSet = relationParam.getIgnoreMaskFieldMap() + .get(relationStruct.relationOneToOne.slaveModelClass().getSimpleName()); + } + relationService.maskFieldDataList(relationList, igoreMaskFieldSet); + MyModelUtil.makeOneToOneRelation( + modelClass, resultList, relationList, relationStruct.relationField.getName()); + // 仅仅当需要加载从表字典关联时,才去加载。 + if (withDict && relationStruct.relationOneToOne.loadSlaveDict() && CollUtil.isNotEmpty(relationList)) { + @SuppressWarnings("unchecked") + BaseService proxyTarget = + (BaseService) AopTargetUtil.getTarget(relationService); + // 关联全局字典 + proxyTarget.buildGlobalDictForDataList(relationList, ignoreFields); + // 关联常量字典 + proxyTarget.buildConstDictForDataList(relationList, ignoreFields); + // 关联本地字典。 + proxyTarget.buildDictForDataList(relationList, false, ignoreFields); + // 关联远程字典。 + proxyTarget.buildRemoteDictForDataList(relationList, false, ignoreFields); + } + } + } + } + + /** + * 为实体对象数据集成本地一对一关联数据。 + * + * @param dataObject 实体对象。 + * @param relationParam 从表数据关联参数对象。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildOneToOneForData(M dataObject, MyRelationParam relationParam, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationOneToOneStructList)) { + return; + } + boolean withDict = relationParam.isBuildOneToOneWithDict(); + for (LocalRelationStruct relationStruct : this.localRelationOneToOneStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + BaseService relationService = relationStruct.localService; + Object relationObject = relationService.getOne(relationStruct.relationOneToOne.slaveIdField(), id); + Set ignoreMaskFieldSet = null; + if (relationParam.getIgnoreMaskFieldMap() != null) { + ignoreMaskFieldSet = relationParam.getIgnoreMaskFieldMap() + .get(relationStruct.relationOneToOne.slaveModelClass().getSimpleName()); + } + relationService.maskFieldData(relationObject, ignoreMaskFieldSet); + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, relationObject); + // 仅仅当需要加载从表字典关联时,才去加载。 + if (withDict && relationStruct.relationOneToOne.loadSlaveDict() && relationObject != null) { + @SuppressWarnings("unchecked") + BaseService proxyTarget = + (BaseService) AopTargetUtil.getTarget(relationService); + // 关联全局字典 + proxyTarget.buildGlobalDictForData(relationObject, ignoreFields); + // 关联常量字典 + proxyTarget.buildConstDictForData(relationObject, ignoreFields); + // 关联本地字典。 + proxyTarget.buildDictForData(relationObject, false, ignoreFields); + // 关联远程字典。 + proxyTarget.buildRemoteDictForData(relationObject, false, ignoreFields); + } + } + } + } + + private void buildOneToManyForDataList(List resultList, MyRelationParam relationParam, Set ignoreFields) { + if (CollUtil.isEmpty(this.localRelationOneToManyStructList) || CollUtil.isEmpty(resultList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationOneToManyStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + // 从主表集合中,抽取主表关联字段的集合,再以in list形式去从表中查询。 + if (CollUtil.isNotEmpty(masterIdSet)) { + BaseService relationService = relationStruct.localService; + List relationList = relationService.getInListWithRelation( + relationStruct.relationOneToMany.slaveIdField(), masterIdSet, MyRelationParam.dictOnly()); + MyModelUtil.makeOneToManyRelation( + modelClass, resultList, relationList, relationStruct.relationField.getName()); + Set ignoreMaskFieldSet = null; + if (relationParam.getIgnoreMaskFieldMap() != null) { + ignoreMaskFieldSet = relationParam.getIgnoreMaskFieldMap() + .get(relationStruct.relationOneToMany.slaveModelClass().getSimpleName()); + } + for (M data : resultList) { + @SuppressWarnings("unchecked") + List relationDataList = + (List) ReflectUtil.getFieldValue(data, relationStruct.relationField.getName()); + relationService.maskFieldDataList(relationDataList, ignoreMaskFieldSet); + } + } + } + } + + private void buildOneToManyForData(M dataObject, MyRelationParam relationParam, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationOneToManyStructList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationOneToManyStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + BaseService relationService = relationStruct.localService; + Set masterIdSet = new HashSet<>(1); + masterIdSet.add(id); + List relationObject = relationService.getInListWithRelation( + relationStruct.relationOneToMany.slaveIdField(), masterIdSet, MyRelationParam.dictOnly()); + Set ignoreMaskFieldSet = null; + if (relationParam.getIgnoreMaskFieldMap() != null) { + ignoreMaskFieldSet = relationParam.getIgnoreMaskFieldMap() + .get(relationStruct.relationOneToMany.slaveModelClass().getSimpleName()); + } + relationService.maskFieldDataList(relationObject, ignoreMaskFieldSet); + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, relationObject); + } + } + } + + private void buildManyToManyForDataList(List resultList, Set ignoreFields) { + if (CollUtil.isEmpty(this.localRelationManyToManyStructList) || CollUtil.isEmpty(resultList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationManyToManyStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + // 从主表集合中,抽取主表关联字段的集合,再以in list形式去从表中查询。 + if (CollUtil.isNotEmpty(masterIdSet)) { + String masterIdColumn = this.safeMapToColumnName(relationStruct.masterIdField.getName()); + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.in(masterIdColumn, masterIdSet); + List relationList = relationStruct.manyToManyMapper.selectList(queryWrapper); + MyModelUtil.makeManyToManyRelation( + modelClass, resultList, relationList, relationStruct.relationField.getName()); + } + } + } + + /** + * 为实体对象参数列表数据集成本地字典关联数据。 + * + * @param resultList 实体对象数据列表。 + * @param hasBuiltOneToOne 性能优化参数。如果该值为true,同时注解参数RelationDict.equalOneToOneRelationField + * 不为空,则直接从已经完成一对一数据关联的从表对象中获取数据,减少一次数据库交互。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildDictForDataList(List resultList, boolean hasBuiltOneToOne, Set ignoreFields) { + if (CollUtil.isEmpty(this.localRelationDictStructList) || CollUtil.isEmpty(resultList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationDictStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + List relationList = null; + if (hasBuiltOneToOne && relationStruct.equalOneToOneRelationField != null) { + relationList = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.equalOneToOneRelationField)) + .filter(Objects::nonNull) + .collect(toList()); + } else { + String slaveId = relationStruct.relationDict.slaveIdField(); + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isNotEmpty(masterIdSet)) { + relationList = relationStruct.localService.getInList(slaveId, masterIdSet); + } + } + MyModelUtil.makeDictRelation( + modelClass, resultList, relationList, relationStruct.relationField.getName()); + } + } + + /** + * 为实体对象数据集成本地数据字典关联数据。 + * + * @param dataObject 实体对象。 + * @param hasBuiltOneToOne 性能优化参数。如果该值为true,同时注解参数RelationDict.equalOneToOneRelationField + * 不为空,则直接从已经完成一对一数据关联的从表对象中获取数据,减少一次数据库交互。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildDictForData(T dataObject, boolean hasBuiltOneToOne, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationDictStructList)) { + return; + } + for (LocalRelationStruct relationStruct : this.localRelationDictStructList) { + if (ignoreFields != null && ignoreFields.contains(relationStruct.relationField.getName())) { + continue; + } + Object relationObject = null; + if (hasBuiltOneToOne && relationStruct.equalOneToOneRelationField != null) { + relationObject = ReflectUtil.getFieldValue(dataObject, relationStruct.equalOneToOneRelationField); + } else { + Object id = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (id != null) { + relationObject = relationStruct.localService.getOne(relationStruct.relationDict.slaveIdField(), id); + } + } + MyModelUtil.makeDictRelation( + modelClass, dataObject, relationObject, relationStruct.relationField.getName()); + } + } + + /** + * 根据实体对象参数列表和过滤条件,集成本地多对多关联聚合计算数据。 + * + * @param resultList 实体对象数据列表。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildManyToManyAggregationForDataList( + List resultList, Map> criteriaListMap, Set ignoreFields) { + if (CollUtil.isEmpty(this.localRelationManyToManyAggrStructList) || CollUtil.isEmpty(resultList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(this.localRelationManyToManyAggrStructList.size()); + } + for (LocalRelationStruct relationStruct : this.localRelationManyToManyAggrStructList) { + if (!CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + this.doBuildManyToManyAggregationForDataList(resultList, criteriaListMap, relationStruct); + } + } + } + + private void doBuildManyToManyAggregationForDataList( + List resultList, Map> criteriaListMap, LocalRelationStruct relationStruct) { + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isEmpty(masterIdSet)) { + return; + } + RelationManyToManyAggregation relation = relationStruct.relationManyToManyAggregation; + // 提取关联中用到的各种字段和表数据。 + LocalAggregationRelationInfo basicRelationInfo = + this.parseLocalAggregationRelationInfo(relationStruct, criteriaListMap); + // 构建多表关联的where语句 + StringBuilder whereClause = new StringBuilder(256); + // 如果需要从表聚合计算或参与过滤,则需要把中间表和从表之间的关联条件加上。 + if (!basicRelationInfo.onlySelectRelationTable) { + whereClause.append(basicRelationInfo.relationTable) + .append(".") + .append(basicRelationInfo.relationSlaveColumn) + .append(" = ") + .append(basicRelationInfo.slaveTable) + .append(".") + .append(basicRelationInfo.slaveColumn); + } else { + whereClause.append("1 = 1"); + } + List criteriaList = criteriaListMap.get(relationStruct.relationField.getName()); + if (criteriaList == null) { + criteriaList = new LinkedList<>(); + } + MyWhereCriteria inlistFilter = new MyWhereCriteria(); + inlistFilter.setCriteria(relation.relationModelClass(), + relation.relationMasterIdField(), MyWhereCriteria.OPERATOR_IN, masterIdSet); + criteriaList.add(inlistFilter); + if (StrUtil.isNotBlank(relationStruct.localService.deletedFlagFieldName)) { + MyWhereCriteria deleteFilter = new MyWhereCriteria(); + deleteFilter.setCriteria( + relation.slaveModelClass(), + relationStruct.localService.deletedFlagFieldName, + MyWhereCriteria.OPERATOR_EQUAL, + GlobalDeletedFlag.NORMAL); + criteriaList.add(deleteFilter); + } + String criteriaString = MyWhereCriteria.makeCriteriaString(criteriaList); + whereClause.append(AND_OP).append(criteriaString); + StringBuilder tableNames = new StringBuilder(64); + tableNames.append(basicRelationInfo.relationTable); + if (!basicRelationInfo.onlySelectRelationTable) { + tableNames.append(", ").append(basicRelationInfo.slaveTable); + } + List> aggregationMapList = + mapper().getGroupedListByCondition(tableNames.toString(), + basicRelationInfo.selectList, whereClause.toString(), basicRelationInfo.groupBy); + doMakeAggregationData(aggregationMapList, resultList, relationStruct); + } + + /** + * 根据实体对象参数列表和过滤条件,集成本地一对多关联聚合计算数据。 + * + * @param resultList 实体对象数据列表。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildOneToManyAggregationForDataList( + List resultList, Map> criteriaListMap, Set ignoreFields) { + if (CollUtil.isEmpty(this.localRelationOneToManyAggrStructList) || CollUtil.isEmpty(resultList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(localRelationOneToManyAggrStructList.size()); + } + for (LocalRelationStruct relationStruct : this.localRelationOneToManyAggrStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Set masterIdSet = resultList.stream() + .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField)) + .filter(Objects::nonNull) + .collect(toSet()); + if (CollUtil.isNotEmpty(masterIdSet)) { + RelationOneToManyAggregation relation = relationStruct.relationOneToManyAggregation; + // 开始获取后面所需的各种关联数据。此部分今后可以移植到缓存中,无需每次计算。 + String slaveTable = MyModelUtil.mapToTableName(relation.slaveModelClass()); + String slaveColumnName = MyModelUtil.mapToColumnName( + relation.slaveIdField(), relation.slaveModelClass()); + Tuple2 selectAndGroupByTuple = makeSelectListAndGroupByClause( + slaveTable, slaveColumnName, relation.slaveModelClass(), + slaveTable, relation.aggregationField(), relation.aggregationType()); + String selectList = selectAndGroupByTuple.getFirst(); + String groupBy = selectAndGroupByTuple.getSecond(); + List criteriaList = criteriaListMap.get(relationStruct.relationField.getName()); + if (criteriaList == null) { + criteriaList = new LinkedList<>(); + } + MyWhereCriteria inlistFilter = new MyWhereCriteria(); + inlistFilter.setCriteria(relation.slaveModelClass(), + relation.slaveIdField(), MyWhereCriteria.OPERATOR_IN, masterIdSet); + criteriaList.add(inlistFilter); + if (StrUtil.isNotBlank(relationStruct.localService.deletedFlagFieldName)) { + MyWhereCriteria deleteFilter = new MyWhereCriteria(); + deleteFilter.setCriteria( + relation.slaveModelClass(), + relationStruct.localService.deletedFlagFieldName, + MyWhereCriteria.OPERATOR_EQUAL, + GlobalDeletedFlag.NORMAL); + criteriaList.add(deleteFilter); + } + String criteriaString = MyWhereCriteria.makeCriteriaString(criteriaList); + List> aggregationMapList = + mapper().getGroupedListByCondition(slaveTable, selectList, criteriaString, groupBy); + doMakeAggregationData(aggregationMapList, resultList, relationStruct); + } + } + } + + /** + * 根据实体对象和过滤条件,集成本地多对多关联聚合计算数据。 + * + * @param dataObject 实体对象。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildManyToManyAggregationForData( + T dataObject, Map> criteriaListMap, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationManyToManyAggrStructList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(localRelationManyToManyAggrStructList.size()); + } + for (LocalRelationStruct relationStruct : this.localRelationManyToManyAggrStructList) { + Object masterIdValue = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (masterIdValue == null || CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + LocalAggregationRelationInfo basicRelationInfo = + this.parseLocalAggregationRelationInfo(relationStruct, criteriaListMap); + // 组装过滤条件 + String whereClause = this.makeManyToManyWhereClause( + relationStruct, masterIdValue, basicRelationInfo, criteriaListMap); + StringBuilder tableNames = new StringBuilder(64); + tableNames.append(basicRelationInfo.relationTable); + if (!basicRelationInfo.onlySelectRelationTable) { + tableNames.append(", ").append(basicRelationInfo.slaveTable); + } + List> aggregationMapList = + mapper().getGroupedListByCondition(tableNames.toString(), + basicRelationInfo.selectList, whereClause, basicRelationInfo.groupBy); + // 将查询后的结果回填到主表数据中。 + if (CollUtil.isNotEmpty(aggregationMapList)) { + Object value = aggregationMapList.get(0).get(MyAggregationParam.VALUE_NAME); + if (value != null) { + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, value); + } + } + } + } + + /** + * 根据实体对象和过滤条件,集成本地一对多关联聚合计算数据。 + * + * @param dataObject 实体对象。 + * @param criteriaListMap 过滤参数。key为主表字段名称,value是过滤条件列表。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + private void buildOneToManyAggregationForData( + T dataObject, Map> criteriaListMap, Set ignoreFields) { + if (dataObject == null || CollUtil.isEmpty(this.localRelationOneToManyAggrStructList)) { + return; + } + if (criteriaListMap == null) { + criteriaListMap = new HashMap<>(localRelationOneToManyAggrStructList.size()); + } + for (LocalRelationStruct relationStruct : this.localRelationOneToManyAggrStructList) { + if (CollUtil.contains(ignoreFields, relationStruct.relationField.getName())) { + continue; + } + Object masterIdValue = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (masterIdValue != null) { + RelationOneToManyAggregation relation = relationStruct.relationOneToManyAggregation; + String slaveTable = MyModelUtil.mapToTableName(relation.slaveModelClass()); + String slaveColumnName = + MyModelUtil.mapToColumnName(relation.slaveIdField(), relation.slaveModelClass()); + Tuple2 selectAndGroupByTuple = makeSelectListAndGroupByClause( + slaveTable, slaveColumnName, relation.slaveModelClass(), + slaveTable, relation.aggregationField(), relation.aggregationType()); + String selectList = selectAndGroupByTuple.getFirst(); + String groupBy = selectAndGroupByTuple.getSecond(); + String whereClause = this.makeOneToManyWhereClause( + relationStruct, masterIdValue, slaveColumnName, criteriaListMap); + // 获取分组聚合计算结果 + List> aggregationMapList = + mapper().getGroupedListByCondition(slaveTable, selectList, whereClause, groupBy); + // 将计算结果回填到主表关联字段 + if (CollUtil.isNotEmpty(aggregationMapList)) { + Object value = aggregationMapList.get(0).get(MyAggregationParam.VALUE_NAME); + if (value != null) { + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, value); + } + } + } + } + } + + /** + * 仅仅在spring boot 启动后的监听器事件中调用,缓存所有远程调用Client的关联关系,加速后续的数据绑定效率。 + */ + @Override + public void loadRemoteRelationStruct() { + Field[] fields = ReflectUtil.getFields(modelClass); + for (Field f : fields) { + initializeRemoteRelationDictStruct(f); + initializeRemoteRelationStruct(f); + initializeRemoteRelationAggregationStruct(f); + } + } + + /** + * 仅仅在spring boot 启动后的监听器事件中调用,缓存所有service的关联关系,加速后续的数据绑定效率。 + */ + @Override + public void loadLocalRelationStruct() { + Field[] fields = ReflectUtil.getFields(modelClass); + for (Field f : fields) { + initializeLocalRelationDictStruct(f); + initializeLocalRelationStruct(f); + initializeLocalRelationAggregationStruct(f); + } + } + + /** + * 缺省实现返回null,在进行一对多和多对多聚合计算时,没有额外的自定义过滤条件。如有需要,需子类自行实现。 + * + * @return 自定义过滤条件列表。 + */ + protected Map> buildAggregationAdditionalWhereCriteria() { + return null; + } + + /** + * 判断当前对象的关联字段数据是否需要被验证,如果原有对象为null,表示新对象第一次插入,则必须验证。 + * + * @param object 新对象。 + * @param originalObject 原有对象。 + * @param fieldGetter 获取需要验证字段的函数对象。 + * @param 需要验证字段的类型。 + * @return 需要关联验证返回true,否则false。 + */ + protected boolean needToVerify(M object, M originalObject, Function fieldGetter) { + if (object == null) { + return false; + } + T data = fieldGetter.apply(object); + if (data == null) { + return false; + } + if (data instanceof String) { + String stringData = (String) data; + if (stringData.length() == 0) { + return false; + } + } + if (originalObject == null) { + return true; + } + T originalData = fieldGetter.apply(originalObject); + return !data.equals(originalData); + } + + /** + * 判断远程关联数据是否包含错误信息。 + * + * @param responseResult 远程服务返回结果对象。 + * @return true表示包含错误信息,否则false。 + */ + protected boolean hasErrorOfVerifyRemoteRelatedData(ResponseResult responseResult) { + return !responseResult.isSuccess() || Boolean.FALSE.equals(responseResult.getData()); + } + + /** + * 因为Mybatis Plus中QueryWrapper的条件方法都要求传入数据表字段名,因此提供该函数将 + * Java实体对象的字段名转换为数据表字段名,如果不存在会抛出异常。 + * 另外在MyModelUtil.mapToColumnName有一级缓存,对于查询过的对象字段都会放到缓存中, + * 下次映射转换的时候,会直接从缓存获取。 + * + * @param fieldName Java实体对象的字段名。 + * @return 对应的数据表字段名。 + */ + protected String safeMapToColumnName(String fieldName) { + String columnName = MyModelUtil.mapToColumnName(fieldName, modelClass); + if (columnName == null) { + throw new InvalidDataFieldException(modelClass.getSimpleName(), fieldName); + } + return columnName; + } + + /** + * 因为Mybatis Plus在update的时候,不能将实体对象中值为null的字段,更新为null, + * 而且忽略更新,在全部更新场景下,这个是非常重要的,所以我们写了这个函数绕开这一问题。 + * 该函数会遍历实体对象中,所有不包含@Transient注解,没有transient修饰符的字段,如果 + * 当前对象的该字段值为null,则会调用UpdateWrapper的set方法,将该字段赋值为null。 + * 相比于其他重载方法,该方法会将参数中的主键id,设置到UpdateWrapper的过滤条件中。 + * + * @param o 实体对象。 + * @param id 实体对象的主键值。 + * @return 创建后的UpdateWrapper。 + */ + protected UpdateWrapper createUpdateQueryForNullValue(M o, K id) { + UpdateWrapper uw = createUpdateQueryForNullValue(o, modelClass); + try { + M filter = modelClass.newInstance(); + this.setIdFieldMethod.invoke(filter, id); + uw.setEntity(filter); + } catch (Exception e) { + log.error("Failed to call reflection code of BaseService.createUpdateQueryForNullValue.", e); + throw new MyRuntimeException(e); + } + return uw; + } + + /** + * 因为Mybatis Plus在update的时候,不能将实体对象中值为null的字段,更新为null, + * 而且忽略更新,在全部更新场景下,这个是非常重要的,所以我们写了这个函数绕开这一问题。 + * 该函数会遍历实体对象中,所有不包含@Transient注解,没有transient修饰符的字段,如果 + * 当前对象的该字段值为null,则会调用UpdateWrapper的set方法,将该字段赋值为null。 + * + * @param o 实体对象。 + * @return 创建后的UpdateWrapper。 + */ + protected UpdateWrapper createUpdateQueryForNullValue(M o) { + return createUpdateQueryForNullValue(o, modelClass); + } + + /** + * 因为Mybatis Plus在update的时候,不能将实体对象中值为null的字段,更新为null, + * 而且忽略更新,在全部更新场景下,这个是非常重要的,所以我们写了这个函数绕开这一问题。 + * 该函数会遍历实体对象中,所有不包含@Transient注解,没有transient修饰符的字段,如果 + * 当前对象的该字段值为null,则会调用UpdateWrapper的set方法,将该字段赋值为null。 + * + * @param o 实体对象。 + * @param clazz 实体对象的class。 + * @return 创建后的UpdateWrapper。 + */ + public static UpdateWrapper createUpdateQueryForNullValue(T o, Class clazz) { + UpdateWrapper uw = new UpdateWrapper<>(); + Field[] fields = ReflectUtil.getFields(clazz); + List nullColumnList = new LinkedList<>(); + for (Field field : fields) { + TableField tableField = field.getAnnotation(TableField.class); + if (tableField == null || tableField.exist()) { + int modifiers = field.getModifiers(); + // transient类型的字段不能作为查询条件,静态字段和逻辑删除都不考虑。 + int transientMask = 128; + if ((modifiers & transientMask) == 1 + || Modifier.isStatic(modifiers) + || field.getAnnotation(TableLogic.class) != null) { + continue; + } + // 仅当实体对象参数中,当前字段值为null的时候,才会赋值给UpdateWrapper。 + // 以便在后续的更新中,可以将这些null字段的值设置到数据库表对应的字段中。 + if (ReflectUtil.getFieldValue(o, field) == null) { + nullColumnList.add(MyModelUtil.safeMapToColumnName(field.getName(), clazz)); + } + } + } + if (CollUtil.isNotEmpty(nullColumnList)) { + for (String nullColumn : nullColumnList) { + uw.set(nullColumn, null); + } + } + return uw; + } + + @SuppressWarnings("unchecked") + private void initializeRemoteRelationDictStruct(Field f) { + RelationDict relationDict = f.getAnnotation(RelationDict.class); + if (relationDict != null) { + if (relationDict.slaveClientClass().equals(DummyClass.class)) { + return; + } + RemoteRelationStruct relationStruct = new RemoteRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationDict.masterIdField()); + relationStruct.relationDict = relationDict; + if (StrUtil.isNotBlank(relationDict.equalOneToOneRelationField())) { + relationStruct.equalOneToOneRelationField = + ReflectUtil.getField(modelClass, relationDict.equalOneToOneRelationField()); + } + Object client = ApplicationContextHolder.getBean(relationDict.slaveClientClass()); + relationStruct.remoteClient = (BaseClient) client; + remoteRelationDictStructList.add(relationStruct); + } + } + + @SuppressWarnings("unchecked") + private void initializeRemoteRelationStruct(Field f) { + RelationOneToOne relationOneToOne = f.getAnnotation(RelationOneToOne.class); + if (relationOneToOne != null) { + if (relationOneToOne.slaveClientClass().equals(DummyClass.class)) { + return; + } + RemoteRelationStruct relationStruct = new RemoteRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationOneToOne.masterIdField()); + relationStruct.relationOneToOne = relationOneToOne; + Object client = ApplicationContextHolder.getBean(relationOneToOne.slaveClientClass()); + relationStruct.remoteClient = (BaseClient) client; + remoteRelationOneToOneStructList.add(relationStruct); + } + } + + @SuppressWarnings("unchecked") + private void initializeRemoteRelationAggregationStruct(Field f) { + RelationOneToManyAggregation relationOneToManyAggregation = + f.getAnnotation(RelationOneToManyAggregation.class); + if (relationOneToManyAggregation != null) { + if (relationOneToManyAggregation.slaveClientClass().equals(DummyClass.class)) { + return; + } + RemoteRelationStruct relationStruct = new RemoteRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationOneToManyAggregation.masterIdField()); + relationStruct.relationOneToManyAggregation = relationOneToManyAggregation; + Object client = ApplicationContextHolder.getBean(relationOneToManyAggregation.slaveClientClass()); + relationStruct.remoteClient = (BaseClient) client; + remoteRelationOneToManyAggrStructList.add(relationStruct); + return; + } + RelationManyToManyAggregation relationManyToManyAggregation = + f.getAnnotation(RelationManyToManyAggregation.class); + if (relationManyToManyAggregation != null) { + if (relationManyToManyAggregation.slaveClientClass().equals(DummyClass.class)) { + return; + } + RemoteRelationStruct relationStruct = new RemoteRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField( + modelClass, relationManyToManyAggregation.masterIdField()); + relationStruct.relationManyToManyAggregation = relationManyToManyAggregation; + Object client = ApplicationContextHolder.getBean(relationManyToManyAggregation.slaveClientClass()); + relationStruct.remoteClient = (BaseClient) client; + remoteRelationManyToManyAggrStructList.add(relationStruct); + } + } + + @SuppressWarnings("unchecked") + private void initializeLocalRelationStruct(Field f) { + RelationOneToOne relationOneToOne = f.getAnnotation(RelationOneToOne.class); + if (relationOneToOne != null) { + if (!relationOneToOne.slaveClientClass().equals(DummyClass.class)) { + return; + } + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationOneToOne.masterIdField()); + relationStruct.relationOneToOne = relationOneToOne; + if (relationOneToOne.slaveServiceClass().equals(DummyClass.class)) { + relationStruct.localService = + ApplicationContextHolder.getBean(this.getNormalizedSlaveServiceName( + relationOneToOne.slaveServiceName(), relationOneToOne.slaveModelClass())); + } else { + relationStruct.localService = (BaseService) + ApplicationContextHolder.getBean(relationOneToOne.slaveServiceClass()); + } + localRelationOneToOneStructList.add(relationStruct); + return; + } + RelationOneToMany relationOneToMany = f.getAnnotation(RelationOneToMany.class); + if (relationOneToMany != null) { + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationOneToMany.masterIdField()); + relationStruct.relationOneToMany = relationOneToMany; + if (relationOneToMany.slaveServiceClass().equals(DummyClass.class)) { + relationStruct.localService = + ApplicationContextHolder.getBean(this.getNormalizedSlaveServiceName( + relationOneToMany.slaveServiceName(), relationOneToMany.slaveModelClass())); + } else { + relationStruct.localService = (BaseService) + ApplicationContextHolder.getBean(relationOneToMany.slaveServiceClass()); + } + localRelationOneToManyStructList.add(relationStruct); + return; + } + RelationManyToMany relationManyToMany = f.getAnnotation(RelationManyToMany.class); + if (relationManyToMany != null) { + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationManyToMany.relationMasterIdField()); + relationStruct.relationManyToMany = relationManyToMany; + String relationMapperName = relationManyToMany.relationMapperName(); + if (StrUtil.isBlank(relationMapperName)) { + relationMapperName = relationManyToMany.relationModelClass().getSimpleName() + "Mapper"; + } + relationStruct.manyToManyMapper = ApplicationContextHolder.getBean(StrUtil.lowerFirst(relationMapperName)); + localRelationManyToManyStructList.add(relationStruct); + } + } + + @SuppressWarnings("unchecked") + private void initializeLocalRelationDictStruct(Field f) { + RelationConstDict relationConstDict = f.getAnnotation(RelationConstDict.class); + if (relationConstDict != null) { + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationConstDict = relationConstDict; + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationConstDict.masterIdField()); + Field dictMapField = ReflectUtil.getField(relationConstDict.constantDictClass(), "DICT_MAP"); + relationStruct.dictMap = (Map) ReflectUtil.getStaticFieldValue(dictMapField); + relationConstDictStructList.add(relationStruct); + return; + } + RelationGlobalDict relationGlobalDict = f.getAnnotation(RelationGlobalDict.class); + if (relationGlobalDict != null) { + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationGlobalDict = relationGlobalDict; + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationGlobalDict.masterIdField()); + relationStruct.localService = ApplicationContextHolder.getBean("globalDictService"); + relationStruct.globalDictMethd = ReflectUtil.getMethodByName( + relationStruct.localService.getClass(), "getGlobalDictItemDictMapFromCache"); + relationGlobalDictStructList.add(relationStruct); + return; + } + RelationDict relationDict = f.getAnnotation(RelationDict.class); + if (relationDict != null) { + if (!relationDict.slaveClientClass().equals(DummyClass.class)) { + return; + } + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationDict.masterIdField()); + relationStruct.relationDict = relationDict; + if (StrUtil.isNotBlank(relationDict.equalOneToOneRelationField())) { + relationStruct.equalOneToOneRelationField = + ReflectUtil.getField(modelClass, relationDict.equalOneToOneRelationField()); + } + if (relationDict.slaveServiceClass().equals(DummyClass.class)) { + relationStruct.localService = ApplicationContextHolder.getBean( + this.getNormalizedSlaveServiceName(relationDict.slaveServiceName(), relationDict.slaveModelClass())); + } else { + relationStruct.localService = (BaseService) + ApplicationContextHolder.getBean(relationDict.slaveServiceClass()); + } + localRelationDictStructList.add(relationStruct); + } + } + + @SuppressWarnings("unchecked") + private void initializeLocalRelationAggregationStruct(Field f) { + RelationOneToManyAggregation relationOneToManyAggregation = f.getAnnotation(RelationOneToManyAggregation.class); + if (relationOneToManyAggregation != null) { + if (!relationOneToManyAggregation.slaveClientClass().equals(DummyClass.class)) { + return; + } + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationOneToManyAggregation.masterIdField()); + relationStruct.relationOneToManyAggregation = relationOneToManyAggregation; + if (relationOneToManyAggregation.slaveServiceClass().equals(DummyClass.class)) { + relationStruct.localService = ApplicationContextHolder.getBean(this.getNormalizedSlaveServiceName( + relationOneToManyAggregation.slaveServiceName(), relationOneToManyAggregation.slaveModelClass())); + } else { + relationStruct.localService = (BaseService) + ApplicationContextHolder.getBean(relationOneToManyAggregation.slaveServiceClass()); + } + localRelationOneToManyAggrStructList.add(relationStruct); + return; + } + RelationManyToManyAggregation relationManyToManyAggregation = f.getAnnotation(RelationManyToManyAggregation.class); + if (relationManyToManyAggregation != null) { + if (!relationManyToManyAggregation.slaveClientClass().equals(DummyClass.class)) { + return; + } + LocalRelationStruct relationStruct = new LocalRelationStruct(); + relationStruct.relationField = f; + relationStruct.masterIdField = ReflectUtil.getField(modelClass, relationManyToManyAggregation.masterIdField()); + relationStruct.relationManyToManyAggregation = relationManyToManyAggregation; + if (relationManyToManyAggregation.slaveServiceClass().equals(DummyClass.class)) { + relationStruct.localService = ApplicationContextHolder.getBean(this.getNormalizedSlaveServiceName( + relationManyToManyAggregation.slaveServiceName(), relationManyToManyAggregation.slaveModelClass())); + } else { + relationStruct.localService = (BaseService) + ApplicationContextHolder.getBean(relationManyToManyAggregation.slaveServiceClass()); + } + localRelationManyToManyAggrStructList.add(relationStruct); + } + } + + private Object normalizeData(Object data, Class clazz) { + if (data instanceof JSONObject) { + return ((JSONObject) data).toJavaObject(clazz); + } + return data; + } + + private void logErrorOrThrowException(String errorMsg) { + log.error(LogMessageUtil.makeRpcError(errorMsg)); + if (!this.ignoreRpcError()) { + throw new RemoteDataBuildException(errorMsg); + } + } + + private LocalAggregationRelationInfo parseLocalAggregationRelationInfo( + LocalRelationStruct relationStruct, Map> criteriaListMap) { + RelationManyToManyAggregation relation = relationStruct.relationManyToManyAggregation; + LocalAggregationRelationInfo relationInfo = new LocalAggregationRelationInfo(); + // 提取关联中用到的各种字段和表数据。 + relationInfo.slaveTable = MyModelUtil.mapToTableName(relation.slaveModelClass()); + relationInfo.relationTable = MyModelUtil.mapToTableName(relation.relationModelClass()); + relationInfo.relationMasterColumn = + MyModelUtil.mapToColumnName(relation.relationMasterIdField(), relation.relationModelClass()); + relationInfo.relationSlaveColumn = + MyModelUtil.mapToColumnName(relation.relationSlaveIdField(), relation.relationModelClass()); + relationInfo.slaveColumn = MyModelUtil.mapToColumnName(relation.slaveIdField(), relation.slaveModelClass()); + // 判断是否只需要关联中间表即可,从而提升查询统计的效率。 + // 1. 统计字段为中间表字段。2. 自定义过滤条件中没有基于从表字段的过滤条件。 + relationInfo.onlySelectRelationTable = + relation.aggregationModelClass().equals(relation.relationModelClass()); + if (relationInfo.onlySelectRelationTable && MapUtil.isNotEmpty(criteriaListMap)) { + List criteriaList = + criteriaListMap.get(relationStruct.relationField.getName()); + if (CollUtil.isNotEmpty(criteriaList)) { + for (MyWhereCriteria whereCriteria : criteriaList) { + if (whereCriteria.getModelClazz().equals(relation.slaveModelClass())) { + relationInfo.onlySelectRelationTable = false; + break; + } + } + } + } + String aggregationTable = relation.aggregationModelClass().equals(relation.relationModelClass()) + ? relationInfo.relationTable : relationInfo.slaveTable; + Tuple2 selectAndGroupByTuple = makeSelectListAndGroupByClause( + relationInfo.relationTable, relationInfo.relationMasterColumn, relation.aggregationModelClass(), + aggregationTable, relation.aggregationField(), relation.aggregationType()); + relationInfo.selectList = selectAndGroupByTuple.getFirst(); + relationInfo.groupBy = selectAndGroupByTuple.getSecond(); + return relationInfo; + } + + private RemoteAggregationRelationInfo parseRemoteAggregationRelationInfo( + RemoteRelationStruct relationStruct, + Map> criteriaListMap, + Object value, + boolean forDataList) { + RemoteAggregationRelationInfo relationInfo = new RemoteAggregationRelationInfo(); + // 这里需要拆分出哪些是关联表过滤,哪些是从表过滤。 + RelationManyToManyAggregation relation = relationStruct.relationManyToManyAggregation; + relationInfo.relationTable = MyModelUtil.mapToTableName(relation.relationModelClass()); + relationInfo.relationMasterColumn = MyModelUtil.mapToColumnName( + relation.relationMasterIdField(), relation.relationModelClass()); + relationInfo.relationSlaveColumn = MyModelUtil.mapToColumnName( + relation.relationSlaveIdField(), relation.relationModelClass()); + // 这里需要拆分出哪些是关联表过滤,哪些是从表过滤。 + List relationCriteriaList = new LinkedList<>(); + // 先把关联表的inlist过滤 [r_table.master_id = masterIdValue] 预先插入。 + MyWhereCriteria criteria = new MyWhereCriteria(); + if (forDataList) { + criteria.setCriteria(relation.relationModelClass(), + relation.relationMasterIdField(), MyWhereCriteria.OPERATOR_IN, value); + } else { + criteria.setCriteria(relation.relationModelClass(), + relation.relationMasterIdField(), MyWhereCriteria.OPERATOR_EQUAL, value); + } + relationCriteriaList.add(criteria); + List criteriaList = criteriaListMap.get(relationStruct.relationField.getName()); + if (CollUtil.isNotEmpty(criteriaList)) { + for (MyWhereCriteria c : criteriaList) { + if (c.getModelClazz().equals(relation.relationModelClass())) { + relationCriteriaList.add(c); + } else if (c.getModelClazz().equals(relation.slaveModelClass())) { + if (relationInfo.slaveCriteriaList == null) { + relationInfo.slaveCriteriaList = new LinkedList<>(); + } + relationInfo.slaveCriteriaList.add(c); + } + } + } + relationInfo.relationWhereClause = MyWhereCriteria.makeCriteriaString(relationCriteriaList); + return relationInfo; + } + + private void processRemoteManyToManyAggregationWithRelationModel( + RemoteAggregationRelationInfo relationInfo, RelationManyToManyAggregation relation, + RemoteRelationStruct relationStruct, List resultList, Object value, boolean forDataList) { + // 1. 先构建SQL中的Select List,下面的函数返回值是: + // relationTable.relationMasterColumn as GROUPED_KEY, SUM(relationTable.aggregationField) AGGREGATED_VALUE + // 2. 基于中间表与主表的关联字段构建SQL语句中的GROUP BY从句。 + Tuple2 selectAndGroupByTuple = makeSelectListAndGroupByClause( + relationInfo.relationTable, relationInfo.relationMasterColumn, + relation.aggregationModelClass(), relationInfo.relationTable, + relation.aggregationField(), relation.aggregationType()); + String groupedSelectList = selectAndGroupByTuple.getFirst(); + String groupBy = selectAndGroupByTuple.getSecond(); + List> aggregationMapList = null; + // 过滤条件中不涉及远程从表,那么可以直接进行基于中间表的SQL计算即可,避免了远程调用。 + if (relationInfo.slaveCriteriaList == null) { + // 3. 下面的函数调用将会执行仅包含中间表的SQL。 + aggregationMapList = mapper().getGroupedListByCondition( + relationInfo.relationTable, groupedSelectList, relationInfo.relationWhereClause, groupBy); + this.doMakeAggregationData(aggregationMapList, resultList, relationStruct); + return; + } + // 此场景是最复杂的,实现步骤如下: + // 1. 先基于主表的inlist和中间表的过滤条件,过滤出符合条件的中间表数据列表。 + // 构建获取中间表数据SQL的Select List部分。 + StringBuilder selectList = new StringBuilder(64); + selectList.append(relationInfo.relationMasterColumn).append(", ").append(relationInfo.relationSlaveColumn); + // SQL中的Where从句不变,和上面的过滤逻辑是一致的。 + List> mapList = mapper().getListByCondition( + relationInfo.relationTable, selectList.toString(), relationInfo.relationWhereClause, null); + if (CollUtil.isNotEmpty(mapList)) { + // 2. 基于中间表的查询结果,即中间表中与从表关联字段的列表作为从表的inlist过滤条件, + // 同时结合远程从表的自身过滤条件,获取远程从表数据。目前仅需从表中的关联字段即可。 + MyWhereCriteria slaveInlistFilterCriteria = new MyWhereCriteria(); + // 将中间表对从表进行inlist过滤的条件手动插入。 + slaveInlistFilterCriteria.setCriteria(relation.slaveIdField(), MyWhereCriteria.OPERATOR_IN, + mapList.stream().map(m -> m.get(relationInfo.relationSlaveColumn)).collect(toSet())); + relationInfo.slaveCriteriaList.add(slaveInlistFilterCriteria); + MyQueryParam queryParam = new MyQueryParam(); + queryParam.setCriteriaList(relationInfo.slaveCriteriaList); + // 远程从表返回的结果集中,仅仅包含从表中的关联字段即可。 + List slaveSelectList = new LinkedList<>(); + slaveSelectList.add(relation.slaveIdField()); + queryParam.setSelectFieldList(slaveSelectList); + // 关联集成数据需要把数据权限过滤关闭,以保证计算结果的正确性。 + queryParam.setUseDataFilter(false); + ResponseResult>> result = relationStruct.remoteClient.listMapBy(queryParam); + if (!result.isSuccess()) { + this.logErrorOrThrowException(result.getErrorMessage()); + return; + } + // 3. 将远程从表查询的返回结果,作为中间表与从表关联字段(relationSlaveColumn)的inlistFilter。 + // 并计算最终聚合结果。 + List slaveList = null; + if (result.getData() != null) { + slaveList = result.getData().getDataList() + .stream().map(m -> m.get(relation.slaveIdField())).collect(toList()); + } + if (CollUtil.isNotEmpty(slaveList)) { + // 中间表的最终过滤条件是从表返回的id列表将作为关联表slaveIdColumn的inlist-filter, + // 同时原有主表的id列表仍将作为关联表masterIdColumn的inlist-filter。 + List finalRelationCriteriaList = new LinkedList<>(); + MyWhereCriteria relationSlaveIdInListFilterCriteria = new MyWhereCriteria(); + relationSlaveIdInListFilterCriteria.setCriteria(relation.relationModelClass(), + relation.relationSlaveIdField(), MyWhereCriteria.OPERATOR_IN, slaveList); + finalRelationCriteriaList.add(relationSlaveIdInListFilterCriteria); + MyWhereCriteria relationMasterIdInListFilterCriteria = new MyWhereCriteria(); + if (forDataList) { + relationMasterIdInListFilterCriteria.setCriteria(relation.relationModelClass(), + relation.relationMasterIdField(), MyWhereCriteria.OPERATOR_IN, value); + } else { + relationMasterIdInListFilterCriteria.setCriteria(relation.relationModelClass(), + relation.relationMasterIdField(), MyWhereCriteria.OPERATOR_EQUAL, value); + } + finalRelationCriteriaList.add(relationMasterIdInListFilterCriteria); + String whereClause = MyWhereCriteria.makeCriteriaString(finalRelationCriteriaList); + aggregationMapList = mapper().getGroupedListByCondition( + relationInfo.relationTable, groupedSelectList, whereClause, groupBy); + } + } + if (aggregationMapList != null) { + this.doMakeAggregationData(aggregationMapList, resultList, relationStruct); + } + } + + private void processRemoteManyToManyAggregationWithSlaveModel( + RemoteAggregationRelationInfo relationInfo, + RelationManyToManyAggregation relation, + RemoteRelationStruct relationStruct, + List resultList) { + // 聚合字段在远程从表,计算步骤如下: + // 1. 先进行中间表过滤,并将中间表的过滤结果。 + StringBuilder selectList = new StringBuilder(64); + selectList.append(relationInfo.relationMasterColumn).append(", ").append(relationInfo.relationSlaveColumn); + List> relationMapList = + mapper().getListByCondition(relationInfo.relationTable, + selectList.toString(), relationInfo.relationWhereClause, null); + if (CollUtil.isNotEmpty(relationMapList)) { + // 2. 将中间表的过滤结果,作为从表关联字段的inlistFilter过滤,再结合从表自身的过滤条件, + // 一并传给远程服务进行聚合计算,并返回计算结果。 + MyAggregationParam aggregationParam = createAggregationParam(AggregationKind.MANY_TO_MANY, + relation.aggregationType(), relation.aggregationField(), null); + aggregationParam.setInFilterField(relation.slaveIdField()); + if (relationInfo.slaveCriteriaList != null) { + aggregationParam.setWhereCriteriaList(relationInfo.slaveCriteriaList); + } + // 将过滤后的中间表结果集手动分组,传给远程分组计算调用,保证一次返回所有分组结果。 + Map> groupedFilterMap = + relationMapList.stream().collect( + groupingBy(m -> m.get(relationInfo.relationMasterColumn), + mapping(n -> n.get(relationInfo.relationSlaveColumn), toSet()))); + aggregationParam.setGroupedInFilterValues(groupedFilterMap); + aggregationParam.setUseDataFilter(false); + // 开始将远程返回的聚合计算结果集合,回填到主表中的聚合虚拟字段。 + ResponseResult>> result = + relationStruct.remoteClient.aggregateBy(aggregationParam); + if (result.isSuccess()) { + this.doMakeAggregationData(result.getData(), resultList, relationStruct); + } else { + this.logErrorOrThrowException(result.getErrorMessage()); + } + } + } + + private String makeManyToManyWhereClause( + LocalRelationStruct relationStruct, + Object masterIdValue, + LocalAggregationRelationInfo localRelationInfo, + Map> criteriaListMap) { + StringBuilder whereClause = new StringBuilder(256); + whereClause.append(localRelationInfo.relationTable) + .append(".").append(localRelationInfo.relationMasterColumn); + if (masterIdValue instanceof Number) { + whereClause.append(" = ").append(masterIdValue); + } else { + whereClause.append(" = '").append(masterIdValue).append("'"); + } + // 如果需要从表聚合计算或参与过滤,则需要把中间表和从表之间的关联条件加上。 + if (!localRelationInfo.onlySelectRelationTable) { + whereClause.append(AND_OP) + .append(localRelationInfo.relationTable) + .append(".") + .append(localRelationInfo.relationSlaveColumn) + .append(" = ") + .append(localRelationInfo.slaveTable) + .append(".") + .append(localRelationInfo.slaveColumn); + } + List criteriaList = criteriaListMap.get(relationStruct.relationField.getName()); + if (criteriaList == null) { + criteriaList = new LinkedList<>(); + } + if (StrUtil.isNotBlank(relationStruct.localService.deletedFlagFieldName)) { + MyWhereCriteria deleteFilter = new MyWhereCriteria(); + deleteFilter.setCriteria( + relationStruct.relationManyToManyAggregation.slaveModelClass(), + relationStruct.localService.deletedFlagFieldName, + MyWhereCriteria.OPERATOR_EQUAL, + GlobalDeletedFlag.NORMAL); + criteriaList.add(deleteFilter); + } + if (CollUtil.isNotEmpty(criteriaList)) { + String criteriaString = MyWhereCriteria.makeCriteriaString(criteriaList); + whereClause.append(AND_OP).append(criteriaString); + } + return whereClause.toString(); + } + + private String makeOneToManyWhereClause( + LocalRelationStruct relationStruct, + Object masterIdValue, + String slaveColumnName, + Map> criteriaListMap) { + StringBuilder whereClause = new StringBuilder(64); + if (masterIdValue instanceof Number) { + whereClause.append(slaveColumnName).append(" = ").append(masterIdValue); + } else { + whereClause.append(slaveColumnName).append(" = '").append(masterIdValue).append("'"); + } + List criteriaList = criteriaListMap.get(relationStruct.relationField.getName()); + if (criteriaList == null) { + criteriaList = new LinkedList<>(); + } + if (StrUtil.isNotBlank(relationStruct.localService.deletedFlagFieldName)) { + MyWhereCriteria deleteFilter = new MyWhereCriteria(); + deleteFilter.setCriteria( + relationStruct.relationOneToManyAggregation.slaveModelClass(), + relationStruct.localService.deletedFlagFieldName, + MyWhereCriteria.OPERATOR_EQUAL, + GlobalDeletedFlag.NORMAL); + criteriaList.add(deleteFilter); + } + if (CollUtil.isNotEmpty(criteriaList)) { + String criteriaString = MyWhereCriteria.makeCriteriaString(criteriaList); + whereClause.append(AND_OP).append(criteriaString); + } + return whereClause.toString(); + } + + private static class LocalAggregationRelationInfo { + private String slaveTable; + private String slaveColumn; + private String relationTable; + private String relationMasterColumn; + private String relationSlaveColumn; + private String selectList; + private String groupBy; + private boolean onlySelectRelationTable; + } + + private static class RemoteAggregationRelationInfo { + private String relationTable; + private String relationMasterColumn; + private String relationSlaveColumn; + private String relationWhereClause; + private List slaveCriteriaList; + } + + private void doMakeAggregationData( + List> aggregationMapList, + List resultList, + RelationStruct relationStruct) { + // 根据获取的分组聚合结果集,绑定到主表总的关联字段。 + if (CollUtil.isNotEmpty(aggregationMapList)) { + Map relatedMap = new HashMap<>(aggregationMapList.size()); + for (Map map : aggregationMapList) { + relatedMap.put(map.get(MyAggregationParam.KEY_NAME).toString(), map.get(MyAggregationParam.VALUE_NAME)); + } + for (M dataObject : resultList) { + Object masterIdValue = ReflectUtil.getFieldValue(dataObject, relationStruct.masterIdField); + if (masterIdValue != null) { + Object value = relatedMap.get(masterIdValue.toString()); + if (value != null) { + ReflectUtil.setFieldValue(dataObject, relationStruct.relationField, value); + } + } + } + } + } + + private MyAggregationParam createAggregationParam(int kind, int type, String aggregationField, String groupField) { + MyAggregationParam aggregationParam = new MyAggregationParam(); + aggregationParam.setAggregationKind(kind); + aggregationParam.setAggregationType(type); + aggregationParam.setAggregationField(aggregationField); + aggregationParam.setGroupField(groupField); + return aggregationParam; + } + + private Tuple2 makeSelectListAndGroupByClause( + String groupTableName, + String groupColumnName, + Class aggregationModel, + String aggregationTableName, + String aggregationField, + Integer aggregationType) { + if (!AggregationType.isValid(aggregationType)) { + throw new IllegalArgumentException("Invalid AggregationType Value [" + + aggregationType + "] in Model [" + aggregationModel.getName() + "]."); + } + String aggregationFunc = AggregationType.getAggregationFunction(aggregationType); + String aggregationColumn = MyModelUtil.mapToColumnName(aggregationField, aggregationModel); + if (StrUtil.isBlank(aggregationColumn)) { + throw new IllegalArgumentException("Invalid AggregationField [" + + aggregationField + "] in Model [" + aggregationModel.getName() + "]."); + } + // 构建Select List + // 如:r_table.master_id groupedKey, SUM(r_table.aggr_column) aggregated_value + StringBuilder groupedSelectList = new StringBuilder(128); + groupedSelectList.append(groupTableName) + .append(".") + .append(groupColumnName) + .append(" ") + .append(MyAggregationParam.KEY_NAME) + .append(", ") + .append(aggregationFunc) + .append("(") + .append(aggregationTableName) + .append(".") + .append(aggregationColumn) + .append(") ") + .append(MyAggregationParam.VALUE_NAME) + .append(" "); + StringBuilder groupBy = new StringBuilder(64); + groupBy.append(groupTableName).append(".").append(groupColumnName); + return new Tuple2<>(groupedSelectList.toString(), groupBy.toString()); + } + + private Object doMaskFieldData(M data, Field maskField, MaskField anno) { + Object value = ReflectUtil.getFieldValue(data, maskField); + if (value == null) { + return value; + } + if (anno.maskType().equals(MaskFieldTypeEnum.NAME)) { + value = MaskFieldUtil.chineseName(value.toString(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.MOBILE_PHONE)) { + value = MaskFieldUtil.mobilePhone(value.toString(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.FIXED_PHONE)) { + value = MaskFieldUtil.fixedPhone(value.toString(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.EMAIL)) { + value = MaskFieldUtil.email(value.toString(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.ID_CARD)) { + value = MaskFieldUtil.idCardNum(value.toString(), anno.noMaskPrefix(), anno.noMaskSuffix(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.BANK_CARD)) { + value = MaskFieldUtil.bankCard(value.toString(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.CAR_LICENSE)) { + value = MaskFieldUtil.carLicense(value.toString(), anno.maskChar()); + } else if (anno.maskType().equals(MaskFieldTypeEnum.CUSTOM)) { + MaskFieldHandler handler = + maskFieldHandlerMap.computeIfAbsent(anno.handler(), ApplicationContextHolder::getBean); + value = handler.handleMask(modelClass.getSimpleName(), maskField.getName(), value.toString(), anno.maskChar()); + } + return value; + } + + private void compareAndSetMaskFieldData(M data, K id) { + if (CollUtil.isNotEmpty(maskFieldList)) { + M originalData = this.getById(id); + this.compareAndSetMaskFieldData(data, originalData); + } + } + + private String getNormalizedSlaveServiceName(String slaveServiceName, Class slaveModelClass) { + if (StrUtil.isBlank(slaveServiceName)) { + slaveServiceName = slaveModelClass.getSimpleName() + "Service"; + } + return StrUtil.lowerFirst(slaveServiceName); + } + + @Data + public static class RelationStruct { + protected Field equalOneToOneRelationField; + protected Field relationField; + protected Field masterIdField; + protected RelationDict relationDict; + protected RelationOneToOne relationOneToOne; + protected RelationOneToManyAggregation relationOneToManyAggregation; + protected RelationManyToManyAggregation relationManyToManyAggregation; + } + + @EqualsAndHashCode(callSuper = true) + @Data + public static class LocalRelationStruct extends RelationStruct { + private BaseService localService; + private BaseDaoMapper manyToManyMapper; + private Map dictMap; + private Method globalDictMethd; + private RelationConstDict relationConstDict; + private RelationGlobalDict relationGlobalDict; + private RelationOneToMany relationOneToMany; + private RelationManyToMany relationManyToMany; + } + + @EqualsAndHashCode(callSuper = true) + @Data + public static class RemoteRelationStruct extends RelationStruct { + private BaseClient remoteClient; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/service/IBaseDictService.java b/common/common-core/src/main/java/apelet/common/core/base/service/IBaseDictService.java new file mode 100644 index 0000000..30a8446 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/service/IBaseDictService.java @@ -0,0 +1,69 @@ +package apelet.common.core.base.service; + +import java.io.Serializable; +import java.util.List; + +/** + * 带有缓存功能的字典Service接口。 + * + * @param Model实体对象的类型。 + * @param Model对象主键的类型。 + * @author guifc + * @date 2023-08-04 + */ +public interface IBaseDictService extends IBaseService { + + /** + * 重新加载数据库中所有当前表数据到系统内存。 + * + * @param force true则强制刷新,如果false,当缓存中存在数据时不刷新。 + */ + void reloadCachedData(boolean force); + + /** + * 保存新增对象。 + * + * @param data 新增对象。 + * @return 返回新增对象。 + */ + M saveNew(M data); + + /** + * 更新数据对象。 + * + * @param data 更新的对象。 + * @param originalData 原有数据对象。 + * @return 成功返回true,否则false。 + */ + boolean update(M data, M originalData); + + /** + * 删除指定数据。 + * + * @param id 主键Id。 + * @return 成功返回true,否则false。 + */ + boolean remove(K id); + + /** + * 直接从缓存池中获取所有数据。 + * + * @return 返回所有数据。 + */ + List getAllListFromCache(); + + /** + * 根据父主键Id,获取子对象列表。 + * + * @param parentId 上级行政区划Id。 + * @return 下级行政区划列表。 + */ + List getListByParentId(K parentId); + + /** + * 获取缓存中的数据数量。 + * + * @return 缓存中的数据总量。 + */ + int getCachedCount(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/service/IBaseService.java b/common/common-core/src/main/java/apelet/common/core/base/service/IBaseService.java new file mode 100644 index 0000000..60c768b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/service/IBaseService.java @@ -0,0 +1,614 @@ +package apelet.common.core.base.service; + +import apelet.common.core.object.CallResult; +import apelet.common.core.object.MyRelationParam; +import apelet.common.core.object.TableModelInfo; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * 所有Service的接口。 + * + * @param Model对象的类型。 + * @param Model对象主键的类型。 + * @author guifc + * @date 2023-08-04 + */ +public interface IBaseService extends IService { + + /** + * 如果主键存在则更新,否则新增保存实体对象。 + * + * @param data 实体对象数据。 + * @param saveNew 新增实体对象方法。 + * @param update 更新实体对象方法。 + */ + void saveNewOrUpdate(M data, Consumer saveNew, BiConsumer update); + + /** + * 如果主键存在的则更新,否则批量新增保存实体对象。 + * + * @param dataList 实体对象数据列表。 + * @param saveNewBatch 批量新增实体对象方法。 + * @param update 更新实体对象方法。 + */ + void saveNewOrUpdateBatch(List dataList, Consumer> saveNewBatch, BiConsumer update); + + /** + * 根据过滤条件删除数据。 + * + * @param filter 过滤对象。 + * @return 删除数量。 + */ + Integer removeBy(M filter); + + /** + * 基于主从表之间的关联字段,批量改更新一对多从表数据。 + * 该操作会覆盖增、删、改三个操作,具体如下: + * 1. 先删除。从表中relationFieldName字段的值为relationFieldValue, 同时主键Id不在dataList中的。 + * 2. 再批量插入。遍历dataList中没有主键Id的对象,视为新对象批量插入。 + * 3. 最后逐条更新,遍历dataList中有主键Id的对象,视为已存在对象并逐条更新。 + * 4. 如果更新时间和更新用户Id为空,我们将视当前记录为变化数据,因此使用当前时间和用户分别填充这两个字段。 + * + * @param relationFieldName 主从表关联中,从表的Java字段名。 + * @param relationFieldValue 主从表关联中,与从表关联的主表字段值。该值会被赋值给从表关联字段。 + * @param updateUserIdFieldName 一对多从表的更新用户Id字段名。 + * @param updateTimeFieldName 一对多从表的更新时间字段名 + * @param dataList 批量更新的从表数据列表。 + * @param batchInserter 从表批量插入方法。 + */ + void updateBatchOneToManyRelation( + String relationFieldName, + Object relationFieldValue, + String updateUserIdFieldName, + String updateTimeFieldName, + List dataList, + Consumer> batchInserter); + + /** + * 判断指定字段的数据是否存在,且仅仅存在一条记录。 + * 如果是基于主键的过滤,会直接调用existId过滤函数,提升性能。在有缓存的场景下,也可以利用缓存。 + * + * @param fieldName 待过滤的字段名(Java 字段)。 + * @param fieldValue 字段值。 + * @return 存在且仅存在一条返回true,否则false。 + */ + boolean existOne(String fieldName, Object fieldValue); + + /** + * 判断主键Id关联的数据是否存在。 + * + * @param id 主键Id。 + * @return 存在返回true,否则false。 + */ + boolean existId(K id); + + /** + * 返回符合过滤条件的一条数据。 + * + * @param filter 过滤的Java对象。 + * @return 查询后的数据对象。 + */ + M getOne(M filter); + + /** + * 返回符合 filterField = filterValue 条件的一条数据。 + * + * @param filterField 过滤的Java字段。 + * @param filterValue 过滤的Java字段值。 + * @return 查询后的数据对象。 + */ + M getOne(String filterField, Object filterValue); + + /** + * 获取主表的查询结果,以及主表关联的字典数据和一对一从表数据,以及一对一从表的字典数据。 + * + * @param id 主表主键Id。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 查询结果对象。 + */ + M getByIdWithRelation(K id, MyRelationParam relationParam); + + /** + * 获取所有数据。 + * + * @return 返回所有数据。 + */ + List getAllList(); + + /** + * 获取排序后所有数据。 + * + * @param orderByProperties 需要排序的字段属性,这里使用Java对象中的属性名,而不是数据库字段名。 + * @return 返回排序后所有数据。 + */ + List getAllListByOrder(String... orderByProperties); + + /** + * 判断参数值主键集合中的所有数据,是否全部存在 + * + * @param idSet 待校验的主键集合。 + * @return 全部存在返回true,否则false。 + */ + boolean existAllPrimaryKeys(Set idSet); + + /** + * 判断参数值列表中的所有数据,是否全部存在。另外,keyName字段在数据表中必须是唯一键值,否则返回结果会出现误判。 + * + * @param inFilterField 待校验的数据字段,这里使用Java对象中的属性,如courseId,而不是数据字段名course_id + * @param inFilterValues 数据值列表。 + * @return 全部存在返回true,否则false。 + */ + boolean existUniqueKeyList(String inFilterField, Set inFilterValues); + + /** + * 根据过滤字段和过滤集合,返回不存在的数据。 + * + * @param filterField 过滤的Java字段。 + * @param filterSet 过滤字段数据集合。 + * @param findFirst 是否找到第一个就返回。 + * @param 过滤字段类型。 + * @return filterSet中,在从表中不存在的数据集合。 + */ + List notExist(String filterField, Set filterSet, boolean findFirst); + + /** + * 返回符合主键 IN (idValues) 条件的所有数据。 + * + * @param idValues 主键值集合。 + * @return 检索后的数据列表。 + */ + List getInList(Set idValues); + + /** + * 返回符合 inFilterField IN (inFilterValues) 条件的所有数据。 + * + * @param inFilterField 参与(IN-list)过滤的Java字段。 + * @param inFilterValues 参与(IN-list)过滤的Java字段值集合。 + * @return 检索后的数据列表。 + */ + List getInList(String inFilterField, Set inFilterValues); + + /** + * 返回符合 inFilterField IN (inFilterValues) 条件的所有数据,并根据orderBy字段排序。 + * + * @param inFilterField 参与(IN-list)过滤的Java字段。 + * @param inFilterValues 参与(IN-list)过滤的Java字段值集合。 + * @param orderBy 排序字段。 + * @return 检索后的数据列表。 + */ + List getInList(String inFilterField, Set inFilterValues, String orderBy); + + /** + * 返回符合主键 IN (idValues) 条件的所有数据。同时返回关联数据。 + * + * @param idValues 主键值集合。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 检索后的数据列表。 + */ + List getInListWithRelation(Set idValues, MyRelationParam relationParam); + + /** + * 返回符合 inFilterField IN (inFilterValues) 条件的所有数据。同时返回关联数据。 + * + * @param inFilterField 参与(IN-list)过滤的Java字段。 + * @param inFilterValues 参与(IN-list)过滤的Java字段值集合。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 检索后的数据列表。 + */ + List getInListWithRelation(String inFilterField, Set inFilterValues, MyRelationParam relationParam); + + /** + * 返回符合 inFilterField IN (inFilterValues) 条件的所有数据,并根据orderBy字段排序。同时返回关联数据。 + * + * @param inFilterField 参与(IN-list)过滤的Java字段。 + * @param inFilterValues 参与(IN-list)过滤的Java字段值集合。 + * @param orderBy 排序字段。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 检索后的数据列表。 + */ + List getInListWithRelation( + String inFilterField, Set inFilterValues, String orderBy, MyRelationParam relationParam); + + /** + * 返回符合主键 NOT IN (idValues) 条件的所有数据。 + * + * @param idValues 主键值集合。 + * @return 检索后的数据列表。 + */ + List getNotInList(Set idValues); + + /** + * 返回符合 inFilterField NOT IN (inFilterValues) 条件的所有数据。 + * + * @param inFilterField 参与(NOT IN-list)过滤的Java字段。 + * @param inFilterValues 参与(NOT IN-list)过滤的Java字段值集合。 + * @return 检索后的数据列表。 + */ + List getNotInList(String inFilterField, Set inFilterValues); + + /** + * 返回符合 inFilterField NOT IN (inFilterValues) 条件的所有数据,并根据orderBy字段排序。 + * + * @param inFilterField 参与(NOT IN-list)过滤的Java字段。 + * @param inFilterValues 参与(NOT IN-list)过滤的Java字段值集合。 + * @param orderBy 排序字段。 + * @return 检索后的数据列表。 + */ + List getNotInList(String inFilterField, Set inFilterValues, String orderBy); + + /** + * 返回符合主键 NOT IN (idValues) 条件的所有数据。同时返回关联数据。 + * + * @param idValues 主键值集合。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 检索后的数据列表。 + */ + List getNotInListWithRelation(Set idValues, MyRelationParam relationParam); + + /** + * 返回符合 inFilterField NOT IN (inFilterValues) 条件的所有数据。同时返回关联数据。 + * + * @param inFilterField 参与(NOT IN-list)过滤的Java字段。 + * @param inFilterValues 参与(NOT IN-list)过滤的Java字段值集合。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 检索后的数据列表。 + */ + List getNotInListWithRelation(String inFilterField, Set inFilterValues, MyRelationParam relationParam); + + /** + * 返回符合 inFilterField NOT IN (inFilterValues) 条件的所有数据,并根据orderBy字段排序。同时返回关联数据。 + * + * @param inFilterField 参与(NOT IN-list)过滤的Java字段。 + * @param inFilterValues 参与(NOT IN-list)过滤的Java字段值集合。 + * @param orderBy 排序字段。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 检索后的数据列表。 + */ + List getNotInListWithRelation( + String inFilterField, Set inFilterValues, String orderBy, MyRelationParam relationParam); + + /** + * 用参数对象作为过滤条件,获取数据数量。 + * + * @param filter 过滤对象中,只有被赋值的字段,才会成为where中的条件。 + * @return 返回过滤后的数据数量。 + */ + long getCountByFilter(M filter); + + /** + * 用参数对象作为过滤条件,判断是否存在过滤数据。 + * + * @param filter 过滤对象中,只有被赋值的字段,才会成为where中的条件。 + * @return 存在返回true,否则false。 + */ + boolean existByFilter(M filter); + + /** + * 用参数对象作为过滤条件,获取查询结果。 + * + * @param filter 过滤对象中,只有被赋值的字段,才会成为where中的条件。如果参数为null,则返回全部数据。 + * @return 返回过滤后的数据。 + */ + List getListByFilter(M filter); + + /** + * 用参数对象作为过滤条件,获取查询结果。同时查询并绑定关联数据。 + * + * @param filter 该方法基于mybatis的通用mapper。如果参数为null,则返回全部数据。 + * @param orderBy 排序字段。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @return 返回过滤后的数据。 + */ + List getListWithRelationByFilter(M filter, String orderBy, MyRelationParam relationParam); + + /** + * 获取父主键Id下的所有子数据列表。 + * + * @param parentIdFieldName 父主键字段名字,如"courseId"。 + * @param parentId 父主键的值。 + * @return 父主键Id下的所有子数据列表。 + */ + List getListByParentId(String parentIdFieldName, K parentId); + + /** + * 根据指定的显示字段列表、过滤条件字符串和分组字符串,返回聚合计算后的查询结果。(基本是内部框架使用,不建议外部接口直接使用)。 + * + * @param selectFields 选择的字段列表,多个字段逗号分隔。 + * NOTE: 如果数据表字段和Java对象字段名字不同,Java对象字段应该以别名的形式出现。 + * 如: table_column_name modelFieldName。否则无法被反射回Bean对象。 + * @param whereClause SQL常量形式的条件从句。 + * @param groupBy SQL常量形式分组字段列表,逗号分隔。 + * @return 聚合计算后的数据结果集。 + */ + List> getGroupedListByCondition(String selectFields, String whereClause, String groupBy); + + /** + * 根据指定的显示字段列表、过滤条件字符串和排序字符串,返回查询结果。(基本是内部框架使用,不建议外部接口直接使用)。 + * + * @param selectList 选择的Java字段列表。如果为空表示返回全部字段。 + * @param filter 过滤对象。 + * @param whereClause SQL常量形式的条件从句。 + * @param orderBy SQL常量形式排序字段列表,逗号分隔。 + * @return 查询结果。 + */ + List getListByCondition(List selectList, M filter, String whereClause, String orderBy); + + /** + * 用指定过滤条件,计算记录数量。(基本是内部框架使用,不建议外部接口直接使用)。 + * + * @param whereClause SQL常量形式的条件从句。 + * @return 返回过滤后的数据数量。 + */ + Integer getCountByCondition(String whereClause); + + /** + * 仅对标记MaskField注解的字段数据进行脱敏。 + * + * @param data 实体对象。 + * @param ignoreFieldSet 忽略字段集合。如果为null,则对所有标记MaskField注解的字段数据进行脱敏处理。 + */ + void maskFieldData(M data, Set ignoreFieldSet); + + /** + * 仅对标记MaskField注解的字段数据进行脱敏。 + * + * @param dataList 实体对象列表。 + * @param ignoreFieldSet 忽略字段集合。如果为null,则对所有标记MaskField注解的字段数据进行脱敏处理。 + */ + void maskFieldDataList(List dataList, Set ignoreFieldSet); + + /** + * 比较并处理脱敏字段的数据变化。 + * 如果data对象中的脱敏字段值和originalData字段的脱敏后值相同,表示当前data对象的脱敏字段数据没有变化, + * 因此需要使用数据库中的原有字段值,覆盖当前实体对象中的该字段值,以保证数据库表字段中始终存储的是未脱敏数据。 + * + * @param data 当前数据对象。 + * @param originalData 原数据对象。 + */ + void compareAndSetMaskFieldData(M data, M originalData); + + /** + * 对标记MaskField注解的脱敏字段进行判断。字段数据中不能包含脱敏掩码字符。 + * + * @param data 实体对象。 + */ + void verifyMaskFieldData(M data); + + /** + * 根据最新对象和原有对象的数据对比,判断关联的字典数据和多对一主表数据是否都是合法数据。 + * NOTE: BaseService中会给出返回CallResult.ok()的缺省实现。每个业务服务实现类在需要的时候可以重载该方法。 + * + * @param data 数据对象。 + * @param originalData 原有数据对象,null表示data为新增对象。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyRelatedData(M data, M originalData); + + /** + * 根据最新对象和原有对象的数据对比,判断关联的字典数据和多对一主表数据是否都是合法数据。 + * 如果data对象中包含主键值,方法内部会获取原有对象值,并进行更新方式的关联数据比对,否则视为新增数据关联对象比对。 + * + * @param data 数据对象。 + * @return 应答结果对象。 + */ + CallResult verifyRelatedData(M data); + + /** + * 根据最新对象列表和原有对象列表的数据对比,判断关联的字典数据和多对一主表数据是否都是合法数据。 + * 如果dataList列表中的对象包含主键值,方法内部会获取原有对象值,并进行更新方式的关联数据比对,否则视为新增数据关联对象比对。 + * + * @param dataList 数据对象列表。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyRelatedData(List dataList); + + /** + * 根据最新对象和原有对象的数据对比,判断关联的远程字典数据和多对一主表数据是否都是合法数据。 + * NOTE: BaseService中会给出返回CallResult.ok()的缺省实现。每个业务服务实现类在需要的时候可以重载该方法。 + * + * @param data 数据对象。 + * @param originalData 原有数据对象,null表示data为新增对象。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyRemoteRelatedData(M data, M originalData); + + /** + * 根据最新对象列表和原有对象列表的数据对比,判断关联的远程字典数据和多对一主表数据是否都是合法数据。 + * NOTE: BaseService中会给出返回CallResult.ok()的缺省实现。每个业务服务实现类在需要的时候可以重载该方法。 + * + * @param dataList 数据对象列表。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyRemoteRelatedData(List dataList); + + /** + * 根据最新对象和原有对象的数据对比,判断关联的本地和远程字典数据和多对一主表数据是否都是合法数据。 + * NOTE: BaseService中给出了缺省实现。 + * + * @param data 数据对象。 + * @param originalData 原有数据对象,null表示data为新增对象。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyAllRelatedData(M data, M originalData); + + /** + * 根据最新对象和原有对象的数据对比,判断关联的本地和远程字典数据和多对一主表数据是否都是合法数据。 + * NOTE: BaseService中给出了缺省实现。 + * 如果data对象中包含主键值,方法内部会获取原有对象值,并进行更新方式的关联数据比对,否则视为新增数据关联对象比对。 + * + * @param data 数据对象。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyAllRelatedData(M data); + + /** + * 根据最新对象列表和原有对象列表的数据对比,判断关联的本地和远程字典数据和多对一主表数据是否都是合法数据。 + * NOTE: BaseService中给出了缺省实现。 + * + * @param dataList 数据对象列表。 + * @return 数据全部正确返回true,否则false,同时返回具体的错误信息。 + */ + CallResult verifyAllRelatedData(List dataList); + + /** + * 批量导入数据列表,对依赖全局字典的数据进行验证。 + * + * @param dataList 批量导入数据列表。 + * @param fieldName 业务主表中依赖全局字典的字段名,包含RelationGlobalDict注解的字段。 + * @param idGetter 获取业务主表中依赖全局字典字段值的Function对象。 + * @param 业务主表中依全局字典的字段类型。 + * @return 验证结果,如果失败,在data中包含具体的错误对象。 + */ + CallResult verifyImportForGlobalDict(List dataList, String fieldName, Function idGetter); + + /** + * 批量导入数据列表,对依赖常量字典的数据进行验证。 + * + * @param dataList 批量导入数据列表。 + * @param fieldName 业务主表中依赖常量字典的字段名,包含RelationConstDict注解的字段。 + * @param idGetter 获取业务主表中依赖常量字典字段值的Function对象。 + * @param 业务主表中依赖常量字典的字段类型。 + * @return 验证结果,如果失败,在data中包含具体的错误对象。 + */ + CallResult verifyImportForConstDict(List dataList, String fieldName, Function idGetter); + + /** + * 批量导入数据列表,对依赖字典表字典的数据进行验证。 + * + * @param dataList 批量导入数据列表。 + * @param fieldName 业务主表中依赖字典表字典的字段名,包含RelationDict注解的字段。 + * @param idGetter 获取业务主表中依赖字典表字典字段值的Function对象。 + * @param 业务主表中依赖字典表字典的字段类型。 + * @return 验证结果,如果失败,在data中包含具体的错误对象。 + */ + CallResult verifyImportForDict(List dataList, String fieldName, Function idGetter); + + /** + * 批量导入数据列表,对依赖数据源字典的数据进行验证。 + * + * @param dataList 批量导入数据列表。 + * @param fieldName 业务主表中依赖数据源字典的字段名,包含RelationDict注解的字段的数据源字典。 + * @param idGetter 获取业务主表中依赖数据源字典字段值的Function对象。 + * @param 业务主表中依赖数据源字典的字段类型。 + * @return 验证结果,如果失败,在data中包含具体的错误对象。 + */ + CallResult verifyImportForDatasourceDict(List dataList, String fieldName, Function idGetter); + + /** + * 批量导入数据列表,对存在一对一关联的数据进行验证。 + * + * @param dataList 批量导入数据列表。 + * @param fieldName 业务主表中存在一对一关联的字段名,包含RelationOneToOne注解的字段。 + * @param idGetter 获取业务主表中一对一关联字段值的Function对象。 + * @param 业务主表中存在一对一关联的字段类型。 + * @return 验证结果,如果失败,在data中包含具体的错误对象。 + */ + CallResult verifyImportForOneToOneRelation(List dataList, String fieldName, Function idGetter); + + /** + * 集成所有与主表实体对象相关的关联数据列表。包括一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + */ + void buildRelationForDataList(List resultList, MyRelationParam relationParam); + + /** + * 集成所有与主表实体对象相关的关联数据列表。包括本地和远程服务的一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + void buildRelationForDataList(List resultList, MyRelationParam relationParam, Set ignoreFields); + + /** + * 该函数主要用于对查询结果的批量导出。不同于支持分页的列表查询,批量导出没有分页机制, + * 因此在导出数据量较大的情况下,很容易给数据库的内存、CPU和IO带来较大的压力。而通过 + * 我们的分批处理,可以极大的规避该问题的出现几率。调整batchSize的大小,也可以有效的 + * 改善运行效率。 + * 我们目前的处理机制是,先从主表取出所有符合条件的主表数据,这样可以避免分批处理时, + * 后面几批数据,因为skip过多而带来的效率问题。因为是单表过滤,不会给数据库带来过大的压力。 + * 之后再在主表结果集数据上进行分批级联处理。 + * 集成所有与主表实体对象相关的关联数据列表。包括一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param batchSize 每批集成的记录数量。小于等于0时将不做分批处理。 + */ + void buildRelationForDataList(List resultList, MyRelationParam relationParam, int batchSize); + + /** + * 该函数主要用于对查询结果的批量导出。不同于支持分页的列表查询,批量导出没有分页机制, + * 因此在导出数据量较大的情况下,很容易给数据库的内存、CPU和IO带来较大的压力。而通过 + * 我们的分批处理,可以极大的规避该问题的出现几率。调整batchSize的大小,也可以有效的 + * 改善运行效率。 + * 我们目前的处理机制是,先从主表取出所有符合条件的主表数据,这样可以避免分批处理时, + * 后面几批数据,因为skip过多而带来的效率问题。因为是单表过滤,不会给数据库带来过大的压力。 + * 之后再在主表结果集数据上进行分批级联处理。 + * 集成所有与主表实体对象相关的关联数据列表。包括一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param resultList 主表实体对象列表。数据集成将直接作用于该对象列表。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param batchSize 每批集成的记录数量。小于等于0时将不做分批处理。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + */ + void buildRelationForDataList( + List resultList, MyRelationParam relationParam, int batchSize, Set ignoreFields); + + /** + * 集成所有与主表实体对象相关的关联数据对象。包括一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param dataObject 主表实体对象。数据集成将直接作用于该对象。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param 实体对象类型。 + */ + void buildRelationForData(T dataObject, MyRelationParam relationParam); + + /** + * 集成所有与主表实体对象相关的关联数据对象。包括本地和远程服务的一对一、字典、一对多和多对多聚合运算等。 + * 也可以根据实际需求,单独调用该函数所包含的各个数据集成函数。 + * NOTE: 该方法内执行的SQL将禁用数据权限过滤。 + * + * @param dataObject 主表实体对象。数据集成将直接作用于该对象。 + * @param relationParam 实体对象数据组装的参数构建器。 + * @param ignoreFields 该集合中的字段,即便包含注解也不会在当前调用中进行数据组装。 + * @param 实体对象类型。 + */ + void buildRelationForData(T dataObject, MyRelationParam relationParam, Set ignoreFields); + + /** + * 仅仅在spring boot 启动后的监听器事件中调用,缓存所有远程调用Client的关联关系,加速后续的数据绑定效率。 + */ + void loadRemoteRelationStruct(); + + /** + * 仅仅在spring boot 启动后的监听器事件中调用,缓存所有service的关联关系,加速后续的数据绑定效率。 + */ + void loadLocalRelationStruct(); + + /** + * 获取当前服务引用的实体对象及表信息。 + * + * @return 实体对象及表信息。 + */ + TableModelInfo getTableModelInfo(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/base/vo/BaseVo.java b/common/common-core/src/main/java/apelet/common/core/base/vo/BaseVo.java new file mode 100644 index 0000000..08de657 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/base/vo/BaseVo.java @@ -0,0 +1,35 @@ +package apelet.common.core.base.vo; + +import lombok.Data; + +import java.util.Date; + +/** + * VO对象的公共基类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class BaseVo { + + /** + * 创建者Id。 + */ + private Long createUserId; + + /** + * 创建时间。 + */ + private Date createTime; + + /** + * 更新者Id。 + */ + private Long updateUserId; + + /** + * 更新时间。 + */ + private Date updateTime; +} diff --git a/common/common-core/src/main/java/apelet/common/core/cache/CacheConfig.java b/common/common-core/src/main/java/apelet/common/core/cache/CacheConfig.java new file mode 100644 index 0000000..ac16677 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/cache/CacheConfig.java @@ -0,0 +1,111 @@ +package apelet.common.core.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/** + * 使用Caffeine作为本地缓存库 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +@EnableCaching +public class CacheConfig { + + private static final int DEFAULT_MAXSIZE = 10000; + private static final int DEFAULT_TTL = 3600; + + /** + * 定义cache名称、超时时长秒、最大个数 + * 每个cache缺省3600秒过期,最大个数1000 + */ + public enum CacheEnum { + /** + * 专门存储用户权限的缓存(600秒)。 + */ + USER_PERMISSION_CACHE(600, 10000), + /** + * 专门存储用户数据权限的缓存(600秒)。 + */ + DATA_PERMISSION_CACHE(600, 10000), + /** + * 专门存储用户菜单关联权限的缓存(600秒)。 + */ + MENU_PERM_CACHE(600, 10000), + /** + * 存储指定部门Id集合的所有子部门Id结合(600秒)。 + */ + CHILDREN_DEPT_ID_CACHE(1800, 10000), + /** + * 在线表单组件渲染数据缓存。 + */ + ONLINE_FORM_RENDER_CACCHE(600, 100), + /** + * 报表表单组件渲染数据缓存。 + */ + REPORT_FORM_RENDER_CACCHE(600, 100), + /** + * 缺省全局缓存(时间是24小时)。 + */ + GLOBAL_CACHE(86400, 20000), + + /** + * 元数据缓存 + */ + METAS_CACHE(86400, 2000); + + CacheEnum() { + } + + CacheEnum(int ttl, int maxSize) { + this.ttl = ttl; + this.maxSize = maxSize; + } + + /** + * 缓存的最大数量。 + */ + private int maxSize = DEFAULT_MAXSIZE; + /** + * 缓存的时长(单位:秒) + */ + private int ttl = DEFAULT_TTL; + + public int getMaxSize() { + return maxSize; + } + + public int getTtl() { + return ttl; + } + } + + /** + * 初始化缓存配置。这里为了有别于Redisson的缓存。 + */ + @Bean("caffeineCacheManager") + public CacheManager cacheManager() { + SimpleCacheManager manager = new SimpleCacheManager(); + // 把各个cache注册到cacheManager中,CaffeineCache实现了org.springframework.cache.Cache接口 + ArrayList caches = new ArrayList<>(); + for (CacheEnum c : CacheEnum.values()) { + caches.add(new CaffeineCache(c.name(), + Caffeine.newBuilder().recordStats() + .expireAfterWrite(c.getTtl(), TimeUnit.SECONDS) + .maximumSize(c.getMaxSize()) + .build()) + ); + } + manager.setCaches(caches); + return manager; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/cache/CacheKey.java b/common/common-core/src/main/java/apelet/common/core/cache/CacheKey.java new file mode 100644 index 0000000..32dd3e2 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/cache/CacheKey.java @@ -0,0 +1,60 @@ +package apelet.common.core.cache; + +/** + * 在线表单缓存 + * @author Soon + * @version 1.0 + * Create by 2024/10/29 15:13 + */ +public class CacheKey { + + /** + * ORM 封装元数据 + 表名 + */ + public final static String EntityTable = "EntityTable:"; + + + /** + *表单元数据字段集合 + headId 表单元数据id + */ + public final static String FORM_FIELD_BY_HEAD_Id = "FORM_FIELD_BY_HEAD_Id:"; + + public static String makeEntityTableKey(String datasourceVariableName) { + return "EntityTable:" + datasourceVariableName; + } + + + + public static String makeTableKey(Long id) { + return "FORM_HEAD_TABLE:" + id; + } + + public static String makeFormHeadByTableId(Long id) { + return "FORM_HEAD_BY_TABLE_ID:" + id; + } + + + public static String makeTableKey(String tableName) { + return "FORM_HEAD_TABLE_NAME:" + tableName; + } + + + public static String makeChildFieldListKey(Long id) { + return "FORM_CHILD_FIELD:" + id; + } + + + public static String makeOneToManyTable(String tableName) { + return "FORM_ONETOMANY_TABLE:" + tableName; + } + + + + public static String getFormFieldByHeadId(long headId) { + return FORM_FIELD_BY_HEAD_Id + headId; + } + + + + +} diff --git a/common/common-core/src/main/java/apelet/common/core/cache/DictionaryCache.java b/common/common-core/src/main/java/apelet/common/core/cache/DictionaryCache.java new file mode 100644 index 0000000..62b30ff --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/cache/DictionaryCache.java @@ -0,0 +1,89 @@ +package apelet.common.core.cache; + +import java.util.List; +import java.util.Set; + +/** + * 主要用于完整缓存字典表数据的接口对象。 + * + * @param 字典表主键类型。 + * @param 字典表对象类型。 + * @author guifc + * @date 2023-08-04 + */ +public interface DictionaryCache { + + /** + * 按照数据插入的顺序返回全部字典对象的列表。 + * + * @return 全部字段数据列表。 + */ + List getAll(); + + /** + * 获取缓存中与键列表对应的对象列表。 + * + * @param keys 主键集合。 + * @return 对象列表。 + */ + List getInList(Set keys); + + /** + * 重新加载。如果数据列表为空,则会清空原有缓存数据。 + * + * @param dataList 待缓存的数据列表。 + * @param force true则强制刷新,如果false,当缓存中存在数据时不刷新。 + */ + void reload(List dataList, boolean force); + + /** + * 从缓存中获取指定的数据。 + * + * @param key 数据的key。 + * @return 获取到的数据,如果没有返回null。 + */ + V get(K key); + + /** + * 将数据存入缓存。 + * + * @param key 通常为字典数据的主键。 + * @param object 字典数据对象。 + */ + void put(K key, V object); + + /** + * 获取缓存中数据条目的数量。 + * + * @return 返回缓存的数据数量。 + */ + int getCount(); + + /** + * 删除缓存中指定的键。 + * + * @param key 待删除数据的主键。 + * @return 返回被删除的对象,如果主键不存在,返回null。 + */ + V invalidate(K key); + + /** + * 删除缓存中,参数列表中包含的键。 + * + * @param keys 待删除数据的主键集合。 + */ + void invalidateSet(Set keys); + + /** + * 清空缓存。 + */ + void invalidateAll(); + + /** + * 根据父主键Id获取所有子对象的列表。 + * + * @param parentId 父主键Id。如果parentId为null,则返回所有一级节点数据。 + * @return 所有子对象的列表。 + */ + default List getListByParentId(K parentId) { throw new UnsupportedOperationException(); } +} diff --git a/common/common-core/src/main/java/apelet/common/core/cache/MapDictionaryCache.java b/common/common-core/src/main/java/apelet/common/core/cache/MapDictionaryCache.java new file mode 100644 index 0000000..14076fa --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/cache/MapDictionaryCache.java @@ -0,0 +1,200 @@ +package apelet.common.core.cache; + +import apelet.common.core.exception.MapCacheAccessException; +import cn.hutool.core.map.MapUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * 字典数据内存缓存对象。 + * + * @param 字典表主键类型。 + * @param 字典表对象类型。 + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class MapDictionaryCache implements DictionaryCache { + + /** + * 存储字典数据的Map。 + */ + protected final LinkedHashMap dataMap = new LinkedHashMap<>(); + /** + * 获取字典主键数据的函数对象。 + */ + protected final Function idGetter; + /** + * 由于大部分场景是读取操作,所以使用读写锁提高并发的伸缩性。 + */ + protected final ReadWriteLock lock = new ReentrantReadWriteLock(); + /** + * 超时时长。单位毫秒。 + */ + protected static final long TIMEOUT = 2000L; + + /** + * 当前对象的构造器函数。 + * + * @param idGetter 获取当前类主键字段值的函数对象。 + * @param 字典主键类型。 + * @param 字典对象类型 + * @return 实例化后的字典内存缓存对象。 + */ + public static MapDictionaryCache create(Function idGetter) { + if (idGetter == null) { + throw new IllegalArgumentException("IdGetter can't be NULL."); + } + return new MapDictionaryCache<>(idGetter); + } + + /** + * 构造函数。 + * + * @param idGetter 主键Id的获取函数对象。 + */ + public MapDictionaryCache(Function idGetter) { + this.idGetter = idGetter; + } + + @Override + public List getAll() { + return this.safeRead("getAll", () -> { + List resultList = new LinkedList<>(); + if (MapUtil.isNotEmpty(dataMap)) { + resultList.addAll(dataMap.values()); + } + return resultList; + }); + } + + @Override + public List getInList(Set keys) { + return this.safeRead("getInList", () -> { + List resultList = new LinkedList<>(); + keys.forEach(key -> { + V object = dataMap.get(key); + if (object != null) { + resultList.add(object); + } + }); + return resultList; + }); + } + + @Override + public V get(K id) { + if (id == null) { + return null; + } + return this.safeRead("get", () -> dataMap.get(id)); + } + + @Override + public void reload(List dataList, boolean force) { + if (!force && this.getCount() > 0) { + return; + } + this.safeWrite("reload", () -> { + dataMap.clear(); + dataList.forEach(dataObj -> { + K id = idGetter.apply(dataObj); + dataMap.put(id, dataObj); + }); + return null; + }); + } + + @Override + public void put(K id, V object) { + this.safeWrite("put", () -> dataMap.put(id, object)); + } + + @Override + public int getCount() { + return dataMap.size(); + } + + @Override + public V invalidate(K id) { + if (id == null) { + return null; + } + return this.safeWrite("invalidate", () -> dataMap.remove(id)); + } + + @Override + public void invalidateSet(Set keys) { + this.safeWrite("invalidateSet", () -> { + keys.forEach(id -> { + if (id != null) { + dataMap.remove(id); + } + }); + return null; + }); + } + + @Override + public void invalidateAll() { + this.safeWrite("invalidateAll", () -> { + dataMap.clear(); + return null; + }); + } + + protected T safeRead(String functionName, Supplier supplier) { + String exceptionMessage; + try { + if (lock.readLock().tryLock(TIMEOUT, TimeUnit.MILLISECONDS)) { + try { + return supplier.get(); + } finally { + lock.readLock().unlock(); + } + } else { + throw new TimeoutException(); + } + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + exceptionMessage = String.format( + "LOCK Operation of [MapDictionaryCache::%s] encountered EXCEPTION [%s] for DICT.", + functionName, e.getClass().getSimpleName()); + log.warn(exceptionMessage); + throw new MapCacheAccessException(exceptionMessage, e); + } + } + + protected T safeWrite(String functionName, Supplier supplier) { + String exceptionMessage; + try { + if (lock.writeLock().tryLock(TIMEOUT, TimeUnit.MILLISECONDS)) { + try { + return supplier.get(); + } finally { + lock.writeLock().unlock(); + } + } else { + throw new TimeoutException(); + } + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + exceptionMessage = String.format( + "LOCK Operation of [MapDictionaryCache::%s] encountered EXCEPTION [%s] for DICT.", + functionName, e.getClass().getSimpleName()); + log.warn(exceptionMessage); + throw new MapCacheAccessException(exceptionMessage, e); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/cache/MapTreeDictionaryCache.java b/common/common-core/src/main/java/apelet/common/core/cache/MapTreeDictionaryCache.java new file mode 100644 index 0000000..f133233 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/cache/MapTreeDictionaryCache.java @@ -0,0 +1,138 @@ +package apelet.common.core.cache; + +import cn.hutool.core.collection.CollUtil; +import com.google.common.collect.LinkedHashMultimap; +import com.google.common.collect.Multimap; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.function.Function; + +/** + * 树形字典数据内存缓存对象。 + * + * @param 字典表主键类型。 + * @param 字典表对象类型。 + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class MapTreeDictionaryCache extends MapDictionaryCache { + + /** + * 树形数据存储对象。 + */ + private final Multimap allTreeMap = LinkedHashMultimap.create(); + /** + * 获取字典父主键数据的函数对象。 + */ + protected final Function parentIdGetter; + + /** + * 当前对象的构造器函数。 + * + * @param idGetter 获取当前类主键字段值的函数对象。 + * @param parentIdGetter 获取当前类父主键字段值的函数对象。 + * @param 字典主键类型。 + * @param 字典对象类型 + * @return 实例化后的树形字典内存缓存对象。 + */ + public static MapTreeDictionaryCache create(Function idGetter, Function parentIdGetter) { + if (idGetter == null) { + throw new IllegalArgumentException("IdGetter can't be NULL."); + } + if (parentIdGetter == null) { + throw new IllegalArgumentException("ParentIdGetter can't be NULL."); + } + return new MapTreeDictionaryCache<>(idGetter, parentIdGetter); + } + + /** + * 构造函数。 + * + * @param idGetter 获取当前类主键字段值的函数对象。 + * @param parentIdGetter 获取当前类父主键字段值的函数对象。 + */ + public MapTreeDictionaryCache(Function idGetter, Function parentIdGetter) { + super(idGetter); + this.parentIdGetter = parentIdGetter; + } + + @Override + public void reload(List dataList, boolean force) { + if (!force && this.getCount() > 0) { + return; + } + this.safeWrite("reload", () -> { + dataMap.clear(); + allTreeMap.clear(); + dataList.forEach(data -> { + K id = idGetter.apply(data); + dataMap.put(id, data); + K parentId = parentIdGetter.apply(data); + allTreeMap.put(parentId, data); + }); + return null; + }); + } + + @Override + public List getListByParentId(K parentId) { + return this.safeRead("getListByParentId", () -> { + List resultList = new LinkedList<>(); + Collection children = allTreeMap.get(parentId); + if (CollUtil.isNotEmpty(children)) { + resultList.addAll(children); + } + return resultList; + }); + } + + @Override + public void put(K id, V data) { + this.safeWrite("put", () -> { + dataMap.put(id, data); + K parentId = parentIdGetter.apply(data); + allTreeMap.remove(parentId, data); + allTreeMap.put(parentId, data); + return null; + }); + } + + @Override + public V invalidate(K id) { + return this.safeWrite("invalidate", () -> { + V v = dataMap.remove(id); + if (v != null) { + K parentId = parentIdGetter.apply(v); + allTreeMap.remove(parentId, v); + } + return v; + }); + } + + @Override + public void invalidateSet(Set keys) { + this.safeWrite("invalidateSet", () -> { + keys.forEach(id -> { + if (id != null) { + V data = dataMap.remove(id); + if (data != null) { + K parentId = parentIdGetter.apply(data); + allTreeMap.remove(parentId, data); + } + } + }); + return null; + }); + } + + @Override + public void invalidateAll() { + this.safeWrite("invalidateAll", () -> { + dataMap.clear(); + allTreeMap.clear(); + return null; + }); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/BaseMultiDataSourceConfig.java b/common/common-core/src/main/java/apelet/common/core/config/BaseMultiDataSourceConfig.java new file mode 100644 index 0000000..8433659 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/BaseMultiDataSourceConfig.java @@ -0,0 +1,60 @@ +package apelet.common.core.config; + +import com.alibaba.druid.pool.DruidDataSource; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 基于Druid的数据源配置的基类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "spring.datasource.druid") +public class BaseMultiDataSourceConfig { + + private String driverClassName; + private String name; + private Integer initialSize; + private Integer minIdle; + private Integer maxActive; + private Integer maxWait; + private Integer timeBetweenEvictionRunsMillis; + private Integer minEvictableIdleTimeMillis; + private Boolean poolPreparedStatements; + private Integer maxPoolPreparedStatementPerConnectionSize; + private Integer maxOpenPreparedStatements; + private String validationQuery; + private Boolean testWhileIdle; + private Boolean testOnBorrow; + private Boolean testOnReturn; + + /** + * 将连接池的通用配置应用到数据源对象上。 + * + * @param druidDataSource Druid的数据源。 + * @return 应用后的Druid数据源。 + */ + protected DruidDataSource applyCommonProps(DruidDataSource druidDataSource) { + druidDataSource.setConnectionErrorRetryAttempts(5); + druidDataSource.setDriverClassName(driverClassName); + druidDataSource.setName(name); + druidDataSource.setInitialSize(initialSize); + druidDataSource.setMinIdle(minIdle); + druidDataSource.setMaxActive(maxActive); + druidDataSource.setMaxWait(maxWait); + druidDataSource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis); + druidDataSource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis); + druidDataSource.setPoolPreparedStatements(poolPreparedStatements); + druidDataSource.setMaxPoolPreparedStatementPerConnectionSize(maxPoolPreparedStatementPerConnectionSize); + druidDataSource.setMaxOpenPreparedStatements(maxOpenPreparedStatements); + druidDataSource.setValidationQuery(validationQuery); + druidDataSource.setTestWhileIdle(testWhileIdle); + druidDataSource.setTestOnBorrow(testOnBorrow); + druidDataSource.setTestOnReturn(testOnReturn); + return druidDataSource; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/CommonWebMvcConfig.java b/common/common-core/src/main/java/apelet/common/core/config/CommonWebMvcConfig.java new file mode 100644 index 0000000..33c23ac --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/CommonWebMvcConfig.java @@ -0,0 +1,87 @@ +package apelet.common.core.config; + +import apelet.common.core.interceptor.MyRequestArgumentResolver; +import apelet.common.core.util.ContextUtil; +import apelet.common.core.util.MyDateUtil; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.alibaba.fastjson.support.config.FastJsonConfig; +import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.servlet.http.HttpServletRequest; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * 所有的项目拦截器、参数解析器、消息对象转换器都在这里集中配置。 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +public class CommonWebMvcConfig implements WebMvcConfigurer { + + @Bean + public MethodValidationPostProcessor methodValidationPostProcessor() { + return new MethodValidationPostProcessor(); + } + + @Override + public void addArgumentResolvers(List argumentResolvers) { + // 添加MyRequestBody参数解析器 + argumentResolvers.add(new MyRequestArgumentResolver()); + } + + private HttpMessageConverter responseBodyConverter() { + return new StringHttpMessageConverter(StandardCharsets.UTF_8); + } + + @Bean + public FastJsonHttpMessageConverter fastJsonHttpMessageConverter() { + FastJsonHttpMessageConverter fastConverter = new MyFastJsonHttpMessageConverter(); + List supportedMediaTypes = new ArrayList<>(); + supportedMediaTypes.add(MediaType.APPLICATION_JSON); + supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED); + fastConverter.setSupportedMediaTypes(supportedMediaTypes); + FastJsonConfig fastJsonConfig = new FastJsonConfig(); + fastJsonConfig.setSerializerFeatures( + SerializerFeature.PrettyFormat, + SerializerFeature.DisableCircularReferenceDetect, + SerializerFeature.IgnoreNonFieldGetter); + fastJsonConfig.setDateFormat(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT); + fastConverter.setFastJsonConfig(fastJsonConfig); + return fastConverter; + } + + @Override + public void configureMessageConverters(List> converters) { + converters.add(responseBodyConverter()); + converters.add(new ByteArrayHttpMessageConverter()); + converters.add(fastJsonHttpMessageConverter()); + } + + public static class MyFastJsonHttpMessageConverter extends FastJsonHttpMessageConverter { + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + HttpServletRequest request = ContextUtil.getHttpRequest(); + if (request == null) { + return super.canWrite(type, clazz, mediaType); + } + if (request.getRequestURI().contains("/v3/api-docs")) { + return false; + } + return super.canWrite(type, clazz, mediaType); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/CoreProperties.java b/common/common-core/src/main/java/apelet/common/core/config/CoreProperties.java new file mode 100644 index 0000000..afc0504 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/CoreProperties.java @@ -0,0 +1,83 @@ +package apelet.common.core.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * common-core的配置属性类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "common-core") +public class CoreProperties { + + public static final String MYSQL_TYPE = "mysql"; + public static final String POSTGRESQL_TYPE = "postgresql"; + public static final String ORACLE_TYPE = "oracle"; + public static final String DM_TYPE = "dm8"; + public static final String KINGBASE_TYPE = "kingbase"; + public static final String OPENGAUSS_TYPE = "opengauss"; + + /** + * 数据库类型。 + */ + private String databaseType = MYSQL_TYPE; + + /** + * 是否为MySQL。 + * + * @return 是返回true,否则false。 + */ + public boolean isMySql() { + return this.databaseType.equals(MYSQL_TYPE); + } + + /** + * 是否为PostgreSQl。 + * + * @return 是返回true,否则false。 + */ + public boolean isPostgresql() { + return this.databaseType.equals(POSTGRESQL_TYPE); + } + + /** + * 是否为Oracle。 + * + * @return 是返回true,否则false。 + */ + public boolean isOracle() { + return this.databaseType.equals(ORACLE_TYPE); + } + + /** + * 是否为达梦8。 + * + * @return 是返回true,否则false。 + */ + public boolean isDm() { + return this.databaseType.equals(DM_TYPE); + } + + /** + * 是否为人大金仓。 + * + * @return 是返回true,否则false。 + */ + public boolean isKingbase() { + return this.databaseType.equals(KINGBASE_TYPE); + } + + /** + * 是否为华为高斯。 + * + * @return 是返回true,否则false。 + */ + public boolean isOpenGauss() { + return this.databaseType.equals(OPENGAUSS_TYPE); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/DataSourceContextHolder.java b/common/common-core/src/main/java/apelet/common/core/config/DataSourceContextHolder.java new file mode 100644 index 0000000..e20b633 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/DataSourceContextHolder.java @@ -0,0 +1,52 @@ +package apelet.common.core.config; + +/** + * 通过线程本地存储的方式,保存当前数据库操作所需的数据源类型,动态数据源会根据该值,进行动态切换。 + * + * @author guifc + * @date 2023-08-04 + */ +public class DataSourceContextHolder { + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + /** + * 设置数据源类型。 + * + * @param type 数据源类型 + * @return 原有数据源类型,如果第一次设置则返回null。 + */ + public static Integer setDataSourceType(Integer type) { + Integer datasourceType = CONTEXT_HOLDER.get(); + CONTEXT_HOLDER.set(type); + return datasourceType; + } + + /** + * 获取当前数据库操作执行线程的数据源类型,同时由动态数据源的路由函数调用。 + * + * @return 数据源类型。 + */ + public static Integer getDataSourceType() { + return CONTEXT_HOLDER.get(); + } + + /** + * 清除线程本地变量,以免内存泄漏。 + + * @param originalType 原有的数据源类型,如果该值为null,则情况本地化变量。 + */ + public static void unset(Integer originalType) { + if (originalType == null) { + CONTEXT_HOLDER.remove(); + } else { + CONTEXT_HOLDER.set(originalType); + } + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private DataSourceContextHolder() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/DataSourceInfo.java b/common/common-core/src/main/java/apelet/common/core/config/DataSourceInfo.java new file mode 100644 index 0000000..90de6e9 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/DataSourceInfo.java @@ -0,0 +1,41 @@ +package apelet.common.core.config; + +import lombok.Data; + +/** + * 主要用户动态多数据源使用的配置数据。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class DataSourceInfo { + /** + * 用于多数据源切换的数据源类型。 + */ + private Integer datasourceType; + /** + * 用户名。 + */ + private String username; + /** + * 密码。 + */ + private String password; + /** + * 数据库主机。 + */ + private String databaseHost; + /** + * 端口号。 + */ + private Integer port; + /** + * 模式名。 + */ + private String schemaName; + /** + * 数据库名称。 + */ + private String databaseName; +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/DynamicDataSource.java b/common/common-core/src/main/java/apelet/common/core/config/DynamicDataSource.java new file mode 100644 index 0000000..8c8af7f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/DynamicDataSource.java @@ -0,0 +1,170 @@ +package apelet.common.core.config; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.druid.pool.DruidDataSource; +import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.util.Assert; + +import java.util.*; + +/** + * 动态数据源对象。当存在多个数据连接时使用。 + * + * @author guifc + * @date 2023-08-04 + */ +public class DynamicDataSource extends AbstractRoutingDataSource { + + @Autowired + private BaseMultiDataSourceConfig baseMultiDataSourceConfig; + @Autowired + private CoreProperties properties; + + private Set dynamicDatasourceTypeSet = new HashSet<>(); + private static final String ASSERT_MSG = "defaultTargetDatasource can't be null."; + + @Override + protected Object determineCurrentLookupKey() { + return DataSourceContextHolder.getDataSourceType(); + } + + /** + * 重新加载动态添加的数据源。既清空之前动态添加的数据源,同时添加参数中的新数据源列表。 + * + * @param dataSourceInfoList 新动态数据源列表。 + */ + public synchronized void reloadAll(List dataSourceInfoList) { + Map dataSourceMap = new HashMap<>(this.getResolvedDataSources()); + dynamicDatasourceTypeSet.forEach(dataSourceMap::remove); + dynamicDatasourceTypeSet.clear(); + for (DataSourceInfo dataSourceInfo : dataSourceInfoList) { + dynamicDatasourceTypeSet.add(dataSourceInfo.getDatasourceType()); + DruidDataSource dataSource = this.doConvert(dataSourceInfo); + baseMultiDataSourceConfig.applyCommonProps(dataSource); + dataSourceMap.put(dataSourceInfo.getDatasourceType(), dataSource); + } + Object defaultTargetDatasource = this.getResolvedDefaultDataSource(); + Assert.notNull(defaultTargetDatasource, ASSERT_MSG); + this.setTargetDataSources(dataSourceMap); + this.setDefaultTargetDataSource(defaultTargetDatasource); + super.afterPropertiesSet(); + } + + /** + * 添加动态添加数据源。 + * + * 动态添加数据源。 + */ + public synchronized void addDataSource(DataSourceInfo dataSourceInfo) { + if (dynamicDatasourceTypeSet.contains(dataSourceInfo.getDatasourceType())) { + return; + } + dynamicDatasourceTypeSet.add(dataSourceInfo.getDatasourceType()); + Map dataSourceMap = new HashMap<>(this.getResolvedDataSources()); + DruidDataSource dataSource = this.doConvert(dataSourceInfo); + baseMultiDataSourceConfig.applyCommonProps(dataSource); + dataSourceMap.put(dataSourceInfo.getDatasourceType(), dataSource); + Object defaultTargetDatasource = this.getResolvedDefaultDataSource(); + Assert.notNull(defaultTargetDatasource, ASSERT_MSG); + this.setTargetDataSources(dataSourceMap); + this.setDefaultTargetDataSource(defaultTargetDatasource); + super.afterPropertiesSet(); + } + + /** + * 添加动态添加数据源列表。 + * + * @param dataSourceInfoList 数据源信息列表。 + */ + public synchronized void addDataSources(List dataSourceInfoList) { + Map dataSourceMap = new HashMap<>(this.getResolvedDataSources()); + for (DataSourceInfo dataSourceInfo : dataSourceInfoList) { + if (!dynamicDatasourceTypeSet.contains(dataSourceInfo.getDatasourceType())) { + dynamicDatasourceTypeSet.add(dataSourceInfo.getDatasourceType()); + DruidDataSource dataSource = this.doConvert(dataSourceInfo); + baseMultiDataSourceConfig.applyCommonProps(dataSource); + dataSourceMap.put(dataSourceInfo.getDatasourceType(), dataSource); + } + } + Object defaultTargetDatasource = this.getResolvedDefaultDataSource(); + Assert.notNull(defaultTargetDatasource, ASSERT_MSG); + this.setTargetDataSources(dataSourceMap); + this.setDefaultTargetDataSource(defaultTargetDatasource); + super.afterPropertiesSet(); + } + + /** + * 动态移除数据源。 + * + * @param datasourceType 数据源类型。 + */ + public synchronized void removeDataSource(int datasourceType) { + if (!dynamicDatasourceTypeSet.remove(datasourceType)) { + return; + } + Map dataSourceMap = new HashMap<>(this.getResolvedDataSources()); + dataSourceMap.remove(datasourceType); + Object defaultTargetDatasource = this.getResolvedDefaultDataSource(); + Assert.notNull(defaultTargetDatasource, ASSERT_MSG); + this.setTargetDataSources(dataSourceMap); + this.setDefaultTargetDataSource(defaultTargetDatasource); + super.afterPropertiesSet(); + } + + private DruidDataSource doConvert(DataSourceInfo dataSourceInfo) { + DruidDataSource dataSource = DruidDataSourceBuilder.create().build(); + dataSource.setUsername(dataSourceInfo.getUsername()); + dataSource.setPassword(dataSourceInfo.getPassword()); + StringBuilder urlBuilder = new StringBuilder(256); + String hostAndPort = dataSourceInfo.getDatabaseHost() + ":" + dataSourceInfo.getPort(); + if (properties.isMySql()) { + urlBuilder.append("jdbc:mysql://") + .append(hostAndPort) + .append("/") + .append(dataSourceInfo.getDatabaseName()) + .append("?characterEncoding=utf8&useSSL=true&serverTimezone=Asia/Shanghai"); + } else if (properties.isOracle()) { + urlBuilder.append("jdbc:oracle:thin:@") + .append(hostAndPort) + .append(":") + .append(dataSourceInfo.getDatabaseName()); + } else if (properties.isPostgresql()) { + urlBuilder.append("jdbc:postgresql://") + .append(hostAndPort) + .append("/") + .append(dataSourceInfo.getDatabaseName()); + if (StrUtil.isBlank(dataSourceInfo.getSchemaName())) { + urlBuilder.append("?currentSchema=public"); + } else { + urlBuilder.append("?currentSchema=").append(dataSourceInfo.getSchemaName()); + } + urlBuilder.append("&TimeZone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8"); + } else if (properties.isDm()) { + urlBuilder.append("jdbc:dm://") + .append(hostAndPort) + .append("?schema=") + .append(dataSourceInfo.getDatabaseName()) + .append("&useJDBCCompliantTimezoneShift=true&serverTimezone=Asia/Shanghai&useSSL=true&characterEncoding=UTF-8"); + } else if (properties.isKingbase()) { + urlBuilder.append("jdbc:kingbase8://") + .append(hostAndPort) + .append("/") + .append(dataSourceInfo.getDatabaseName()) + .append("?useJDBCCompliantTimezoneShift=true&serverTimezone=Asia/Shanghai&useSSL=true&characterEncoding=UTF-8"); + } else if (properties.isOpenGauss()) { + urlBuilder.append("jdbc:opengauss://") + .append(hostAndPort) + .append("/") + .append(dataSourceInfo.getDatabaseName()); + if (StrUtil.isBlank(dataSourceInfo.getSchemaName())) { + urlBuilder.append("?currentSchema=public"); + } else { + urlBuilder.append("?currentSchema=").append(dataSourceInfo.getSchemaName()); + } + } + dataSource.setUrl(urlBuilder.toString()); + return dataSource; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/EncryptConfig.java b/common/common-core/src/main/java/apelet/common/core/config/EncryptConfig.java new file mode 100644 index 0000000..764d8eb --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/EncryptConfig.java @@ -0,0 +1,20 @@ +package apelet.common.core.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * 目前用于用户密码加密,UAA接入应用客户端的client_secret加密。 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +public class EncryptConfig { + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/FeignConfig.java b/common/common-core/src/main/java/apelet/common/core/config/FeignConfig.java new file mode 100644 index 0000000..77e3aa7 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/FeignConfig.java @@ -0,0 +1,69 @@ +package apelet.common.core.config; + +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.ContextUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import feign.RequestInterceptor; +import feign.RequestTemplate; +import lombok.SneakyThrows; +import org.springframework.context.annotation.Configuration; + +import javax.servlet.http.HttpServletRequest; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * FeignClient的配置对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +public class FeignConfig implements RequestInterceptor { + + @SneakyThrows + @Override + public void apply(RequestTemplate requestTemplate) { + // 对于非servlet请求发起的远程调用,由于无法获取到标识用户身份的TokenData,因此需要略过下面的HEADER注入。 + // 如:由消息队列consumer发起的远程调用请求。 + if (!ContextUtil.hasRequestContext()) { + return; + } + HttpServletRequest request = ContextUtil.getHttpRequest(); + String tokenData = request.getHeader(TokenData.REQUEST_ATTRIBUTE_NAME); + if (StrUtil.isBlank(tokenData)) { + Object token = request.getAttribute(TokenData.REQUEST_ATTRIBUTE_NAME); + if (token != null) { + tokenData = JSON.toJSONString(token); + } + } + if (StrUtil.isNotBlank(tokenData)) { + String encodedTokenData = tokenData; + if (StrUtil.isBlank(request.getHeader(TokenData.REQUEST_ENCODED_TOKEN))) { + encodedTokenData = URLEncoder.encode(tokenData, StandardCharsets.UTF_8.name()); + } + requestTemplate.header(TokenData.REQUEST_ATTRIBUTE_NAME, encodedTokenData); + requestTemplate.header(TokenData.REQUEST_ENCODED_TOKEN, Boolean.TRUE.toString()); + } + String traceId = request.getHeader(ApplicationConstant.HTTP_HEADER_TRACE_ID); + if (StrUtil.isBlank(traceId)) { + traceId = (String) request.getAttribute(ApplicationConstant.HTTP_HEADER_TRACE_ID); + } + if (StrUtil.isNotBlank(traceId)) { + requestTemplate.header(ApplicationConstant.HTTP_HEADER_TRACE_ID, traceId); + } + String originalUrl = request.getHeader(ApplicationConstant.HTTP_HEADER_ORIGINAL_REQUEST_URL); + if (StrUtil.isNotBlank(originalUrl)) { + requestTemplate.header(ApplicationConstant.HTTP_HEADER_ORIGINAL_REQUEST_URL, originalUrl); + } + String menuId = request.getHeader(ApplicationConstant.HTTP_HEADER_MENU_ID); + if (menuId == null) { + menuId = request.getParameter(ApplicationConstant.HTTP_HEADER_MENU_ID); + } + if (StrUtil.isNotBlank(menuId)) { + requestTemplate.header(ApplicationConstant.HTTP_HEADER_MENU_ID, menuId); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/MybatisPlusKeyGeneratorConfig.java b/common/common-core/src/main/java/apelet/common/core/config/MybatisPlusKeyGeneratorConfig.java new file mode 100644 index 0000000..b56c06e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/MybatisPlusKeyGeneratorConfig.java @@ -0,0 +1,21 @@ +package apelet.common.core.config; + +import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator; +import com.baomidou.mybatisplus.extension.incrementer.OracleKeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 仅仅用于Oracle,基于Sequence计算自增字段值的生成器。 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +public class MybatisPlusKeyGeneratorConfig { + + @Bean + public IKeyGenerator keyGenerator() { + return new OracleKeyGenerator(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/RestTemplateConfig.java b/common/common-core/src/main/java/apelet/common/core/config/RestTemplateConfig.java new file mode 100644 index 0000000..b6c86e1 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/RestTemplateConfig.java @@ -0,0 +1,64 @@ +package apelet.common.core.config; + +import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter; +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * RestTemplate连接池配置对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +public class RestTemplateConfig { + private static final int MAX_TOTAL_CONNECTION = 50; + private static final int MAX_CONNECTION_PER_ROUTE = 20; + private static final int CONNECTION_TIMEOUT = 20000; + private static final int READ_TIMEOUT = 30000; + + @Bean + @ConditionalOnMissingBean({RestOperations.class, RestTemplate.class}) + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(createFactory()); + List> messageConverters = restTemplate.getMessageConverters(); + messageConverters.removeIf( + c -> c instanceof StringHttpMessageConverter || c instanceof MappingJackson2HttpMessageConverter); + messageConverters.add(1, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + messageConverters.add(new FastJsonHttpMessageConverter()); + restTemplate.setErrorHandler(new DefaultResponseErrorHandler() { + @Override + public void handleError(ClientHttpResponse response) throws IOException { + // 防止400+和500等错误被直接抛出异常,这里避开了缺省处理方式,所有的错误均交给业务代码处理。 + } + }); + return restTemplate; + } + + private ClientHttpRequestFactory createFactory() { + HttpClient httpClient = HttpClientBuilder.create() + .setMaxConnTotal(MAX_TOTAL_CONNECTION) + .setMaxConnPerRoute(MAX_CONNECTION_PER_ROUTE) + .build(); + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient); + factory.setReadTimeout(READ_TIMEOUT); + factory.setConnectTimeout(CONNECTION_TIMEOUT); + return factory; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/config/TomcatConfig.java b/common/common-core/src/main/java/apelet/common/core/config/TomcatConfig.java new file mode 100644 index 0000000..22751eb --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/config/TomcatConfig.java @@ -0,0 +1,39 @@ +package apelet.common.core.config; + +import org.apache.tomcat.util.descriptor.web.SecurityCollection; +import org.apache.tomcat.util.descriptor.web.SecurityConstraint; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * tomcat配置对象。当前配置禁用了PUT和DELETE方法,防止渗透攻击。 + * + * @author guifc + * @date 2023-08-04 + */ +@Configuration +public class TomcatConfig { + + @Bean + public TomcatServletWebServerFactory servletContainer() { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(); + factory.addContextCustomizers(context -> { + SecurityConstraint securityConstraint = new SecurityConstraint(); + securityConstraint.setUserConstraint("CONFIDENTIAL"); + SecurityCollection collection = new SecurityCollection(); + collection.addPattern("/*"); + collection.addMethod("HEAD"); + collection.addMethod("PUT"); + collection.addMethod("PATCH"); + collection.addMethod("DELETE"); + collection.addMethod("TRACE"); + collection.addMethod("COPY"); + collection.addMethod("SEARCH"); + collection.addMethod("PROPFIND"); + securityConstraint.addCollection(collection); + context.addConstraint(securityConstraint); + }); + return factory; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/AggregationKind.java b/common/common-core/src/main/java/apelet/common/core/constant/AggregationKind.java new file mode 100644 index 0000000..9b56071 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/AggregationKind.java @@ -0,0 +1,36 @@ +package apelet.common.core.constant; + +/** + * 在跨服务调用时,需要传递的聚合分类常量对象。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class AggregationKind { + + /** + * 一对多关联中,聚合从表中指定字段,将多条记录汇总到主表的指定字段上。 + */ + public static final int ONE_TO_MANY = 1; + /** + * 多对多关联中,聚合从表中指定字段,将多条记录汇总到主表的指定字段上。 + */ + public static final int MANY_TO_MANY = 2; + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param aggregationKind 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer aggregationKind) { + return aggregationKind != null && + (aggregationKind == ONE_TO_MANY || aggregationKind == MANY_TO_MANY); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private AggregationKind() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/AggregationType.java b/common/common-core/src/main/java/apelet/common/core/constant/AggregationType.java new file mode 100644 index 0000000..9766d40 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/AggregationType.java @@ -0,0 +1,81 @@ +package apelet.common.core.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 聚合计算的常量类型对象。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class AggregationType { + + /** + * sum 计数 + */ + public static final int SUM = 0; + /** + * count 汇总 + */ + public static final int COUNT = 1; + /** + * average 平均值 + */ + public static final int AVG = 2; + /** + * min 最小值 + */ + public static final int MIN = 3; + /** + * max 最大值 + */ + public static final int MAX = 4; + + private static final Map DICT_MAP = new HashMap<>(5); + static { + DICT_MAP.put(SUM, "累计总和"); + DICT_MAP.put(COUNT, "数量总和"); + DICT_MAP.put(AVG, "平均值"); + DICT_MAP.put(MIN, "最小值"); + DICT_MAP.put(MAX, "最大值"); + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 获取与SQL对应的聚合函数字符串名称。 + * + * @return 聚合函数名称。 + */ + public static String getAggregationFunction(Integer aggregationType) { + switch (aggregationType) { + case COUNT: + return "COUNT"; + case AVG: + return "AVG"; + case SUM: + return "SUM"; + case MAX: + return "MAX"; + case MIN: + return "MIN"; + default: + throw new IllegalArgumentException("无效的聚合类型!"); + } + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private AggregationType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/AppDeviceType.java b/common/common-core/src/main/java/apelet/common/core/constant/AppDeviceType.java new file mode 100644 index 0000000..e7655a4 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/AppDeviceType.java @@ -0,0 +1,68 @@ +package apelet.common.core.constant; + +import apelet.common.core.object.TokenData; + +import java.util.HashMap; +import java.util.Map; + +/** + * App 登录的设备类型。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class AppDeviceType { + + /** + * 移动端 (如果不考虑区分android或ios的,可以使用该值) + */ + public static final int MOBILE = 0; + /** + * android + */ + public static final int ANDROID = 1; + /** + * iOS + */ + public static final int IOS = 2; + /** + * 微信公众号和小程序 + */ + public static final int WEIXIN = 3; + /** + * PC WEB + */ + public static final int WEB = 4; + + private static final Map DICT_MAP = new HashMap<>(5); + static { + DICT_MAP.put(MOBILE, "移动端"); + DICT_MAP.put(ANDROID, "Android"); + DICT_MAP.put(IOS, "iOS"); + DICT_MAP.put(WEIXIN, "微信"); + DICT_MAP.put(WEB, "PC WEB"); + } + + public static String getDeviceType(){ + if(TokenData.takeFromRequest().getDeviceType() == 4){ + return "pc"; + } + return "mobile"; + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private AppDeviceType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/ApplicationConstant.java b/common/common-core/src/main/java/apelet/common/core/constant/ApplicationConstant.java new file mode 100644 index 0000000..9f255fd --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/ApplicationConstant.java @@ -0,0 +1,153 @@ +package apelet.common.core.constant; + +import java.util.regex.Pattern; + +/** + * 应用程序的常量声明对象。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class ApplicationConstant { + + /** + * 适用于所有类型的字典格式数据。该常量为字典的键字段。 + */ + public static final String DICT_ID = "id"; + /** + * 适用于所有类型的字典格式数据。该常量为字典的名称字段。 + */ + public static final String DICT_NAME = "name"; + /** + * 适用于所有类型的字典格式数据。该常量为字典的键父字段。 + */ + public static final String PARENT_ID = "parentId"; + /** + * 数据同步使用的缺省消息队列主题名称。 + */ + public static final String DEFAULT_DATA_SYNC_TOPIC = "apelet_saas"; + /** + * 全量数据同步中,新增数据对象的键名称。 + */ + public static final String DEFAULT_FULL_SYNC_DATA_KEY = "data"; + /** + * 全量数据同步中,原有数据对象的键名称。 + */ + public static final String DEFAULT_FULL_SYNC_OLD_DATA_KEY = "oldData"; + /** + * 全量数据同步中,数据对象主键的键名称。 + */ + public static final String DEFAULT_FULL_SYNC_ID_KEY = "id"; + /** + * 为字典表数据缓存时,缓存名称的固定后缀。 + */ + public static final String DICT_CACHE_NAME_SUFFIX = "-DICT"; + /** + * 为树形字典表数据缓存时,缓存名称的固定后缀。 + */ + public static final String TREE_DICT_CACHE_NAME_SUFFIX = "-TREE-DICT"; + /** + * 图片文件上传的父目录。 + */ + public static final String UPLOAD_IMAGE_PARENT_PATH = "image"; + /** + * 附件文件上传的父目录。 + */ + public static final String UPLOAD_ATTACHMENT_PARENT_PATH = "attachment"; + /** + * CSV文件扩展名。 + */ + public static final String CSV_EXT = "csv"; + /** + * XLSX文件扩展名。 + */ + public static final String XLSX_EXT = "xlsx"; + /** + * 统计分类计算时,按天聚合计算的常量值。(前端在MyOrderParam和MyGroupParam中传给后台) + */ + public static final String DAY_AGGREGATION = "day"; + /** + * 统计分类计算时,按月聚合计算的常量值。(前端在MyOrderParam和MyGroupParam中传给后台) + */ + public static final String MONTH_AGGREGATION = "month"; + /** + * 统计分类计算时,按年聚合计算的常量值。(前端在MyOrderParam和MyGroupParam中传给后台) + */ + public static final String YEAR_AGGREGATION = "year"; + /** + * 请求头跟踪id名。 + */ + public static final String HTTP_HEADER_TRACE_ID = "traceId"; + /** + * 请求头菜单Id。 + */ + public static final String HTTP_HEADER_MENU_ID = "MenuId"; + /** + * 数据权限中,标记所有菜单的Id值。 + */ + public static final String DATA_PERM_ALL_MENU_ID = "AllMenuId"; + /** + * 请求头中记录的原始请求URL。 + */ + public static final String HTTP_HEADER_ORIGINAL_REQUEST_URL = "MY_ORIGINAL_REQUEST_URL"; + /** + * 系统服务内部调用时,可使用该HEAD,以便和外部调用加以区分,便于监控和流量分析。 + */ + public static final String HTTP_HEADER_INTERNAL_TOKEN = "INTERNAL_AUTH_TOKEN"; + /** + * 操作日志的数据源类型。 + */ + public static final int OPERATION_LOG_DATASOURCE_TYPE = 1000; + /** + * 工作流和在线表单的数据源类型。 + */ + public static final int COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE = 1010; + /** + * 报表模块的数据源类型。 + */ + public static final int COMMON_REPORT_DATASOURCE_TYPE = 1020; + /** + * 全局编码字典的数据源类型。 + */ + public static final int COMMON_GLOBAL_DICT_TYPE = 1050; + /** + * 租户管理所对应的数据源常量值。 + */ + public static final int TENANT_ADMIN_DATASOURCE_TYPE = 1100; + /** + * 租户业务默认数据库(系统搭建时的第一个租户数据库)所对应的数据源常量值。 + */ + public static final int TENANT_BUSINESS_DATASOURCE_TYPE = 1120; + /** + * 租户通用数据所对应的数据源常量值,如全局编码字典、在线表单、流程和报表等内置表数据。 + */ + public static final int TENANT_COMMON_DATASOURCE_TYPE = 1130; + /** + * 租户动态数据源主题(Redis)。 + */ + public static final String TENANT_DYNAMIC_DATASOURCE_TOPIC = "TenantDynamicDatasoruce"; + /** + * 租户基础数据同步(RocketMQ),如upms、全局编码字典、在线表单、流程、报表等。 + */ + public static final String TENANT_DATASYNC_TOPIC = "TenantSync"; + /** + * 重要说明:该值为项目生成后的缺省密钥,仅为使用户可以快速上手并跑通流程。 + * 在实际的应用中,一定要为不同的项目或服务,自行生成公钥和私钥,并将 PRIVATE_KEY 的引用改为服务的配置项。 + * 密钥的生成方式,可通过执行common.core.util.RsaUtil类的main函数动态生成。 + */ + public static final String PRIVATE_KEY = + "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKkLhAydtOtA4WuIkkIIUVaGWu4ElOEAQF9GTulHHWOwCHI1UvcKolvS1G+mdsKcmGtEAQ92AUde/kDRGu8Wn7kLDtCgUfo72soHz7Qfv5pVB4ohMxQd/9cxeKjKbDoirhB9Z3xGF20zUozp4ZPLxpTtI7azr0xzUtd5+D/HfLDrAgMBAAECgYEApESZhDz4YyeAJiPnpJ06lS8oS2VOWzsIUs0av5uoloeoHXtt7Lx7u2kroHeNrl3Hy2yg7ypH4dgQkGHin3VHrVAgjG3TxhgBXIqqntzzk2AGJKBeIIkRX86uTvtKZyp3flUgcwcGmpepAHS1V1DPY3aVYvbcqAmoL6DX6VYN0NECQQDQUitMdC76lEtAr5/ywS0nrZJDo6U7eQ7ywx/eiJ+YmrSye8oorlAj1VBWG+Cl6jdHOHtTQyYv/tu71fjzQiJTAkEAz7wb47/vcSUpNWQxItFpXz0o6rbJh71xmShn1AKP7XptOVZGlW9QRYEzHabV9m/DHqI00cMGhHrWZAhCiTkUCQJAFsJjaJ7o4weAkTieyO7B+CvGZw1h5/V55Jvcx3s1tH5yb22G0Jr6tm9/r2isSnQkReutzZLwgR3e886UvD7lcQJAAUcD2OOuQkDbPwPNtYwaHMbQgJj9JkOI9kskUE5vuiMdltOr/XFAyhygRtdmy2wmhAK1VnDfkmL6/IR8fEGImQJABOB0KCalb0M8CPnqqHzozrD8gPObnIIr4aVvLIPATN2g7MM2N6F7JbI4RZFiKa92LV6bhQCY8OvHi5K2cgFpbw=="; + /** + * SQL注入检测的正则对象。 + */ + @SuppressWarnings("all") + public static final Pattern SQL_INJECT_PATTERN = + Pattern.compile("(.*\\=.*\\-\\-.*)|(.*(\\+).*)|(.*\\w+(%|\\$|#|&)\\w+.*)|(.*\\|\\|.*)|(.*\\s+(and|or)\\s+.*)" + + "|(.*\\b(select|update|union|and|or|delete|insert|trancate|char|substr|ascii|declare|exec|count|master|into|drop|execute|sleep|extractvalue|updatexml|substring|database|concat|rand)\\b.*)"); + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private ApplicationConstant() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/BooleanEnum.java b/common/common-core/src/main/java/apelet/common/core/constant/BooleanEnum.java new file mode 100644 index 0000000..3354969 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/BooleanEnum.java @@ -0,0 +1,40 @@ +package apelet.common.core.constant; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * boolean 枚举 + */ +@Getter +public enum BooleanEnum { + + OK("true", "是"), + NO("false", "否"), + ONE("1", "true"), + TOW("0", "false"), + ; + + private final String type; + private final String name; + + BooleanEnum(String type, String name) { + this.type = type; + this.name = name; + } + + private static Map ENUM_MAP; + + static { + ENUM_MAP = Arrays.stream(values()).collect(Collectors.toMap(BooleanEnum :: getType, e -> e)); + } + + public static BooleanEnum of(String type) { + return Optional.ofNullable(ENUM_MAP.get(type)).orElseThrow(() -> new IllegalArgumentException("Could not get cube auth type by type " + type)); + } + +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/DataPermRuleType.java b/common/common-core/src/main/java/apelet/common/core/constant/DataPermRuleType.java new file mode 100644 index 0000000..b62c7c0 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/DataPermRuleType.java @@ -0,0 +1,81 @@ +package apelet.common.core.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 数据权限规则类型常量类。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class DataPermRuleType { + + /** + * 查看全部。 + */ + public static final int TYPE_ALL = 0; + + /** + * 仅查看当前用户。 + */ + public static final int TYPE_USER_ONLY = 1; + + /** + * 仅查看当前部门。 + */ + public static final int TYPE_DEPT_ONLY = 2; + + /** + * 所在部门及子部门。 + */ + public static final int TYPE_DEPT_AND_CHILD_DEPT = 3; + + /** + * 多部门及子部门。 + */ + public static final int TYPE_MULTI_DEPT_AND_CHILD_DEPT = 4; + + /** + * 自定义部门列表。 + */ + public static final int TYPE_CUSTOM_DEPT_LIST = 5; + + /** + * 本部门所有用户。 + */ + public static final int TYPE_DEPT_USERS = 6; + + /** + * 本部门及子部门所有用户。 + */ + public static final int TYPE_DEPT_AND_CHILD_DEPT_USERS = 7; + + private static final Map DICT_MAP = new HashMap<>(6); + static { + DICT_MAP.put(TYPE_ALL, "查看全部"); + DICT_MAP.put(TYPE_USER_ONLY, "仅查看当前用户"); + DICT_MAP.put(TYPE_DEPT_ONLY, "仅查看所在部门"); + DICT_MAP.put(TYPE_DEPT_AND_CHILD_DEPT, "所在部门及子部门"); + DICT_MAP.put(TYPE_MULTI_DEPT_AND_CHILD_DEPT, "多部门及子部门"); + DICT_MAP.put(TYPE_CUSTOM_DEPT_LIST, "自定义部门列表"); + DICT_MAP.put(TYPE_DEPT_USERS, "本部门所有用户"); + DICT_MAP.put(TYPE_DEPT_AND_CHILD_DEPT_USERS, "本部门及子部门所有用户"); + } + + /** + * 判断参数是否为当前常量字典的合法取值范围。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private DataPermRuleType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/DictType.java b/common/common-core/src/main/java/apelet/common/core/constant/DictType.java new file mode 100644 index 0000000..d51bd75 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/DictType.java @@ -0,0 +1,64 @@ +package apelet.common.core.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 字典类型常量字典对象。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class DictType { + + /** + * 数据表字典。 + */ + public static final int TABLE = 1; + /** + * URL字典。 + */ + public static final int URL = 5; + /** + * 常量字典。 + */ + public static final int CONST = 10; + /** + * 自定义字典。 + */ + public static final int CUSTOM = 15; + /** + * 全局编码字典。 + */ + public static final int GLOBAL_DICT = 20; + /** + * EAS编码字典。 + */ + public static final int EAS_DICT = 25; + + private static final Map DICT_MAP = new HashMap<>(2); + static { + DICT_MAP.put(TABLE, "数据表字典"); + DICT_MAP.put(URL, "URL字典"); + DICT_MAP.put(CONST, "静态字典"); + DICT_MAP.put(CUSTOM, "自定义字典"); + DICT_MAP.put(GLOBAL_DICT, "全局编码字典"); + DICT_MAP.put(EAS_DICT, "EAS字典"); + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private DictType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/ErrorCodeEnum.java b/common/common-core/src/main/java/apelet/common/core/constant/ErrorCodeEnum.java new file mode 100644 index 0000000..d3fef5a --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/ErrorCodeEnum.java @@ -0,0 +1,92 @@ +package apelet.common.core.constant; + +/** + * 返回应答中的错误代码和错误信息。 + * + * @author guifc + * @date 2023-08-04 + */ +public enum ErrorCodeEnum { + + /** + * 没有错误 + */ + NO_ERROR("没有错误"), + /** + * 未处理的异常! + */ + UNHANDLED_EXCEPTION("未处理的异常!"), + + ARGUMENT_NULL_EXIST("数据验证失败,接口调用参数存在空值,请核对!"), + ARGUMENT_PK_ID_NULL("数据验证失败,接口调用主键Id参数为空,请核对!"), + INVALID_ARGUMENT_FORMAT("数据验证失败,不合法的参数格式,请核对!"), + INVALID_STATUS_ARGUMENT("数据验证失败,无效的状态参数值,请核对!"), + UPLOAD_FAILED("数据验证失败,数据上传失败!"), + INVALID_UPLOAD_FIELD("数据验证失败,该字段不支持数据上传!"), + INVALID_UPLOAD_STORE_TYPE("数据验证失败,并不支持上传存储类型!"), + INVALID_UPLOAD_FILE_ARGUMENT("数据验证失败,上传文件参数错误,请核对!"), + INVALID_UPLOAD_FILE_FORMAT("无效的上传文件格式!"), + INVALID_UPLOAD_FILE_IOERROR("上传文件写入失败,请联系管理员!"), + UNAUTHORIZED_LOGIN("当前用户尚未登录或登录已超时,请重新登录!"), + UNAUTHORIZED_USER_PERMISSION("权限验证失败,当前用户不能访问该接口,请核对!"), + NO_ACCESS_PERMISSION("当前用户没有访问权限,请核对!"), + NO_OPERATION_PERMISSION("当前用户没有操作权限,请核对!"), + + PASSWORD_ERR("密码错误,请重试!"), + INVALID_USERNAME_PASSWORD("用户名或密码错误,请重试!"), + INVALID_ACCESS_TOKEN("无效的用户访问令牌!"), + INVALID_USER_STATUS("用户状态错误,请刷新后重试!"), + INVALID_TENANT_CODE("指定的租户编码并不存在,请刷新后重试!"), + INVALID_TENANT_STATUS("当前租户为不可用状态,请刷新后重试!"), + INVALID_USER_TENANT("当前用户并不属于当前租户,请刷新后重试!"), + + HAS_CHILDREN_DATA("数据验证失败,子数据存在,请刷新后重试!"), + DATA_VALIDATED_FAILED("数据验证失败,请核对!"), + UPLOAD_FILE_FAILED("文件上传失败,请联系管理员!"), + DATA_SAVE_FAILED("数据保存失败,请联系管理员!"), + DATA_ACCESS_FAILED("数据访问失败,请联系管理员!"), + DATA_PERM_ACCESS_FAILED("数据访问失败,您没有该页面的数据访问权限!"), + DUPLICATED_UNIQUE_KEY("数据保存失败,存在重复数据,请核对!"), + DATA_NOT_EXIST("数据不存在,请刷新后重试!"), + DATA_PARENT_LEVEL_ID_NOT_EXIST("数据验证失败,父级别关联Id不存在,请刷新后重试!"), + DATA_PARENT_ID_NOT_EXIST("数据验证失败,ParentId不存在,请核对!"), + RPC_DATA_ACCESS_FAILED("远程调用数据访问失败,请联系管理员!"), + INVALID_RELATED_RECORD_ID("数据验证失败,关联数据并不存在,请刷新后重试!"), + INVALID_DATA_MODEL("数据验证失败,无效的数据实体对象!"), + INVALID_DATA_FIELD("数据验证失败,无效的数据实体对象字段!"), + INVALID_CLASS_FIELD("数据验证失败,无效的类对象字段!"), + SERVER_INTERNAL_ERROR("服务器内部错误,请联系管理员!"), + REDIS_CACHE_ACCESS_TIMEOUT("Redis缓存数据访问超时,请刷新后重试!"), + REDIS_CACHE_ACCESS_STATE_ERROR("Redis缓存数据访问状态错误,请刷新后重试!"), + FAILED_TO_INVOKE_THIRDPARTY_URL("调用第三方接口失败!"), + MESSAGE_SEND_FAIL("消息发送失败!"), + TRANSACTION_MESSAGE_LOCAL_STATUS_ROLLBACK("本地数据操作失败,请联系管理员!"), + TRANSACTION_MESSAGE_LOCAL_STATUS_UNKNOW("本地数据操作结果未知,请刷新后重试!"), + + FLOW_WORK_ORDER_EXIST("该业务数据Id存在尚未完成审批的流程实例,同一业务数据主键不能同时重复提交审批!"); + + // 下面的枚举值为特定枚举值,即开发者可以根据自己的项目需求定义更多的非通用枚举值 + + /** + * 构造函数。 + * + * @param errorMessage 错误消息。 + */ + ErrorCodeEnum(String errorMessage) { + this.errorMessage = errorMessage; + } + + /** + * 错误信息。 + */ + private final String errorMessage; + + /** + * 获取错误信息。 + * + * @return 错误信息。 + */ + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/FieldFilterType.java b/common/common-core/src/main/java/apelet/common/core/constant/FieldFilterType.java new file mode 100644 index 0000000..50913f4 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/FieldFilterType.java @@ -0,0 +1,127 @@ +package apelet.common.core.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 字段过滤类型常量字典对象。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class FieldFilterType { + /** + * 等于过滤。 + */ + public static final int EQUAL = 0; + /** + * 不等于过滤。 + */ + public static final int NOT_EQUAL = 1; + /** + * 大于等于。 + */ + public static final int GE = 2; + /** + * 大于。 + */ + public static final int GT = 3; + /** + * 小于等于。 + */ + public static final int LE = 4; + /** + * 小于。 + */ + public static final int LT = 5; + /** + * 模糊查询。 + */ + public static final int LIKE = 6; + /** + * IN列表过滤。 + */ + public static final int IN = 7; + /** + * NOT IN列表过滤。 + */ + public static final int NOT_IN = 8; + /** + * 范围过滤。 + */ + public static final int BETWEEN = 9; + /** + * 不为空。 + */ + public static final int IS_NOT_NULL = 100; + /** + * 为空。 + */ + public static final int IS_NULL = 101; + + private static final Map DICT_MAP = new HashMap<>(9); + static { + DICT_MAP.put(EQUAL, " = "); + DICT_MAP.put(NOT_EQUAL, " <> "); + DICT_MAP.put(GE, " >= "); + DICT_MAP.put(GT, " > "); + DICT_MAP.put(LE, " <= "); + DICT_MAP.put(LT, " < "); + DICT_MAP.put(LIKE, " LIKE "); + DICT_MAP.put(IN, " IN "); + DICT_MAP.put(NOT_IN, " NOT IN "); + DICT_MAP.put(BETWEEN, " BETWEEN "); + DICT_MAP.put(IS_NOT_NULL, " IS NOT NULL "); + DICT_MAP.put(IS_NULL, " IS NULL "); + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 获取显示名。 + * @param value 常量值。 + * @return 常量值对应的显示名。 + */ + public static String getName(Integer value) { + return DICT_MAP.get(value); + } + + /** + * 不支持日期型字段的过滤类型。 + * + * @param filterType 过滤类型。 + * @return 不支持返回true,否则false。 + */ + public static boolean unsupportDateFilterType(int filterType) { + return filterType == FieldFilterType.IN + || filterType == FieldFilterType.NOT_IN + || filterType == FieldFilterType.NOT_EQUAL + || filterType == FieldFilterType.LIKE; + } + + /** + * 支持多过滤值的过滤类型。 + * + * @param filterType 过滤类型。 + * @return 支持返回true,否则false。 + */ + public static boolean supportMultiValueFilterType(int filterType) { + return filterType == FieldFilterType.IN + || filterType == FieldFilterType.NOT_IN + || filterType == FieldFilterType.BETWEEN; + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private FieldFilterType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/FilterParamType.java b/common/common-core/src/main/java/apelet/common/core/constant/FilterParamType.java new file mode 100644 index 0000000..9d0eb29 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/FilterParamType.java @@ -0,0 +1,54 @@ +package apelet.common.core.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 字段过滤参数类型常量字典对象。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class FilterParamType { + + /** + * 整数数值型。 + */ + public static final int LONG = 0; + /** + * 浮点型。 + */ + public static final int FLOAT = 1; + /** + * 字符型。 + */ + public static final int STRING = 2; + /** + * 日期型。 + */ + public static final int DATE = 3; + + private static final Map DICT_MAP = new HashMap<>(9); + static { + DICT_MAP.put(LONG, "整数数值型"); + DICT_MAP.put(FLOAT, "浮点型"); + DICT_MAP.put(STRING, "字符型"); + DICT_MAP.put(DATE, "日期型"); + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private FilterParamType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/GlobalDeletedFlag.java b/common/common-core/src/main/java/apelet/common/core/constant/GlobalDeletedFlag.java new file mode 100644 index 0000000..2cde258 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/GlobalDeletedFlag.java @@ -0,0 +1,25 @@ +package apelet.common.core.constant; + +/** + * 数据记录逻辑删除标记常量。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class GlobalDeletedFlag { + + /** + * 表示数据表记录已经删除 + */ + public static final int DELETED = -1; + /** + * 数据记录正常 + */ + public static final int NORMAL = 1; + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private GlobalDeletedFlag() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/MaskFieldTypeEnum.java b/common/common-core/src/main/java/apelet/common/core/constant/MaskFieldTypeEnum.java new file mode 100644 index 0000000..b13fe96 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/MaskFieldTypeEnum.java @@ -0,0 +1,47 @@ +package apelet.common.core.constant; + +/** + * 字段脱敏类型枚举。。 + * + * @author guifc + * @date 2023-08-04 + */ +public enum MaskFieldTypeEnum { + + /** + * 自定义实现。 + */ + CUSTOM, + /** + * 姓名。 + */ + NAME, + /** + * 移动电话。 + */ + MOBILE_PHONE, + /** + * 座机电话。 + */ + FIXED_PHONE, + /** + * 身份证。 + */ + ID_CARD, + /** + * 银行卡号。 + */ + BANK_CARD, + /** + * 汽车牌照号。 + */ + CAR_LICENSE, + /** + * 邮件。 + */ + EMAIL, + /** + * 固定长度的前缀和后缀不被掩码。 + */ + NO_MASK_PREFIX_SUFFIX, +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/ObjectFieldType.java b/common/common-core/src/main/java/apelet/common/core/constant/ObjectFieldType.java new file mode 100644 index 0000000..66d3451 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/ObjectFieldType.java @@ -0,0 +1,26 @@ +package apelet.common.core.constant; + +/** + * 对应于数据表字段中的类型,我们需要统一映射到Java实体对象字段的类型。 + * 该类是描述Java实体对象字段类型的常量类。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class ObjectFieldType { + + public static final String LONG = "Long"; + public static final String INTEGER = "Integer"; + public static final String DOUBLE = "Double"; + public static final String BIG_DECIMAL = "BigDecimal"; + public static final String BOOLEAN = "Boolean"; + public static final String STRING = "String"; + public static final String DATE = "Date"; + public static final String BYTE_ARRAY = "byte[]"; + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private ObjectFieldType() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/constant/UserFilterGroup.java b/common/common-core/src/main/java/apelet/common/core/constant/UserFilterGroup.java new file mode 100644 index 0000000..57fc66f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/constant/UserFilterGroup.java @@ -0,0 +1,22 @@ +package apelet.common.core.constant; + +/** + * 用户分组过滤常量。 + * + * @author guifc + * @date 2023-08-04 + */ +public class UserFilterGroup { + + public static final String USER = "USER_GROUP"; + public static final String ROLE = "ROLE_GROUP"; + public static final String DEPT = "DEPT_GROUP"; + public static final String POST = "POST_GROUP"; + public static final String DEPT_POST = "DEPT_POST_GROUP"; + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private UserFilterGroup() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/DataValidationException.java b/common/common-core/src/main/java/apelet/common/core/exception/DataValidationException.java new file mode 100644 index 0000000..55a030b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/DataValidationException.java @@ -0,0 +1,26 @@ +package apelet.common.core.exception; + +/** + * 数据验证失败的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +public class DataValidationException extends RuntimeException { + + /** + * 构造函数。 + */ + public DataValidationException() { + + } + + /** + * 构造函数。 + * + * @param msg 错误信息。 + */ + public DataValidationException(String msg) { + super(msg); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/IllegalConversionException.java b/common/common-core/src/main/java/apelet/common/core/exception/IllegalConversionException.java new file mode 100644 index 0000000..a0fe3e7 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/IllegalConversionException.java @@ -0,0 +1,19 @@ +package apelet.common.core.exception; + +/** + * @author Soon + * @version 1.0 + * Create by 2024/4/10 16:11 + */ +public class IllegalConversionException extends RuntimeException{ + + public IllegalConversionException() + { + this("非法转换"); + } + + public IllegalConversionException(String s) + { + super("[" + s + "]"); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/InvalidClassFieldException.java b/common/common-core/src/main/java/apelet/common/core/exception/InvalidClassFieldException.java new file mode 100644 index 0000000..f4d838d --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/InvalidClassFieldException.java @@ -0,0 +1,30 @@ +package apelet.common.core.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 无效的类对象字段的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InvalidClassFieldException extends RuntimeException { + + private final String className; + private final String fieldName; + + /** + * 构造函数。 + * + * @param className 对象名。 + * @param fieldName 字段名。 + */ + public InvalidClassFieldException(String className, String fieldName) { + super("Invalid FieldName [" + fieldName + "] in Class [" + className + "]."); + this.className = className; + this.fieldName = fieldName; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/InvalidDataFieldException.java b/common/common-core/src/main/java/apelet/common/core/exception/InvalidDataFieldException.java new file mode 100644 index 0000000..163e700 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/InvalidDataFieldException.java @@ -0,0 +1,30 @@ +package apelet.common.core.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 无效的实体对象字段的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InvalidDataFieldException extends RuntimeException { + + private final String modelName; + private final String fieldName; + + /** + * 构造函数。 + * + * @param modelName 实体对象名。 + * @param fieldName 字段名。 + */ + public InvalidDataFieldException(String modelName, String fieldName) { + super("Invalid FieldName [" + fieldName + "] in Model Class [" + modelName + "]."); + this.modelName = modelName; + this.fieldName = fieldName; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/InvalidDataModelException.java b/common/common-core/src/main/java/apelet/common/core/exception/InvalidDataModelException.java new file mode 100644 index 0000000..9facadc --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/InvalidDataModelException.java @@ -0,0 +1,27 @@ +package apelet.common.core.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 无效的实体对象的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InvalidDataModelException extends RuntimeException { + + private final String modelName; + + /** + * 构造函数。 + * + * @param modelName 实体对象名。 + */ + public InvalidDataModelException(String modelName) { + super("Invalid Model Class [" + modelName + "]."); + this.modelName = modelName; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/InvalidDblinkTypeException.java b/common/common-core/src/main/java/apelet/common/core/exception/InvalidDblinkTypeException.java new file mode 100644 index 0000000..10f841c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/InvalidDblinkTypeException.java @@ -0,0 +1,24 @@ +package apelet.common.core.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 无效的数据库链接类型自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InvalidDblinkTypeException extends RuntimeException { + + /** + * 构造函数。 + * + * @param dblinkType 数据库链接类型。 + */ + public InvalidDblinkTypeException(int dblinkType) { + super("Invalid Dblink Type [" + dblinkType + "]."); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/InvalidRedisModeException.java b/common/common-core/src/main/java/apelet/common/core/exception/InvalidRedisModeException.java new file mode 100644 index 0000000..dc5ca25 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/InvalidRedisModeException.java @@ -0,0 +1,27 @@ +package apelet.common.core.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 无效的Redis模式的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class InvalidRedisModeException extends RuntimeException { + + private final String mode; + + /** + * 构造函数。 + * + * @param mode 错误的模式。 + */ + public InvalidRedisModeException(String mode) { + super("Invalid Redis Mode [" + mode + "], only supports [single/cluster/sentinel/master_slave]"); + this.mode = mode; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/MapCacheAccessException.java b/common/common-core/src/main/java/apelet/common/core/exception/MapCacheAccessException.java new file mode 100644 index 0000000..1043e4c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/MapCacheAccessException.java @@ -0,0 +1,20 @@ +package apelet.common.core.exception; + +/** + * 内存缓存访问失败。比如:获取分布式数据锁超时、等待线程中断等。 + * + * @author guifc + * @date 2023-08-04 + */ +public class MapCacheAccessException extends RuntimeException { + + /** + * 构造函数。 + * + * @param msg 错误信息。 + * @param cause 原始异常。 + */ + public MapCacheAccessException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/MyRuntimeException.java b/common/common-core/src/main/java/apelet/common/core/exception/MyRuntimeException.java new file mode 100644 index 0000000..2e40fa8 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/MyRuntimeException.java @@ -0,0 +1,46 @@ +package apelet.common.core.exception; + +/** + * 自定义的运行时异常,在需要抛出运行时异常时,可使用该异常。 + * NOTE:主要是为了避免SonarQube进行代码质量扫描时,给出警告。 + * + * @author guifc + * @date 2023-08-04 + */ +public class MyRuntimeException extends RuntimeException { + + /** + * 构造函数。 + */ + public MyRuntimeException() { + + } + + /** + * 构造函数。 + * + * @param throwable 引发异常对象。 + */ + public MyRuntimeException(Throwable throwable) { + super(throwable); + } + + /** + * 构造函数。 + * + * @param msg 错误信息。 + */ + public MyRuntimeException(String msg) { + super(msg); + } + + /** + * 构造函数。 + * + * @param msg 错误信息。 + * @param throwable 引发异常对象。 + */ + public MyRuntimeException(String msg, Throwable throwable) { + super(msg, throwable); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/NoDataAffectException.java b/common/common-core/src/main/java/apelet/common/core/exception/NoDataAffectException.java new file mode 100644 index 0000000..c56a082 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/NoDataAffectException.java @@ -0,0 +1,26 @@ +package apelet.common.core.exception; + +/** + * 没有数据被修改的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +public class NoDataAffectException extends RuntimeException { + + /** + * 构造函数。 + */ + public NoDataAffectException() { + + } + + /** + * 构造函数。 + * + * @param msg 错误信息。 + */ + public NoDataAffectException(String msg) { + super(msg); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/NoDataPermException.java b/common/common-core/src/main/java/apelet/common/core/exception/NoDataPermException.java new file mode 100644 index 0000000..8f2db03 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/NoDataPermException.java @@ -0,0 +1,26 @@ +package apelet.common.core.exception; + +/** + * 没有数据访问权限的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +public class NoDataPermException extends RuntimeException { + + /** + * 构造函数。 + */ + public NoDataPermException() { + + } + + /** + * 构造函数。 + * + * @param msg 错误信息。 + */ + public NoDataPermException(String msg) { + super(msg); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/RedisCacheAccessException.java b/common/common-core/src/main/java/apelet/common/core/exception/RedisCacheAccessException.java new file mode 100644 index 0000000..7b7f1c5 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/RedisCacheAccessException.java @@ -0,0 +1,20 @@ +package apelet.common.core.exception; + +/** + * Redis缓存访问失败。比如:获取分布式数据锁超时、等待线程中断等。 + * + * @author guifc + * @date 2023-08-04 + */ +public class RedisCacheAccessException extends RuntimeException { + + /** + * 构造函数。 + * + * @param msg 错误信息。 + * @param cause 原始异常。 + */ + public RedisCacheAccessException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/exception/RemoteDataBuildException.java b/common/common-core/src/main/java/apelet/common/core/exception/RemoteDataBuildException.java new file mode 100644 index 0000000..f6a9245 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/exception/RemoteDataBuildException.java @@ -0,0 +1,26 @@ +package apelet.common.core.exception; + +/** + * 关联远程服务数据失败的自定义异常。 + * + * @author guifc + * @date 2023-08-04 + */ +public class RemoteDataBuildException extends RuntimeException { + + /** + * 构造函数。 + */ + public RemoteDataBuildException() { + + } + + /** + * 构造函数。 + * + * @param msg 错误信息。 + */ + public RemoteDataBuildException(String msg) { + super(msg); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/interceptor/MyRequestArgumentResolver.java b/common/common-core/src/main/java/apelet/common/core/interceptor/MyRequestArgumentResolver.java new file mode 100644 index 0000000..9daff10 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/interceptor/MyRequestArgumentResolver.java @@ -0,0 +1,229 @@ +package apelet.common.core.interceptor; + +import apelet.common.core.annotation.MyRequestBody; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.io.IOUtils; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.math.BigDecimal; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; + +/** + * MyRequestBody解析器 + * 解决的问题: + * 1、单个字符串等包装类型都要写一个对象才可以用@RequestBody接收; + * 2、多个对象需要封装到一个对象里才可以用@RequestBody接收。 + * + * @author guifc + * @date 2023-08-04 + */ +public class MyRequestArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String JSONBODY_ATTRIBUTE = "MY_REQUEST_BODY_ATTRIBUTE_XX"; + + private static final Set> CLASS_SET = new HashSet<>(); + + static { + CLASS_SET.add(Integer.class); + CLASS_SET.add(Long.class); + CLASS_SET.add(Short.class); + CLASS_SET.add(Float.class); + CLASS_SET.add(Double.class); + CLASS_SET.add(Boolean.class); + CLASS_SET.add(Byte.class); + CLASS_SET.add(BigDecimal.class); + CLASS_SET.add(Character.class); + CLASS_SET.add(Date.class); + } + + /** + * 设置支持的方法参数类型。 + * + * @param parameter 方法参数。 + * @return 支持的类型。 + */ + @Override + public boolean supportsParameter(@NonNull MethodParameter parameter) { + return parameter.hasParameterAnnotation(MyRequestBody.class); + } + + /** + * 参数解析,利用fastjson。 + * 注意:非基本类型返回null会报空指针异常,要通过反射或者JSON工具类创建一个空对象。 + */ + @Override + public Object resolveArgument( + @NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + @NonNull NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class); + Assert.notNull(servletRequest, "HttpServletRequest can't be NULL."); + String contentType = servletRequest.getContentType(); + if (!HttpMethod.POST.name().equals(servletRequest.getMethod())) { + throw new IllegalArgumentException("Only POST method can be applied @MyRequestBody annotation!"); + } + if (!StrUtil.containsIgnoreCase(contentType, MediaType.APPLICATION_JSON_VALUE)) { + throw new IllegalArgumentException( + "Only application/json Content-Type can be applied @MyRequestBody annotation!"); + } + // 根据@MyRequestBody注解value作为json解析的key + MyRequestBody parameterAnnotation = parameter.getParameterAnnotation(MyRequestBody.class); + Assert.notNull(parameterAnnotation, "parameterAnnotation can't be NULL"); + JSONObject jsonObject = getRequestBody(webRequest); + if (jsonObject == null) { + if (parameterAnnotation.required()) { + throw new IllegalArgumentException("Request Body is EMPTY!"); + } + return null; + } + String key = parameterAnnotation.value(); + if (StrUtil.isBlank(key)) { + key = parameter.getParameterName(); + } + Object value = jsonObject.get(key); + if (value == null) { + if (parameterAnnotation.required()) { + throw new IllegalArgumentException(String.format("Required parameter %s is not present!", key)); + } + return null; + } + // 获取参数类型。 + Class parameterType = parameter.getParameterType(); + // 基本类型 + if (parameterType.isPrimitive()) { + return parsePrimitive(parameterType.getName(), value); + } + // 基本类型包装类 + if (isBasicDataTypes(parameterType)) { + return parseBasicTypeWrapper(parameterType, value); + } else if (parameterType == String.class) { + // 字符串类型 + return value.toString(); + } + // 对象类型 + if (!(value instanceof JSONArray)) { + // 其他复杂对象 + return JSON.toJavaObject((JSONObject) value, parameterType); + } + if (parameter.getGenericParameterType() instanceof ParameterizedType) { + return ((JSONArray) value).toJavaObject(parameter.getGenericParameterType()); + } + // 非参数化的集合类型 + return JSON.parseObject(value.toString(), parameterType); + } + + private Object parsePrimitive(String parameterTypeName, Object value) { + final String booleanTypeName = "boolean"; + if (booleanTypeName.equals(parameterTypeName)) { + return Boolean.valueOf(value.toString()); + } + final String intTypeName = "int"; + if (intTypeName.equals(parameterTypeName)) { + return Integer.valueOf(value.toString()); + } + final String charTypeName = "char"; + if (charTypeName.equals(parameterTypeName)) { + return value.toString().charAt(0); + } + final String shortTypeName = "short"; + if (shortTypeName.equals(parameterTypeName)) { + return Short.valueOf(value.toString()); + } + final String longTypeName = "long"; + if (longTypeName.equals(parameterTypeName)) { + return Long.valueOf(value.toString()); + } + final String floatTypeName = "float"; + if (floatTypeName.equals(parameterTypeName)) { + return Float.valueOf(value.toString()); + } + final String doubleTypeName = "double"; + if (doubleTypeName.equals(parameterTypeName)) { + return Double.valueOf(value.toString()); + } + final String byteTypeName = "byte"; + if (byteTypeName.equals(parameterTypeName)) { + return Byte.valueOf(value.toString()); + } + return null; + } + + private Object parseBasicTypeWrapper(Class parameterType, Object value) { + if (Number.class.isAssignableFrom(parameterType)) { + return this.parseNumberType(parameterType, value); + } else if (parameterType == Boolean.class) { + return value; + } else if (parameterType == Character.class) { + return value.toString().charAt(0); + } else if (parameterType == Date.class) { + return Convert.toDate(value); + } + return null; + } + + private Object parseNumberType(Class parameterType, Object value) { + if (value instanceof String) { + return Convert.convert(parameterType, value); + } + Number number = (Number) value; + if (parameterType == Integer.class) { + return number.intValue(); + } else if (parameterType == Short.class) { + return number.shortValue(); + } else if (parameterType == Long.class) { + return number.longValue(); + } else if (parameterType == Float.class) { + return number.floatValue(); + } else if (parameterType == Double.class) { + return number.doubleValue(); + } else if (parameterType == Byte.class) { + return number.byteValue(); + } else if (parameterType == BigDecimal.class) { + if (value instanceof Double || value instanceof Float) { + return BigDecimal.valueOf(number.doubleValue()); + } else { + return BigDecimal.valueOf(number.longValue()); + } + } + return null; + } + + private boolean isBasicDataTypes(Class clazz) { + return CLASS_SET.contains(clazz); + } + + private JSONObject getRequestBody(NativeWebRequest webRequest) throws IOException { + HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class); + Assert.notNull(servletRequest, "servletRequest can't be NULL"); + // 有就直接获取 + JSONObject jsonObject = (JSONObject) webRequest.getAttribute(JSONBODY_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); + // 没有就从请求中读取 + if (jsonObject == null) { + String jsonBody = IOUtils.toString(servletRequest.getReader()); + jsonObject = JSON.parseObject(jsonBody); + if (jsonObject != null) { + webRequest.setAttribute(JSONBODY_ATTRIBUTE, jsonObject, RequestAttributes.SCOPE_REQUEST); + } + } + return jsonObject; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/listener/LoadServiceRelationListener.java b/common/common-core/src/main/java/apelet/common/core/listener/LoadServiceRelationListener.java new file mode 100644 index 0000000..85294d4 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/listener/LoadServiceRelationListener.java @@ -0,0 +1,29 @@ +package apelet.common.core.listener; + +import apelet.common.core.base.service.BaseService; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 应用程序启动后的事件监听对象。主要负责加载Model之间的字典关联和一对一关联所对应的Service结构关系。 + * + * @author guifc + * @date 2023-08-04 + */ +@Component +public class LoadServiceRelationListener implements ApplicationListener { + + @SuppressWarnings("all") + @Override + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + Map serviceMap = + applicationReadyEvent.getApplicationContext().getBeansOfType(BaseService.class); + for (Map.Entry e : serviceMap.entrySet()) { + e.getValue().loadLocalRelationStruct(); + e.getValue().loadRemoteRelationStruct(); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/annotation/McpToolClass.java b/common/common-core/src/main/java/apelet/common/core/mcp/annotation/McpToolClass.java new file mode 100644 index 0000000..6eb2f1e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/annotation/McpToolClass.java @@ -0,0 +1,30 @@ +package apelet.common.core.mcp.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * MCP 工具方法注解:标注在业务方法上,启动时由 McpToolScanner 扫描自动生成 MCP Tool 并注册。 + *

name 与 description 即 MCP Tool 的 name / description,inputSchema 由方法参数上的 @McpToolParam 组装。

+ * + * @author chenchuchuan + * @date 2026-08-31 + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpToolClass { + + /** + * MCP Tool 名称,客户端 tools/list 看到的 name。 + */ + String name(); + + /** + * MCP Tool 描述,供大模型理解用途。 + */ + String description(); +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/annotation/McpToolParam.java b/common/common-core/src/main/java/apelet/common/core/mcp/annotation/McpToolParam.java new file mode 100644 index 0000000..de219e7 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/annotation/McpToolParam.java @@ -0,0 +1,48 @@ +package apelet.common.core.mcp.annotation; + +import apelet.common.core.mcp.model.McpToolParamType; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * MCP 工具方法参数注解:标注在 @McpToolClass 方法的参数上,以及 type=OBJECT 的 schemaClass 字段上。 + *

启动扫描时按注解属性组装 inputSchema;调用工具时按 fieldName 从入参取值绑定到方法实参。

+ * + * @author chenchuchuan + * @date 2026-08-31 + */ +@Documented +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpToolParam { + + /** + * 入参字段名,即客户端传参时使用的 key。 + * 未配置时取 Java 参数名(默认编译带调试信息,可通过 DefaultParameterNameDiscoverer 获取)。 + */ + String fieldName() default ""; + + /** + * 字段类型(JSON Schema 的 type),默认字符串。 + */ + McpToolParamType type() default McpToolParamType.STRING; + + /** + * 字段描述,供大模型理解入参含义。 + */ + String description() default ""; + + /** + * 是否必填,默认 false。 + */ + boolean required() default false; + + /** + * type=OBJECT 时必填,指向的对象类型,其 @McpToolParam 标注字段会递归组装成嵌套 schema。 + */ + Class schemaClass() default Void.class; +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java new file mode 100644 index 0000000..b6af926 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java @@ -0,0 +1,91 @@ +package apelet.common.core.mcp.config; + +import apelet.common.core.mcp.transport.StreamableHttpServerTransportProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import java.util.List; + +/** + * MCP Server 装配:把业务工具暴露为 MCP tool,供大模型(客户端)调用。 + * 基于 mcp-java8-sdk 0.8.1(构造器 API,非新版 builder())。 + * 该 SDK 不提供 listTools(),故注册时同步收集一份工具清单供调试接口读取(见 McpToolScanner.getTools())。 + * 双传输并存:SSE(/mcp/sse + /mcp/message)+ Streamable HTTP(/mcp 单端点)。 + * 工具由 McpToolScanner 启动时扫描 @McpToolClass 自动生成并统一注入。 + * + * @author chenchuchuan + * @date 2026-08-27 + */ +@Configuration +public class McpToolConfig { + + /** + * MCP 消息专用 ObjectMapper:应用注入的 ObjectMapper 在序列化 @JsonTypeInfo 多态对象 + * (如 CallToolResult.content 的 TextContent)时丢失 type 判别符,导致客户端反序列化失败, + * 故 MCP 的序列化统一走此普通 mapper(普通 mapper 会正常输出 "type":"text")。 + */ + private static final ObjectMapper MCP_OBJECT_MAPPER = new ObjectMapper(); + + @Autowired + private McpToolScanner mcpToolScanner; + + /** + * MCP 传输层(Spring MVC SSE):SSE 长连接端点 + JSON-RPC message 端点。 + * 端点路径带 /tenantadmin 前缀(本应用无 servlet context-path,模块 urlPrefix 统一用 /tenantadmin/xxx)。 + */ + @Bean + public WebMvcSseServerTransportProvider mcpTransport() { + return new WebMvcSseServerTransportProvider(MCP_OBJECT_MAPPER, "/tenantadmin/mcp/message", "/tenantadmin/mcp/sse"); + } + + /** + * 把 SSE transport 内部的 RouterFunction(SSE/message 两个端点)注册进 Spring MVC。 + */ + @Bean + public RouterFunction mcpEndpoints(WebMvcSseServerTransportProvider mcpTransport) { + return mcpTransport.getRouterFunction(); + } + + /** + * 构建同步 MCP Server(SSE 传输),并注册业务工具。 + */ + @Bean + public McpSyncServer mcpSyncServer(WebMvcSseServerTransportProvider mcpTransport) { + return registerTools(McpServer.sync(mcpTransport).serverInfo("business-mcp-server", "1.0.0")).build(); + } + + /** + * MCP 传输层(Streamable HTTP,单端点 POST /tenantadmin/mcp)。 + * 端点由 McpStreamableController 提供,不走 RouterFunction(避免应用转换器二次加工响应)。 + */ + @Bean + public StreamableHttpServerTransportProvider streamableHttpTransport() { + return new StreamableHttpServerTransportProvider(MCP_OBJECT_MAPPER); + } + + /** + * 构建同步 MCP Server(Streamable HTTP 传输),注册同一套业务工具。 + */ + @Bean + public McpSyncServer mcpStreamableSyncServer(StreamableHttpServerTransportProvider streamableHttpTransport) { + return registerTools(McpServer.sync(streamableHttpTransport).serverInfo("business-mcp-server", "1.0.0")).build(); + } + + /** + * 注册 MCP 工具(SSE 与 Streamable 两个 server 共用同一套注册逻辑)。 + * 工具清单由 McpToolScanner 扫描 @McpToolClass 自动组装,这里批量注入。 + */ + private McpServer.SyncSpecification registerTools(McpServer.SyncSpecification spec) { + List specifications = mcpToolScanner.getToolSpecifications(); + return specifications.isEmpty() ? spec : spec.tools(specifications); + } + +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java new file mode 100644 index 0000000..eaa15b8 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java @@ -0,0 +1,540 @@ +package apelet.common.core.mcp.config; + +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolArgInfo; +import apelet.common.core.mcp.model.McpToolParamType; +import apelet.common.core.object.TokenData; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.stereotype.Component; +import org.springframework.util.ClassUtils; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.reflect.*; +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +/** + * MCP 工具扫描注册器:启动时从 Spring 容器取 @RestController/@Controller/@Component 等组件 bean, + * 扫描其上 @McpToolClass 标注的方法,自动组装 McpSchema.Tool 并生成调用 handler。 + *

直接基于容器 bean 扫描(getBeanNamesForAnnotation),不做 classpath 资源扫描, + * 避免 fat jar 嵌套 jar 里包名通配(**)扫不到的问题,也无需 scan-packages 等配置。 + * 组装结果以 SyncToolSpecification 列表暴露给 McpToolConfig 注入 SSE + Streamable 两个 server。

+ * + * @author chenchuchuan + * @date 2026-08-31 + */ +@Slf4j +@Component +public class McpToolScanner { + + /** + * 方法参数名发现器:优先 -parameters,兜底 LocalVariableTable(默认编译带调试信息)。 + */ + private static final DefaultParameterNameDiscoverer PARAM_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); + private final ApplicationContext applicationContext; + /** + * MCP 调用使用的默认用户Id(MCP 无 HTTP 登录态,服务内 TokenData.takeFromRequest() 会 NPE,调用前注入)。 + */ + @Value("${apelet.mcp.default-user-id:0}") + private Long defaultUserId; + /** + * 已组装好的工具注册项(供 spec.tools 批量注入)。 + */ + @Getter + private List toolSpecifications = Collections.emptyList(); + /** + * 已注册的工具清单(SDK 不提供 listTools(),供调试接口读取)。 + */ + private List toolList = Collections.emptyList(); + + public McpToolScanner(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + /** + * 按返回类型给动态代理一个安全的默认值(对象 null、字符串空、集合空、数组空、数值 0、布尔 false)。 + */ + private static Object defaultValue(Class returnType) { + if (returnType == String.class) { + return ""; + } + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + if (returnType == List.class || returnType == Map.class || returnType == Set.class) { + return Collections.emptyMap(); + } + if (returnType.isArray()) { + return Array.newInstance(returnType.getComponentType(), 0); + } + return null; + } + + @PostConstruct + public void init() { + List specifications = new ArrayList<>(); + List tools = new ArrayList<>(); + // 直接从 Spring 容器取组件 bean 扫描,不做 classpath 资源扫描。 + // 原因:ClassPathScanningCandidateComponentProvider 走 PathMatchingResourcePatternResolver 递归匹配 classpath 资源, + // 应用打成 fat jar 后 @McpToolClass 类在 BOOT-INF/lib/*.jar 嵌套 jar 里,包名带 `**` 时匹配不上,导致扫不到; + // 改为从容器取 bean 则完全不受打包方式影响。@RestController/@Controller 都被 @Component 元注解标注, + // getBeanNamesForAnnotation(Component.class) 全部命中,@Service/@Repository 等组件同样覆盖。 + Set> scanned = new HashSet<>(); + for (String beanName : applicationContext.getBeanNamesForAnnotation(RestController.class)) { + Class clazz = applicationContext.getType(beanName); + if (clazz == null) { + continue; + } + clazz = ClassUtils.getUserClass(clazz); + if (!scanned.add(clazz)) { + continue; + } + this.scanClassMethods(specifications, tools, clazz, beanName); + } + this.toolSpecifications = specifications; + this.toolList = tools; + log.info("MCP 工具扫描完成,共注册 {} 个 tool:{}", tools.size(), + tools.stream().map(McpSchema.Tool::getName).collect(Collectors.toList())); + } + + /** + * 扫描单个 bean 类的 @McpToolClass 方法并注册。 + */ + private void scanClassMethods(List specifications, + List tools, Class clazz, String beanName) { + for (Method method : clazz.getDeclaredMethods()) { + // 只暴露 public 方法为 MCP 工具,私有/受保护方法不注册。 + if (!Modifier.isPublic(method.getModifiers())) { + continue; + } + McpToolClass ann = method.getAnnotation(McpToolClass.class); + if (ann == null) { + continue; + } + this.registerTool(specifications, tools, clazz, beanName, method, ann); + } + } + + public List getTools() { + return toolList; + } + + /** + * 组装单个工具:inputSchema + Tool + 调用 handler。 + */ + private void registerTool(List specifications, + List tools, Class clazz, String beanName, Method method, McpToolClass ann) { + List argInfos = this.buildArgInfos(method); + String inputSchema = this.buildInputSchema(argInfos); + McpSchema.Tool tool = new McpSchema.Tool(ann.name(), ann.description(), inputSchema); + // 懒取 bean:只有真正带 @McpToolClass 方法的类才触发初始化;拿到的是代理实例,invoke 时走 AOP。 + Object bean = applicationContext.getBean(beanName); + McpServerFeatures.SyncToolSpecification specification = new McpServerFeatures.SyncToolSpecification(); + specification.setTool(tool); + specification.setCall((exchange, args) -> this.invoke(bean, method, argInfos, args)); + specifications.add(specification); + tools.add(tool); + log.info("MCP 工具已注册:{} (inputSchema={})", tool.getName(), inputSchema); + } + + /** + * 组装方法参数绑定信息:@McpToolParam 未配置 fieldName 时取 Java 参数名。 + */ + private List buildArgInfos(Method method) { + Parameter[] parameters = method.getParameters(); + String[] paramNames = PARAM_NAME_DISCOVERER.getParameterNames(method); + List argInfos = new ArrayList<>(); + for (int i = 0; i < parameters.length; i++) { + McpToolParam ann = parameters[i].getAnnotation(McpToolParam.class); + if (ann == null) { + log.warn("MCP 工具方法 [{}#{}] 参数 [{}] 缺少 @McpToolParam,跳过该参数", method.getDeclaringClass().getSimpleName(), method.getName(), i); + continue; + } + String fieldName = StrUtil.isNotBlank(ann.fieldName()) ? ann.fieldName() + : (paramNames != null && paramNames.length > i ? paramNames[i] : "arg" + i); + argInfos.add(this.toArgInfo(i, parameters[i].getType(), parameters[i].getParameterizedType(), ann, fieldName)); + } + return argInfos; + } + + /** + * 由注解 + Java 类型组装元信息(方法参数与对象字段共用)。 + */ + private McpToolArgInfo toArgInfo(int paramIndex, Class paramType, Type genericType, McpToolParam ann, String fieldName) { + Class elementType = ann.type() == McpToolParamType.ARRAY ? this.inferElementType(genericType, paramType) : null; + Class schemaClass = ann.type() == McpToolParamType.OBJECT ? (ann.schemaClass() == Void.class ? paramType : ann.schemaClass()) : null; + return new McpToolArgInfo(paramIndex, fieldName, ann.type(), ann.description(), paramType, elementType, schemaClass, ann.required()); + } + + /** + * 组装 inputSchema:{"type":"object","properties":{...},"required":[...]}。 + */ + private String buildInputSchema(List argInfos) { + JSONObject root = new JSONObject(true); + root.put("type", "object"); + JSONObject properties = new JSONObject(true); + JSONArray required = new JSONArray(); + for (McpToolArgInfo info : argInfos) { + properties.put(info.getFieldName(), this.buildElementSchema(info)); + if (info.isRequired()) { + required.add(info.getFieldName()); + } + } + if (!properties.isEmpty()) { + root.put("properties", properties); + } + if (!required.isEmpty()) { + root.put("required", required); + } + return root.toJSONString(); + } + + /** + * 组装单个参数/字段的 JSON Schema;OBJECT 递归展开 schemaClass 的 @McpToolParam 字段。 + */ + private JSONObject buildElementSchema(McpToolArgInfo info) { + JSONObject schema = new JSONObject(true); + schema.put("type", info.getType().getJsonType()); + if (StrUtil.isNotBlank(info.getDescription())) { + schema.put("description", info.getDescription()); + } + if (info.getType() == McpToolParamType.OBJECT && info.getSchemaClass() != null) { + JSONObject properties = new JSONObject(true); + JSONArray required = new JSONArray(); + this.collectObjectSchema(info.getSchemaClass(), properties, required); + if (!properties.isEmpty()) { + schema.put("properties", properties); + } + if (!required.isEmpty()) { + schema.put("required", required); + } + } else if (info.getType() == McpToolParamType.ARRAY && info.getElementType() != null) { + // List:E 为带 @McpToolParam 的对象时按对象展开 items,否则按基础类型映射 + JSONObject items = new JSONObject(true); + if (this.hasMcpToolParamFields(info.getElementType())) { + JSONObject properties = new JSONObject(true); + JSONArray required = new JSONArray(); + this.collectObjectSchema(info.getElementType(), properties, required); + items.put("type", "object"); + if (!properties.isEmpty()) { + items.put("properties", properties); + } + if (!required.isEmpty()) { + items.put("required", required); + } + } else { + items.put("type", this.toJsonType(info.getElementType())); + } + schema.put("items", items); + } + return schema; + } + + /** + * 判断类型是否为需要递归展开的对象:其声明字段中存在 @McpToolParam 标注。 + *

用于 List<对象> 场景:元素类型带注解字段时按嵌套对象组装 schema 与运行时绑定, + * 否则(String/数值/布尔等)按基础类型处理。

+ */ + private boolean hasMcpToolParamFields(Class clazz) { + if (clazz == null) { + return false; + } + for (Field field : clazz.getDeclaredFields()) { + if (field.getAnnotation(McpToolParam.class) != null) { + return true; + } + } + return false; + } + + /** + * 收集对象类型中 @McpToolParam 标注字段的嵌套 schema。 + */ + private void collectObjectSchema(Class schemaClass, JSONObject properties, JSONArray required) { + for (Field field : schemaClass.getDeclaredFields()) { + McpToolParam ann = field.getAnnotation(McpToolParam.class); + if (ann == null) { + continue; + } + String fieldName = StrUtil.isNotBlank(ann.fieldName()) ? ann.fieldName() : field.getName(); + properties.put(fieldName, this.buildElementSchema(this.toArgInfo(-1, field.getType(), field.getGenericType(), ann, fieldName))); + if (ann.required()) { + required.add(fieldName); + } + } + } + + /** + * 推断数组元素类型:List 泛型实参或数组组件类型,未知默认 String。 + */ + private Class inferElementType(Type genericType, Class rawType) { + if (genericType instanceof ParameterizedType) { + Type actual = ((ParameterizedType) genericType).getActualTypeArguments()[0]; + if (actual instanceof Class) { + return (Class) actual; + } + } + return rawType.isArray() ? rawType.getComponentType() : String.class; + } + + /** + * Java 类型 → JSON Schema type。 + */ + private String toJsonType(Class javaType) { + if (javaType == BigDecimal.class || javaType == Double.class || javaType == Float.class + || javaType == double.class || javaType == float.class) { + return "number"; + } + if (javaType == Long.class || javaType == Integer.class || javaType == Short.class || javaType == Byte.class + || javaType == long.class || javaType == int.class) { + return "integer"; + } + if (javaType == Boolean.class || javaType == boolean.class) { + return "boolean"; + } + return "string"; + } + + /** + * 调用工具方法:按元信息从入参 map 取参、转换类型、反射调用,结果包成 CallToolResult。 + *

MCP 调用运行在 reactor 调度线程,无 servlet 请求上下文,需先挂一个动态代理的 request/response + * (承载 TokenData)再调用,否则 @OperationLog 等切面在 RequestContextHolder 上强转 NPE。

+ */ + private McpSchema.CallToolResult invoke(Object bean, Method method, List argInfos, Map args) { + try { + //todo 这里要求带上token MCP 无 HTTP 登录态,注入默认用户 TokenData,避免服务内 takeFromRequest() 拿到 null 抛 NPE。 + TokenData tokenData = new TokenData(); + tokenData.setUserId(this.defaultUserId); + return this.withRequestContext(tokenData, () -> { + Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount()); + Object result = method.invoke(bean, invokeArgs); + String text = result == null ? "" : (result instanceof String ? (String) result : JSON.toJSONString(result)); + return new McpSchema.CallToolResult(Collections.singletonList(new McpSchema.TextContent(text)), false); + }); + } catch (Exception e) { + Throwable cause = e instanceof InvocationTargetException ? ((InvocationTargetException) e).getTargetException() : e; + log.error("MCP 工具 [{}] 调用失败", method.getName(), cause); + return new McpSchema.CallToolResult(Collections.singletonList(new McpSchema.TextContent(cause.getMessage())), true); + } + } + + /** + * 在指定 TokenData 的请求上下文下执行动作:当前线程没有请求上下文时挂动态代理的 request/response, + * 执行完恢复原样。 + */ + private T withRequestContext(TokenData tokenData, ThrowingSupplier action) throws Exception { + RequestAttributes previous = RequestContextHolder.getRequestAttributes(); + boolean created = previous == null; + if (created) { + ServletRequestAttributes attributes = new ServletRequestAttributes(mcpMockRequest(), mcpMockResponse()); + RequestContextHolder.setRequestAttributes(attributes); + } + TokenData.addToRequest(tokenData); + try { + return action.get(); + } finally { + if (created) { + RequestContextHolder.resetRequestAttributes(); + } + } + } + + /** + * 动态代理的 HttpServletRequest:仅承载 getAttribute/setAttribute(供 TokenData),其余方法返回安全默认值。 + */ + private HttpServletRequest mcpMockRequest() { + return (HttpServletRequest) Proxy.newProxyInstance(this.getClass().getClassLoader(), + new Class[]{HttpServletRequest.class}, new MockRequestHandler()); + } + + /** + * 动态代理的 HttpServletResponse:setHeader 等方法空操作。 + */ + private HttpServletResponse mcpMockResponse() { + return (HttpServletResponse) Proxy.newProxyInstance(this.getClass().getClassLoader(), + new Class[]{HttpServletResponse.class}, (proxy, method, args1) -> defaultValue(method.getReturnType())); + } + + /** + * 按 argInfos 的 paramIndex 填充方法实参数组(数组长度按方法形参总数,未标注解的参数保持 null)。 + */ + private Object[] bindArgs(Map args, List argInfos, int parameterCount) { + Object[] invokeArgs = new Object[parameterCount]; + for (McpToolArgInfo info : argInfos) { + invokeArgs[info.getParamIndex()] = this.convertValue(args.get(info.getFieldName()), info); + } + return invokeArgs; + } + + /** + * 按元信息把入参 JSON 值转换为 Java 值(简单类型 / 数组 / 对象递归)。 + */ + private Object convertValue(Object raw, McpToolArgInfo info) { + if (raw == null) { + return null; + } + switch (info.getType()) { + case STRING: + return String.valueOf(raw); + case BOOLEAN: + return Boolean.valueOf(raw.toString()); + case INTEGER: + return this.toScalar(raw, info.getParamType()); + case NUMBER: + return this.toScalar(raw, info.getParamType()); + case ARRAY: + return this.toArray(raw, info.getParamType(), info.getElementType()); + case OBJECT: + return this.toObject(raw, info.getSchemaClass()); + default: + return raw; + } + } + + /** + * 构造数组实参:支持 List 与 E[],元素按元素类型转换。 + */ + private Object toArray(Object raw, Class paramType, Class elementType) { + List source = raw instanceof List ? (List) raw : Arrays.asList((Object[]) raw); + List converted = new ArrayList<>(); + for (Object item : source) { + // List<对象>:元素为 Map 且元素类型带 @McpToolParam 字段时,逐条走 toObject 递归组装 + if (this.hasMcpToolParamFields(elementType) && item instanceof Map) { + converted.add(this.toObject(item, elementType)); + } else { + converted.add(this.toScalar(item, elementType)); + } + } + if (!paramType.isArray()) { + return converted; + } + Object array = Array.newInstance(elementType, converted.size()); + for (int i = 0; i < converted.size(); i++) { + Array.set(array, i, converted.get(i)); + } + return array; + } + + /** + * 构造对象实参:反射 new schemaClass 实例,遍历 @McpToolParam 字段取值转换后 set 入。 + *

schemaClass 为 Map 类型(如方法参数是 Map<String,Object> 自由 key-value 结构)时不做 POJO 展开, + * 直接把已解析好的入参 Map 原样返回,由业务方法自行按 key 取值。

+ */ + private Object toObject(Object raw, Class schemaClass) { + if (!(raw instanceof Map) || schemaClass == null) { + return null; + } + if (Map.class.isAssignableFrom(schemaClass)) { + return raw; + } + Map map = (Map) raw; + try { + Object instance = schemaClass.getDeclaredConstructor().newInstance(); + for (Field field : schemaClass.getDeclaredFields()) { + McpToolParam ann = field.getAnnotation(McpToolParam.class); + if (ann == null) { + continue; + } + String fieldName = StrUtil.isNotBlank(ann.fieldName()) ? ann.fieldName() : field.getName(); + Object value = map.get(fieldName); + if (value == null) { + continue; + } + McpToolArgInfo fieldInfo = this.toArgInfo(-1, field.getType(), field.getGenericType(), ann, fieldName); + field.setAccessible(true); + field.set(instance, this.convertValue(value, fieldInfo)); + } + return instance; + } catch (Exception e) { + log.error("MCP 对象参数 [{}] 组装失败", schemaClass.getSimpleName(), e); + return null; + } + } + + /** + * 标量值按目标 Java 类型转换(String/数值/布尔)。 + */ + private Object toScalar(Object raw, Class type) { + if (type == String.class) { + return String.valueOf(raw); + } + if (type == Long.class || type == long.class) { + return Long.valueOf(raw.toString()); + } + if (type == Integer.class || type == int.class) { + return Integer.valueOf(raw.toString()); + } + if (type == BigDecimal.class) { + return new BigDecimal(raw.toString()); + } + if (type == Double.class || type == double.class) { + return Double.valueOf(raw.toString()); + } + if (type == Float.class || type == float.class) { + return Float.valueOf(raw.toString()); + } + if (type == Boolean.class || type == boolean.class) { + return Boolean.valueOf(raw.toString()); + } + return raw; + } + + /** + * 允许抛受检异常的 Supplier(method.invoke 抛 IllegalAccessException/InvocationTargetException)。 + */ + @FunctionalInterface + private interface ThrowingSupplier { + T get() throws Exception; + } + + /** + * 动态代理 request 处理器:getAttribute/setAttribute 落到内存 map,其余方法返回安全默认值。 + */ + private static class MockRequestHandler implements InvocationHandler { + + private final Map attributes = new HashMap<>(); + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + String name = method.getName(); + if ("getAttribute".equals(name)) { + return attributes.get(args[0]); + } + if ("setAttribute".equals(name)) { + attributes.put((String) args[0], args[1]); + return null; + } + if ("getHeader".equals(name)) { + return attributes.get(args[0]); + } + if ("getRequestURI".equals(name)) { + return "/mcp"; + } + return defaultValue(method.getReturnType()); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java b/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java new file mode 100644 index 0000000..08878ec --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java @@ -0,0 +1,47 @@ +package apelet.common.core.mcp.controller; + +import apelet.common.core.mcp.transport.StreamableHttpServerTransportProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StreamUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Streamable HTTP 单端点(POST /tenantadmin/mcp)。 + * 直接读写 HttpServletRequest/Response,序列化由 provider 完成并原样写入, + * 不经过 Spring 消息转换器(应用 ObjectMapper 会丢 @JsonTypeInfo 的 type 判别符 / 双重编码 JsonNode)。 + * + * @author chenchuchuan + * @date 2026-08-28 + */ +@RestController +@RequestMapping("/tenantadmin/mcp") +public class McpStreamableController { + + @Autowired + private StreamableHttpServerTransportProvider streamableHttpTransport; + + @PostMapping + public void handle(HttpServletRequest request, HttpServletResponse response, + @RequestHeader(value = "Mcp-Session-Id", required = false) String sessionHeader) throws IOException { + String body = new String(StreamUtils.copyToByteArray(request.getInputStream()), StandardCharsets.UTF_8); + StreamableHttpServerTransportProvider.McpProcessResult result = + streamableHttpTransport.processMcpRequest(body, sessionHeader); + response.setStatus(result.httpStatus); + response.setContentType("application/json;charset=UTF-8"); + if (result.sessionId != null) { + response.setHeader(StreamableHttpServerTransportProvider.SESSION_HEADER, result.sessionId); + } + String json = result.responseJson != null ? result.responseJson : result.errorJson; + if (json != null) { + response.getWriter().write(json); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/model/McpToolArgInfo.java b/common/common-core/src/main/java/apelet/common/core/mcp/model/McpToolArgInfo.java new file mode 100644 index 0000000..150d83b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/model/McpToolArgInfo.java @@ -0,0 +1,71 @@ +package apelet.common.core.mcp.model; + +/** + * 单个 MCP 工具方法参数/对象字段的元信息:组装 inputSchema 与调用时绑定实参共用。 + * + * @author chenchuchuan + * @date 2026-08-31 + */ +public class McpToolArgInfo { + + /** 参数在方法形参列表中的下标;对象字段为 -1。 */ + private final int paramIndex; + /** 入参字段名(注解 fieldName 未配置时为 Java 参数名/属性名)。 */ + private final String fieldName; + /** 注解声明的类型(决定 inputSchema 的 type 与转换分支)。 */ + private final McpToolParamType type; + /** 字段描述。 */ + private final String description; + /** Java 类型(形参或字段类型)。 */ + private final Class paramType; + /** ARRAY 类型的元素类型(由泛型实参或数组组件类型推断),非 ARRAY 为 null。 */ + private final Class elementType; + /** OBJECT 类型指向的对象类型,非 OBJECT 为 null。 */ + private final Class schemaClass; + /** 是否必填(进 inputSchema 的 required 数组)。 */ + private final boolean required; + + public McpToolArgInfo(int paramIndex, String fieldName, McpToolParamType type, String description, + Class paramType, Class elementType, Class schemaClass, boolean required) { + this.paramIndex = paramIndex; + this.fieldName = fieldName; + this.type = type; + this.description = description; + this.paramType = paramType; + this.elementType = elementType; + this.schemaClass = schemaClass; + this.required = required; + } + + public int getParamIndex() { + return paramIndex; + } + + public String getFieldName() { + return fieldName; + } + + public McpToolParamType getType() { + return type; + } + + public String getDescription() { + return description; + } + + public Class getParamType() { + return paramType; + } + + public Class getElementType() { + return elementType; + } + + public Class getSchemaClass() { + return schemaClass; + } + + public boolean isRequired() { + return required; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/model/McpToolParamType.java b/common/common-core/src/main/java/apelet/common/core/mcp/model/McpToolParamType.java new file mode 100644 index 0000000..fe8be4e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/model/McpToolParamType.java @@ -0,0 +1,35 @@ +package apelet.common.core.mcp.model; + +import lombok.Getter; + +/** + * MCP 参数类型枚举:value 即 JSON Schema 的 type 值。 + *

Java 侧实际的转换类型由方法参数声明决定,枚举只负责 inputSchema 的 type 与转换分支。

+ * + * @author chenchuchuan + * @date 2026-08-31 + */ +@Getter +public enum McpToolParamType { + + /** 字符串 */ + STRING("string"), + /** 数字(整数或浮点),对应 BigDecimal/Double/Float */ + NUMBER("number"), + /** 整数,对应 Integer/Long */ + INTEGER("integer"), + /** 布尔值 */ + BOOLEAN("boolean"), + /** 数组,元素类型由 List 泛型实参或数组组件类型推断 */ + ARRAY("array"), + /** 对象,需要配合 schemaClass 指定类型 */ + OBJECT("object"); + + /** JSON Schema 的 type 值 */ + private final String jsonType; + + McpToolParamType(String jsonType) { + this.jsonType = jsonType; + } + +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/transport/StreamableHttpServerTransportProvider.java b/common/common-core/src/main/java/apelet/common/core/mcp/transport/StreamableHttpServerTransportProvider.java new file mode 100644 index 0000000..42af74a --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/transport/StreamableHttpServerTransportProvider.java @@ -0,0 +1,230 @@ +package apelet.common.core.mcp.transport; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Streamable HTTP 传输层(MCP spec 2025-03-26 单端点 POST 传输)。 + * 基于 mcp-java8-sdk 0.8.1 的 SPI(McpServerTransportProvider + McpServerTransport)自研, + * 因该 fork 不提供 Streamable HTTP Provider(官方 SDK 0.9.0 才引入)。 + * 会话通过 Mcp-Session-Id 头管理:initialize 创建,后续请求携带。 + * 响应序列化由调用方(McpStreamableController)直接写入 HTTP 响应, + * 不经过 Spring 消息转换器,避免应用 ObjectMapper 丢失 @JsonTypeInfo 的 type 判别符 + * 或对 JsonNode 双重编码。 + * + * @author chenchuchuan + * @date 2026-08-28 + */ +public class StreamableHttpServerTransportProvider implements McpServerTransportProvider { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpServerTransportProvider.class); + + public static final String SESSION_HEADER = "Mcp-Session-Id"; + + private final ObjectMapper objectMapper; + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap transports = new ConcurrentHashMap<>(); + private volatile boolean isClosing; + + private McpServerSession.Factory sessionFactory; + + public StreamableHttpServerTransportProvider(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono notifyClients(String method, Map params) { + return Flux.fromIterable(sessions.values()) + .flatMap(session -> session.sendNotification(method, params)) + .then(); + } + + @Override + public Mono closeGracefully() { + isClosing = true; + return Flux.fromIterable(sessions.values()) + .flatMap(McpServerSession::closeGracefully) + .then(); + } + + /** + * 处理一条 MCP 请求:JSON-RPC 解析 + Mcp-Session-Id 会话路由 + 业务处理。 + * 返回序列化好的响应 JSON(含 type 判别符),由调用方原样写入 HTTP 响应。 + */ + public McpProcessResult processMcpRequest(String body, String sessionHeader) { + if (isClosing) { + return McpProcessResult.error(503, null, "Server is shutting down"); + } + McpSchema.JSONRPCMessage message; + try { + message = McpSchema.deserializeJsonRpcMessage(objectMapper, body); + } catch (Exception e) { + logger.error("Failed to deserialize MCP message: {}", e.getMessage()); + return McpProcessResult.error(400, null, "Invalid message format"); + } + + String sessionId = sessionHeader; + boolean isInitialize = message instanceof McpSchema.JSONRPCRequest + && "initialize".equals(((McpSchema.JSONRPCRequest) message).getMethod()); + + McpServerSession session; + StreamableHttpSessionTransport transport; + if (isInitialize && sessionId == null) { + sessionId = UUID.randomUUID().toString(); + transport = new StreamableHttpSessionTransport(sessionId, objectMapper); + session = sessionFactory.create(transport); + sessions.put(sessionId, session); + transports.put(sessionId, transport); + } else if (sessionId != null) { + transport = transports.get(sessionId); + session = sessions.get(sessionId); + if (session == null || transport == null) { + return McpProcessResult.error(404, sessionId, "Session not found: " + sessionId); + } + } else { + return McpProcessResult.error(400, null, "Session ID missing in message endpoint"); + } + + logger.info("MCP 请求 method={} sessionId={}", + message instanceof McpSchema.JSONRPCRequest ? ((McpSchema.JSONRPCRequest) message).getMethod() + : (message instanceof McpSchema.JSONRPCNotification ? ((McpSchema.JSONRPCNotification) message).getMethod() : "unknown"), + sessionId); + + try { + session.handle(message).block(Duration.ofSeconds(30)); + // 协议上客户端应在 initialize 后发送 notifications/initialized 才会进入 INITIALIZED 状态, + // 但直接 HTTP 调用(Postman / 平台扫描)往往省略该通知,导致后续 tools/list 等请求 + // 因 exchange 未就绪而在 exchangeSink.asMono() 上无限阻塞超时,故服务端自动补发。 + if (isInitialize) { + session.handle(new McpSchema.JSONRPCNotification("2.0", "notifications/initialized", Collections.emptyMap())) + .block(Duration.ofSeconds(30)); + } + } catch (Exception e) { + logger.error("Failed to process MCP message: {}", e.getMessage()); + return McpProcessResult.error(500, sessionId, "Failed to process MCP message: " + e.getMessage()); + } + + McpSchema.JSONRPCMessage response = null; + if (message instanceof McpSchema.JSONRPCRequest) { + try { + response = transport.pollResponse(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return McpProcessResult.error(500, sessionId, "Interrupted while waiting for response"); + } + } + try { + return new McpProcessResult(200, sessionId, response != null ? objectMapper.writeValueAsString(response) : null, null); + } catch (Exception e) { + logger.error("Failed to serialize MCP response: {}", e.getMessage()); + return McpProcessResult.error(500, sessionId, "Failed to serialize MCP response: " + e.getMessage()); + } + } + + /** + * MCP 请求处理结果,供控制器原样写入 HTTP 响应。 + */ + public static class McpProcessResult { + + /** HTTP 状态码 */ + public final int httpStatus; + /** 会话 id(可能为 null) */ + public final String sessionId; + /** 正常响应 JSON(含 type 判别符);通知类请求为 null */ + public final String responseJson; + /** 错误响应 JSON;正常时为 null */ + public final String errorJson; + + McpProcessResult(int httpStatus, String sessionId, String responseJson, String errorJson) { + this.httpStatus = httpStatus; + this.sessionId = sessionId; + this.responseJson = responseJson; + this.errorJson = errorJson; + } + + static McpProcessResult error(int httpStatus, String sessionId, String message) { + return new McpProcessResult(httpStatus, sessionId, null, + "{\"error\":\"" + message.replace("\"", "\\\"") + "\"}"); + } + } + + /** + * 每会话的传输层:sendMessage 把出站消息放入队列,由 processMcpRequest 取走序列化写回 HTTP 响应。 + */ + public static class StreamableHttpSessionTransport implements McpServerTransport { + + private final String sessionId; + private final ObjectMapper objectMapper; + private final BlockingQueue outboundMessages = new LinkedBlockingQueue<>(); + private volatile boolean isClosed; + + StreamableHttpSessionTransport(String sessionId, ObjectMapper objectMapper) { + this.sessionId = sessionId; + this.objectMapper = objectMapper; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromRunnable(() -> { + try { + if (isClosed) { + logger.warn("Failed to send message to session {}: transport is closed", sessionId); + return; + } + outboundMessages.offer(message); + logger.debug("Message sent to session {}", sessionId); + } catch (Exception e) { + logger.error("Failed to send message to session {}: {}", sessionId, e.getMessage()); + } + }); + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return objectMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + isClosed = true; + outboundMessages.clear(); + }); + } + + @Override + public void close() { + isClosed = true; + outboundMessages.clear(); + } + + /** + * 取出一条出站消息(通常是对当前请求的 JSON-RPC 响应)。 + */ + McpSchema.JSONRPCMessage pollResponse() throws InterruptedException { + return isClosed ? null : outboundMessages.poll(1, TimeUnit.SECONDS); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/CallResult.java b/common/common-core/src/main/java/apelet/common/core/object/CallResult.java new file mode 100644 index 0000000..1bbf1cb --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/CallResult.java @@ -0,0 +1,103 @@ +package apelet.common.core.object; + +import com.alibaba.fastjson.JSONObject; +import lombok.Data; + +/** + * 业务方法调用结果对象。可以同时返回具体的错误和JSON类型的数据对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class CallResult { + + /** + * 为了优化性能,所有没有携带数据的正确结果,均可用该对象表示。 + */ + private static final CallResult OK = new CallResult(); + /** + * 是否成功标记。 + */ + private boolean success = true; + /** + * 错误信息描述。 + */ + private String errorMessage = null; + /** + * 在验证同时,仍然需要附加的关联数据对象。 + */ + private JSONObject data; + + /** + * 创建验证结果对象。 + * + * @param errorMessage 错误描述信息。 + * @return 如果参数为空,表示成功,否则返回代码错误信息的错误对象实例。 + */ + public static CallResult create(String errorMessage) { + return errorMessage == null ? ok() : error(errorMessage); + } + + /** + * 创建验证结果对象。 + * + * @param errorMessage 错误描述信息。 + * @param data 附带的数据对象。 + * @return 如果参数为空,表示成功,否则返回代码错误信息的错误对象实例。 + */ + public static CallResult create(String errorMessage, JSONObject data) { + return errorMessage == null ? ok(data) : error(errorMessage); + } + + /** + * 创建表示验证成功的对象实例。 + * + * @return 验证成功对象实例。 + */ + public static CallResult ok() { + return OK; + } + + /** + * 创建表示验证成功的对象实例。 + * + * @param data 附带的数据对象。 + * @return 验证成功对象实例。 + */ + public static CallResult ok(JSONObject data) { + CallResult result = new CallResult(); + result.data = data; + return result; + } + + /** + * 创建表示验证失败的对象实例。 + * + * @param errorMessage 错误描述。 + * @return 验证失败对象实例。 + */ + public static CallResult error(String errorMessage) { + CallResult result = new CallResult(); + result.success = false; + result.errorMessage = errorMessage; + return result; + } + + /** + * 创建表示验证失败的对象实例。 + * + * @param errorMessage 错误描述。 + * @param data 附带的数据对象。 + * @return 验证失败对象实例。 + */ + public static CallResult error(String errorMessage, T data) { + CallResult result = new CallResult(); + result.success = false; + result.errorMessage = errorMessage; + JSONObject jsonObject = new JSONObject(); + jsonObject.put("errorData", data); + result.data = jsonObject; + return result; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ColumnEncodedRule.java b/common/common-core/src/main/java/apelet/common/core/object/ColumnEncodedRule.java new file mode 100644 index 0000000..fa670bd --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ColumnEncodedRule.java @@ -0,0 +1,38 @@ +package apelet.common.core.object; + +import lombok.Data; + +/** + * 编码字段的编码规则。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class ColumnEncodedRule { + + /** + * 是否显示是计算并回显。 + */ + private Boolean calculateWhenView; + + /** + * 前缀。 + */ + private String prefix; + + /** + * 精确到DAYS/HOURS/MINUTES/SECONDS + */ + private String precisionTo; + + /** + * 中缀。 + */ + private String middle; + + /** + * 流水序号的字符宽度,不足的前面补0。 + */ + private Integer idWidth; +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ConstDictInfo.java b/common/common-core/src/main/java/apelet/common/core/object/ConstDictInfo.java new file mode 100644 index 0000000..b0fef2a --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ConstDictInfo.java @@ -0,0 +1,24 @@ +package apelet.common.core.object; + +import lombok.Data; + +import java.util.List; + +/** + * 常量字典的数据结构。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class ConstDictInfo { + + private List dictData; + + @Data + public static class ConstDictData { + private String type; + private Object id; + private String name; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ConvertRelationDto.java b/common/common-core/src/main/java/apelet/common/core/object/ConvertRelationDto.java new file mode 100644 index 0000000..a7e8711 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ConvertRelationDto.java @@ -0,0 +1,30 @@ +package apelet.common.core.object; + +import lombok.Data; + +import java.util.List; + +/** + * @author Soon + * @version 1.0 + * Create by 2024/2/19 9:59 + * @description 关联关系 + */ +@Data +public class ConvertRelationDto { + + private Long id; + + private String convertRuleId; + + private String sourceNumber; + + private String sourceId; + + private String targetNumber; + + private String targetId; + + private List convertRelationEntryDtoList; + +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ConvertRelationEntryDto.java b/common/common-core/src/main/java/apelet/common/core/object/ConvertRelationEntryDto.java new file mode 100644 index 0000000..abb4c3e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ConvertRelationEntryDto.java @@ -0,0 +1,41 @@ +package apelet.common.core.object; + + +import lombok.Data; + +/** + * @author Soon + * @version 1.0 + * Create by 2024/2/19 10:05 + * @description 转换分录关系 + */ +@Data +public class ConvertRelationEntryDto { + + + private Long id; + + + private String convertRuleId; + + + private String sourceNumber; + + private String sourceId; + + + private String sourceEntryNumber; + + private String sourceEntryId; + + + private String targetNumber; + + private String targetId; + + + private String targeEntryNumber; + + private String targeEntryId; + +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/DummyClass.java b/common/common-core/src/main/java/apelet/common/core/object/DummyClass.java new file mode 100644 index 0000000..6e68035 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/DummyClass.java @@ -0,0 +1,27 @@ +package apelet.common.core.object; + +/** + * 哑元对象,主要用于注解中的缺省对象占位符。 + * + * @author guifc + * @date 2023-08-04 + */ +public final class DummyClass { + + private static final Object EMPTY_OBJECT = new Object(); + + /** + * 可以忽略的空对象。避免sonarqube的各种警告。 + * + * @return 空对象。 + */ + public static Object emptyObject() { + return EMPTY_OBJECT; + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private DummyClass() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/GlobalThreadLocal.java b/common/common-core/src/main/java/apelet/common/core/object/GlobalThreadLocal.java new file mode 100644 index 0000000..c783080 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/GlobalThreadLocal.java @@ -0,0 +1,52 @@ +package apelet.common.core.object; + +import cn.hutool.core.util.BooleanUtil; + +/** + * 线程本地化数据管理的工具类。可根据需求自行添加更多的线程本地化变量及其操作方法。 + * + * @author guifc + * @date 2023-08-04 + */ +public class GlobalThreadLocal { + + /** + * 存储数据权限过滤是否启用的线程本地化对象。 + * 目前的过滤条件,包括数据权限和租户过滤。 + */ + private static final ThreadLocal DATA_FILTER_ENABLE = ThreadLocal.withInitial(() -> Boolean.TRUE); + + /** + * 设置数据过滤是否打开。如果打开,当前Servlet线程所执行的SQL操作,均会进行数据过滤。 + * + * @param enable 打开为true,否则false。 + * @return 返回之前的状态,便于恢复。 + */ + public static boolean setDataFilter(boolean enable) { + boolean oldValue = DATA_FILTER_ENABLE.get(); + DATA_FILTER_ENABLE.set(enable); + return oldValue; + } + + /** + * 判断当前Servlet线程所执行的SQL操作,是否进行数据过滤。 + * + * @return true 进行数据权限过滤,否则false。 + */ + public static boolean enabledDataFilter() { + return BooleanUtil.isTrue(DATA_FILTER_ENABLE.get()); + } + + /** + * 清空该存储数据,主动释放线程本地化存储资源。 + */ + public static void clearDataFilter() { + DATA_FILTER_ENABLE.remove(); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private GlobalThreadLocal() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/LoginUserInfo.java b/common/common-core/src/main/java/apelet/common/core/object/LoginUserInfo.java new file mode 100644 index 0000000..fcc82ef --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/LoginUserInfo.java @@ -0,0 +1,62 @@ +package apelet.common.core.object; + +import lombok.Data; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +import java.util.Date; + +/** + * 在线登录用户信息。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@ToString +@Slf4j +public class LoginUserInfo { + + /** + * 用户Id。 + */ + private Long userId; + /** + * 用户所在部门Id。 + * 仅当系统支持uaa时可用,否则可以直接忽略该字段。保留该字段是为了保持单体和微服务通用代码部分的兼容性。 + */ + private Long deptId; + /** + * 租户Id。 + * 仅当系统支持uaa时可用,否则可以直接忽略该字段。保留该字段是为了保持单体和微服务通用代码部分的兼容性。 + */ + private Long tenantId; + /** + * 是否为超级管理员。 + */ + private Boolean isAdmin; + /** + * 用户登录名。 + */ + private String loginName; + /** + * 用户显示名称。 + */ + private String showName; + /** + * 标识不同登录的会话Id。 + */ + private String sessionId; + /** + * 登录IP。 + */ + private String loginIp; + /** + * 登录时间。 + */ + private Date loginTime; + /** + * 登录设备类型。 + */ + private Integer deviceType; +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/Model.java b/common/common-core/src/main/java/apelet/common/core/object/Model.java new file mode 100644 index 0000000..57632d5 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/Model.java @@ -0,0 +1,55 @@ +package apelet.common.core.object; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class Model { + + /** + * 租户数据源Id + */ + private Long datasourceId; + + /** + * 主表数据 + */ + private Map masterData; + + /** + * 从表数据 + */ + private Map slaveData; + + /** + * 自定义组件数据 + */ + private Map customEmptyData; + + /** + * 查询界面列表数据 + */ + private Map listData; + + /** + * 查询界面选中数据 + */ + private Map rowData; + + private List rowDatas; + + /** + * 查询界面选中字段 + */ + private String cellField;// + + private Map returnData; + + private Map rowChange; + + + private Map queryParams; + +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyAggregationParam.java b/common/common-core/src/main/java/apelet/common/core/object/MyAggregationParam.java new file mode 100644 index 0000000..c92ff1d --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyAggregationParam.java @@ -0,0 +1,71 @@ +package apelet.common.core.object; + +import lombok.Data; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 分组聚合查询参数。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class MyAggregationParam { + + /** + * 聚合返回数据中,聚合键的常量字段名。 + * 如select groupColumn groupedKey, max(aggregationColumn) aggregatedValue。 + */ + public static final String KEY_NAME = "grouped_key"; + + /** + * 聚合返回数据中,聚合值的常量字段名。 + * 如select groupColumn groupedKey, max(aggregationColumn) aggregatedValue。 + */ + public static final String VALUE_NAME = "aggregated_value"; + + /** + * 聚合计算是否使用数据权限进行过滤。true表示数据过滤将产生作用,否则SQL中不会包含数据过滤。 + * 目前数据过滤包括数据权限过滤和租户数据过滤。 + */ + private Boolean useDataFilter = true; + + /** + * 聚合分类,具体数值见AggregationKind。 + */ + private Integer aggregationKind; + + /** + * 和groupedInFilterValues配合使用。其value集合中的值,需要基于infilterField设定的字段进行(in list)过滤。 + */ + private String inFilterField; + + /** + * 需要分组执行的 (in list) 数据集合。 + * 在聚合类别为 MANY_TO_MANY 的场景下,将迭代每一个key并分批次执行(in list)过滤,key作为分组值返回。 + */ + private Map> groupedInFilterValues; + + /** + * 过滤条件列表。 + */ + private List whereCriteriaList; + + /** + * 分组字段。(Java对象字段名称),通常用于聚合类别为 ONE_TO_MANY 的场景。 + */ + private String groupField; + + /** + * 聚合字段。(Java对象字段名称) + */ + private String aggregationField; + + /** + * 聚合类型,具体数值见AggregationType对象的常量值。如COUNT、SUM、MIN、MAX、AVG等。 + */ + private Integer aggregationType; +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyGroupCriteria.java b/common/common-core/src/main/java/apelet/common/core/object/MyGroupCriteria.java new file mode 100644 index 0000000..ac9a6d3 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyGroupCriteria.java @@ -0,0 +1,24 @@ +package apelet.common.core.object; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * Mybatis Mapper.xml中所需的分组条件对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@AllArgsConstructor +public class MyGroupCriteria { + + /** + * GROUP BY 从句后面的参数。 + */ + private String groupBy; + /** + * SELECT 从句后面的分组显示字段。 + */ + private String groupSelect; +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyGroupParam.java b/common/common-core/src/main/java/apelet/common/core/object/MyGroupParam.java new file mode 100644 index 0000000..fca018b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyGroupParam.java @@ -0,0 +1,231 @@ +package apelet.common.core.object; + +import apelet.common.core.config.CoreProperties; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.exception.InvalidClassFieldException; +import apelet.common.core.exception.InvalidDataFieldException; +import apelet.common.core.exception.InvalidDataModelException; +import apelet.common.core.util.ApplicationContextHolder; +import apelet.common.core.util.MyModelUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +/** + * 查询分组参数请求对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@EqualsAndHashCode(callSuper = true) +@Slf4j +@Data +public class MyGroupParam extends ArrayList { + + private final transient CoreProperties coreProperties = + ApplicationContextHolder.getBean(CoreProperties.class); + + /** + * SQL语句的SELECT LIST中,分组字段的返回字段名称列表。 + */ + private List selectGroupFieldList; + /** + * 分组参数解析后构建的SQL语句中所需的分组数据,如GROUP BY的字段列表和SELECT LIST中的分组字段显示列表。 + */ + private transient MyGroupCriteria groupCriteria; + /** + * 基于分组参数对象中的数据,构建SQL中select list和group by从句可以直接使用的分组对象。 + * + * @param groupParam 分组参数对象。 + * @param modelClazz 查询表对应的主对象的Class。 + * @return SQL中所需的GROUP对象。详见MyGroupCriteria类定义。 + */ + public static MyGroupParam buildGroupBy(MyGroupParam groupParam, Class modelClazz) { + if (groupParam == null) { + return null; + } + if (modelClazz == null) { + throw new IllegalArgumentException("modelClazz Argument can't be NULL"); + } + groupParam.selectGroupFieldList = new LinkedList<>(); + StringBuilder groupByBuilder = new StringBuilder(128); + StringBuilder groupSelectBuilder = new StringBuilder(128); + int i = 0; + for (GroupInfo groupInfo : groupParam) { + GroupBaseData groupBaseData = groupParam.parseGroupBaseData(groupInfo, modelClazz); + if (StrUtil.isBlank(groupBaseData.tableName)) { + throw new InvalidDataModelException(groupBaseData.modelName); + } + if (StrUtil.isBlank(groupBaseData.columnName)) { + throw new InvalidDataFieldException(groupBaseData.modelName, groupBaseData.fieldName); + } + groupParam.processGroupInfo(groupInfo, groupBaseData, groupByBuilder, groupSelectBuilder); + String aliasName = StrUtil.isBlank(groupInfo.aliasName) ? groupInfo.fieldName : groupInfo.aliasName; + // selectGroupFieldList中的元素,目前只是被export操作使用。会根据集合中的元素名称匹配导出表头。 + groupParam.selectGroupFieldList.add(aliasName); + if (++i < groupParam.size()) { + groupByBuilder.append(", "); + groupSelectBuilder.append(", "); + } + } + groupParam.groupCriteria = new MyGroupCriteria(groupByBuilder.toString(), groupSelectBuilder.toString()); + return groupParam; + } + + private GroupBaseData parseGroupBaseData(GroupInfo groupInfo, Class modelClazz) { + GroupBaseData baseData = new GroupBaseData(); + if (StrUtil.isBlank(groupInfo.fieldName)) { + throw new IllegalArgumentException("GroupInfo.fieldName can't be EMPTY"); + } + String[] stringArray = StrUtil.splitToArray(groupInfo.fieldName, '.'); + if (stringArray.length == 1) { + baseData.modelName = modelClazz.getSimpleName(); + baseData.fieldName = groupInfo.fieldName; + baseData.tableName = MyModelUtil.mapToTableName(modelClazz); + baseData.columnName = MyModelUtil.mapToColumnName(groupInfo.fieldName, modelClazz); + } else { + Field field = ReflectUtil.getField(modelClazz, stringArray[0]); + if (field == null) { + throw new InvalidClassFieldException(modelClazz.getSimpleName(), stringArray[0]); + } + Class fieldClazz = field.getType(); + baseData.modelName = fieldClazz.getSimpleName(); + baseData.fieldName = stringArray[1]; + baseData.tableName = MyModelUtil.mapToTableName(fieldClazz); + baseData.columnName = MyModelUtil.mapToColumnName(baseData.fieldName, fieldClazz); + } + return baseData; + } + + private void processGroupInfo( + GroupInfo groupInfo, GroupBaseData baseData, StringBuilder groupBy, StringBuilder groupSelect) { + String tableName = baseData.tableName; + String columnName = baseData.columnName; + if (StrUtil.isBlank(groupInfo.dateAggregateBy)) { + groupBy.append(tableName).append(".").append(columnName); + groupSelect.append(tableName).append(".").append(columnName); + if (StrUtil.isNotBlank(groupInfo.aliasName)) { + groupSelect.append(" ").append(groupInfo.aliasName); + } + return; + } + if (coreProperties.isMySql() || coreProperties.isDm()) { + this.processMySqlGroupInfoWithDateAggregation(groupInfo, baseData, groupBy, groupSelect); + } else if (coreProperties.isPostgresql() || coreProperties.isOpenGauss()) { + this.processPostgreSqlGroupInfoWithDateAggregation(groupInfo, baseData, groupBy, groupSelect); + } else if (coreProperties.isOracle() || coreProperties.isKingbase()) { + this.processOracleGroupInfoWithDateAggregation(groupInfo, baseData, groupBy, groupSelect); + } else { + throw new UnsupportedOperationException("Unsupport Database Type."); + } + if (StrUtil.isNotBlank(groupInfo.aliasName)) { + groupSelect.append(" ").append(groupInfo.aliasName); + } else { + groupSelect.append(" ").append(columnName); + } + } + + private void processMySqlGroupInfoWithDateAggregation( + GroupInfo groupInfo, GroupBaseData baseData, StringBuilder groupBy, StringBuilder groupSelect) { + groupBy.append("DATE_FORMAT(") + .append(baseData.tableName).append(".").append(baseData.columnName); + groupSelect.append("DATE_FORMAT(") + .append(baseData.tableName).append(".").append(baseData.columnName); + if (ApplicationConstant.DAY_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", '%Y-%m-%d')"); + groupSelect.append(", '%Y-%m-%d')"); + } else if (ApplicationConstant.MONTH_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", '%Y-%m-01')"); + groupSelect.append(", '%Y-%m-01')"); + } else if (ApplicationConstant.YEAR_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", '%Y-01-01')"); + groupSelect.append(", '%Y-01-01')"); + } else { + throw new IllegalArgumentException("Illegal DATE_FORMAT for GROUP ID list."); + } + } + + private void processPostgreSqlGroupInfoWithDateAggregation( + GroupInfo groupInfo, GroupBaseData baseData, StringBuilder groupBy, StringBuilder groupSelect) { + String toCharFunc = "TO_CHAR("; + String dateFormat = ", 'YYYY-MM-dd')"; + groupBy.append(toCharFunc) + .append(baseData.tableName).append(".").append(baseData.columnName); + groupSelect.append(toCharFunc) + .append(baseData.tableName).append(".").append(baseData.columnName); + if (ApplicationConstant.DAY_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(dateFormat); + groupSelect.append(dateFormat); + } else if (ApplicationConstant.YEAR_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", 'YYYY-01-01')"); + groupSelect.append(", 'YYYY-01-01')"); + } else if (ApplicationConstant.MONTH_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", 'YYYY-MM-01')"); + groupSelect.append(", 'YYYY-MM-01')"); + } else { + throw new IllegalArgumentException("Illegal TO_CHAR for GROUP ID list."); + } + } + + private void processOracleGroupInfoWithDateAggregation( + GroupInfo groupInfo, GroupBaseData baseData, StringBuilder groupBy, StringBuilder groupSelect) { + String toCharFunc = "TO_CHAR("; + String dateFormat = ", 'YYYY-MM-dd')"; + groupBy.append(toCharFunc) + .append(baseData.tableName).append(".").append(baseData.columnName); + groupSelect.append(toCharFunc) + .append(baseData.tableName).append(".").append(baseData.columnName); + if (ApplicationConstant.DAY_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(dateFormat); + groupSelect.append(dateFormat); + } else if (ApplicationConstant.MONTH_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", 'YYYY-MM') || '-01'"); + groupSelect.append(", 'YYYY-MM') || '-01'"); + } else if (ApplicationConstant.YEAR_AGGREGATION.equals(groupInfo.dateAggregateBy)) { + groupBy.append(", 'YYYY') || '-01-01'"); + groupSelect.append(", 'YYYY') || '-01-01'"); + } else { + throw new IllegalArgumentException("Illegal TO_CHAR for GROUP ID list."); + } + } + + /** + * 分组信息对象。 + */ + @Data + public static class GroupInfo { + /** + * Java对象的字段名。目前主要包含三种格式: + * 1. 简单的属性名称,如userId,将会直接映射到与其关联的数据库字段。表名为当前ModelClazz所对应的表名。 + * 映射结果或为 my_main_table.user_id + * 2. 一对一关联表属性,如user.userId,这里将先获取user属性的对象类型并映射到对应的表名,后面的userId为 + * user所在实体的属性。映射结果或为:my_sys_user.user_id + */ + private String fieldName; + /** + * SQL语句的Select List中,分组字段的别名。如果别名为NULL,直接取fieldName。 + */ + private String aliasName; + /** + * 如果该值不为NULL,则会对分组字段进行DATE_FORMAT函数的计算,并根据具体的值,将日期数据截取到指定的位。 + * day: 表示按照天聚合,将会截取到天。DATE_FORMAT(columnName, '%Y-%m-%d') + * month: 表示按照月聚合,将会截取到月。DATE_FORMAT(columnName, '%Y-%m-01') + * year: 表示按照年聚合,将会截取到年。DATE_FORMAT(columnName, '%Y-01-01') + */ + private String dateAggregateBy; + } + + private static class GroupBaseData { + private String modelName; + private String fieldName; + private String tableName; + private String columnName; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyOrderParam.java b/common/common-core/src/main/java/apelet/common/core/object/MyOrderParam.java new file mode 100644 index 0000000..8c7a8ea --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyOrderParam.java @@ -0,0 +1,300 @@ +package apelet.common.core.object; + +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.exception.InvalidClassFieldException; +import apelet.common.core.exception.InvalidDataFieldException; +import apelet.common.core.exception.InvalidDataModelException; +import apelet.common.core.util.MyModelUtil; +import cn.hutool.core.util.ReflectUtil; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Controller参数中的排序请求对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@EqualsAndHashCode(callSuper = true) +@Slf4j +@Data +public class MyOrderParam extends ArrayList { + + private static final String DICT_MAP = "DictMap."; + private static final Map, MyOrderParam> DEFAULT_ORDER_PARAM_MAP = new ConcurrentHashMap<>(); + + /** + * 基于排序对象中的JSON数据,构建SQL中order by从句可以直接使用的排序字符串。 + * 注意:如果orderParam为NULL,则会通过modelClazz对象推演出主键字典名,并按照主键倒排的方式生成默认的排序对象。 + * + * @param orderParam 排序参数对象。 + * @param modelClazz 查询主表对应的主对象的Class。 + * @return SQL中order by从句可以直接使用的排序字符串。 + */ + public static String buildOrderBy(MyOrderParam orderParam, Class modelClazz) { + return buildOrderBy(orderParam, modelClazz, true); + } + + /** + * 基于排序对象中的JSON数据,构建SQL中order by从句可以直接使用的排序字符串。 + * 注意:如果orderParam为NULL,则会通过modelClazz对象推演出主键字典名,并按照主键倒排的方式生成默认的排序对象。 + * + * @param orderParam 排序参数对象。 + * @param modelClazz 查询主表对应的主对象的Class。 + * @param addDefaultIfNull 如果为true,当orderParam参数为NULL是,则自动添加基于主键倒排序的索引。 + * @return SQL中order by从句可以直接使用的排序字符串。 + */ + public static String buildOrderBy(MyOrderParam orderParam, Class modelClazz, boolean addDefaultIfNull) { + if (orderParam == null) { + if (!addDefaultIfNull) { + return null; + } + orderParam = getAndSetDefaultOrderParam(modelClazz); + } + if (modelClazz == null) { + throw new IllegalArgumentException( + "modelClazz Argument in MyOrderParam.buildOrderBy can't be NULL"); + } + int i = 0; + StringBuilder orderBy = new StringBuilder(128); + for (OrderInfo orderInfo : orderParam) { + if (StringUtils.isBlank(orderInfo.getFieldName())) { + continue; + } + OrderBaseData orderBaseData = parseOrderBaseData(orderInfo, modelClazz); + if (StringUtils.isBlank(orderBaseData.tableName)) { + throw new InvalidDataModelException(orderBaseData.modelName); + } + if (StringUtils.isBlank(orderBaseData.columnName)) { + throw new InvalidDataFieldException(orderBaseData.modelName, orderBaseData.fieldName); + } + processOrderInfo(orderInfo, orderBaseData, orderBy); + if (++i < orderParam.size()) { + orderBy.append(", "); + } + } + return orderBy.toString(); + } + + private static MyOrderParam getAndSetDefaultOrderParam(Class modelClazz) { + MyOrderParam orderParam = DEFAULT_ORDER_PARAM_MAP.get(modelClazz); + if (orderParam != null) { + return orderParam; + } + orderParam = new MyOrderParam(); + DEFAULT_ORDER_PARAM_MAP.put(modelClazz, orderParam); + Field[] fields = ReflectUtil.getFields(modelClazz); + for (Field field : fields) { + if (field.getAnnotation(TableId.class) != null) { + orderParam.add(new OrderInfo(field.getName(), false, null)); + break; + } + } + return orderParam; + } + + private static void processOrderInfo( + OrderInfo orderInfo, OrderBaseData orderBaseData, StringBuilder orderByBuilder) { + if (StringUtils.isNotBlank(orderInfo.dateAggregateBy)) { + orderByBuilder.append("DATE_FORMAT(") + .append(orderBaseData.tableName).append(".").append(orderBaseData.columnName); + if (ApplicationConstant.DAY_AGGREGATION.equals(orderInfo.dateAggregateBy)) { + orderByBuilder.append(", '%Y-%m-%d')"); + } else if (ApplicationConstant.MONTH_AGGREGATION.equals(orderInfo.dateAggregateBy)) { + orderByBuilder.append(", '%Y-%m-01')"); + } else if (ApplicationConstant.YEAR_AGGREGATION.equals(orderInfo.dateAggregateBy)) { + orderByBuilder.append(", '%Y-01-01')"); + } else { + throw new IllegalArgumentException("Illegal DATE_FORMAT for GROUP ID list."); + } + } else { + orderByBuilder.append(orderBaseData.tableName).append(".").append(orderBaseData.columnName); + } + if (orderInfo.asc != null && !orderInfo.asc) { + orderByBuilder.append(" DESC"); + } + } + + private static OrderBaseData parseOrderBaseData(OrderInfo orderInfo, Class modelClazz) { + OrderBaseData orderBaseData = new OrderBaseData(); + orderBaseData.fieldName = StringUtils.substringBefore(orderInfo.fieldName, DICT_MAP); + String[] stringArray = StringUtils.split(orderBaseData.fieldName, '.'); + if (stringArray.length == 1) { + orderBaseData.modelName = modelClazz.getSimpleName(); + orderBaseData.tableName = MyModelUtil.mapToTableName(modelClazz); + orderBaseData.columnName = MyModelUtil.mapToColumnName(orderBaseData.fieldName, modelClazz); + } else { + Field field = ReflectUtil.getField(modelClazz, stringArray[0]); + if (field == null) { + throw new InvalidClassFieldException(modelClazz.getSimpleName(), stringArray[0]); + } + Class fieldClazz = field.getType(); + orderBaseData.modelName = fieldClazz.getSimpleName(); + orderBaseData.fieldName = stringArray[1]; + orderBaseData.tableName = MyModelUtil.mapToTableName(fieldClazz); + orderBaseData.columnName = MyModelUtil.mapToColumnName(orderBaseData.fieldName, fieldClazz); + } + return orderBaseData; + } + + /** + * 在排序列表中,可能存在基于指定表字段的排序,该函数将获取指定表的所有排序字段。 + * 返回的字符串,可直接用于SQL中的ORDER BY从句。 + * + * @param orderParam 排序参数对象。 + * @param modelClazz 查询主表对应的主对象的Class。 + * @param relationModelName 与关联表对应的Model的名称,如my_course_paper表应对的Java对象CoursePaper。 + * 如果该值为null或空字符串,则获取所有主表的排序字段。 + * @return 返回的是表字段,而非Java对象的属性,多个字段之间逗号分隔。 + */ + public static String getOrderClauseByModelName( + MyOrderParam orderParam, Class modelClazz, String relationModelName) { + if (orderParam == null) { + return null; + } + if (modelClazz == null) { + throw new IllegalArgumentException( + "modelClazz Argument in MyOrderParam.getOrderClauseByModelName can't be NULL"); + } + List fieldNameList = new LinkedList<>(); + String prefix = null; + if (StringUtils.isNotBlank(relationModelName)) { + prefix = relationModelName + "."; + } + for (OrderInfo orderInfo : orderParam) { + OrderBaseData baseData = parseOrderBaseData(orderInfo, modelClazz, prefix, relationModelName); + if (baseData != null) { + fieldNameList.add(makeOrderBy(baseData, orderInfo.asc)); + } + } + return StringUtils.join(fieldNameList, ", "); + } + + private static OrderBaseData parseOrderBaseData( + OrderInfo orderInfo, Class modelClazz, String prefix, String relationModelName) { + OrderBaseData baseData = null; + String fieldName = StringUtils.substringBefore(orderInfo.fieldName, DICT_MAP); + if (prefix != null) { + if (fieldName.startsWith(prefix)) { + baseData = new OrderBaseData(); + Field field = ReflectUtil.getField(modelClazz, relationModelName); + if (field == null) { + throw new InvalidClassFieldException(modelClazz.getSimpleName(), relationModelName); + } + Class fieldClazz = field.getType(); + baseData.modelName = fieldClazz.getSimpleName(); + baseData.fieldName = StringUtils.removeStart(fieldName, prefix); + baseData.tableName = MyModelUtil.mapToTableName(fieldClazz); + baseData.columnName = MyModelUtil.mapToColumnName(fieldName, fieldClazz); + } + } else { + String dotLimitor = "."; + if (!fieldName.contains(dotLimitor)) { + baseData = new OrderBaseData(); + baseData.modelName = modelClazz.getSimpleName(); + baseData.tableName = MyModelUtil.mapToTableName(modelClazz); + baseData.columnName = MyModelUtil.mapToColumnName(fieldName, modelClazz); + } + } + return baseData; + } + + private static String makeOrderBy(OrderBaseData baseData, Boolean asc) { + if (StringUtils.isBlank(baseData.tableName)) { + throw new InvalidDataModelException(baseData.modelName); + } + if (StringUtils.isBlank(baseData.columnName)) { + throw new InvalidDataFieldException(baseData.modelName, baseData.fieldName); + } + StringBuilder orderBy = new StringBuilder(128); + orderBy.append(baseData.tableName).append(".").append(baseData.columnName); + if (asc != null && !asc) { + orderBy.append(" DESC"); + } + return orderBy.toString(); + } + + /** + * 在排序列表中,可能存在基于指定表字段的排序,该函数将删除指定表的所有排序字段。 + * + * @param orderParam 排序参数对象。 + * @param modelClazz 查询主表对应的主对象的Class。 + * @param relationModelName 与关联表对应的Model的名称,如my_course_paper表应对的Java对象CoursePaper。 + * 如果该值为null或空字符串,则获取所有主表的排序字段。 + */ + public static void removeOrderClauseByModelName( + MyOrderParam orderParam, Class modelClazz, String relationModelName) { + if (orderParam == null) { + return; + } + if (modelClazz == null) { + throw new IllegalArgumentException( + "modelClazz Argument in MyOrderParam.removeOrderClauseByModelName can't be NULL"); + } + List fieldIndexList = new LinkedList<>(); + String prefix = null; + if (StringUtils.isNotBlank(relationModelName)) { + prefix = relationModelName + "."; + } + int i = 0; + for (OrderInfo orderInfo : orderParam) { + String fieldName = StringUtils.substringBefore(orderInfo.fieldName, DICT_MAP); + if (prefix != null) { + if (fieldName.startsWith(prefix)) { + fieldIndexList.add(i); + } + } else { + if (!fieldName.contains(".")) { + fieldIndexList.add(i); + } + } + ++i; + } + for (int index : fieldIndexList) { + orderParam.remove(index); + } + } + + /** + * 排序信息对象。 + */ + @AllArgsConstructor + @NoArgsConstructor + @Data + public static class OrderInfo { + /** + * Java对象的字段名。如果fieldName为空,则忽略跳过。目前主要包含三种格式: + * 1. 简单的属性名称,如userId,将会直接映射到与其关联的数据库字段。表名为当前ModelClazz所对应的表名。 + * 映射结果或为 my_main_table.user_id + * 2. 字典属性名称,如userIdDictMap.id,由于仅仅支持字典中Id数据的排序,所以直接截取DictMap之前的字符串userId作为排序属性。 + * 表名为当前ModelClazz所对应的表名。映射结果或为 my_main_table.user_id + * 3. 一对一关联表属性,如user.userId,这里将先获取user属性的对象类型并映射到对应的表名,后面的userId为 + * user所在实体的属性。映射结果或为:my_sys_user.user_id + */ + private String fieldName; + /** + * 排序方向。true为升序,否则降序。 + */ + private Boolean asc = true; + /** + * 如果该值不为NULL,则会对日期型排序字段进行DATE_FORMAT函数的计算,并根据具体的值,将日期数据截取到指定的位。 + * day: 表示按照天聚合,将会截取到天。DATE_FORMAT(columnName, '%Y-%m-%d') + * month: 表示按照月聚合,将会截取到月。DATE_FORMAT(columnName, '%Y-%m-01') + * year: 表示按照年聚合,将会截取到年。DATE_FORMAT(columnName, '%Y-01-01') + */ + private String dateAggregateBy; + } + + private static class OrderBaseData { + private String modelName; + private String fieldName; + private String tableName; + private String columnName; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyPageData.java b/common/common-core/src/main/java/apelet/common/core/object/MyPageData.java new file mode 100644 index 0000000..c8a9a40 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyPageData.java @@ -0,0 +1,45 @@ +package apelet.common.core.object; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedList; +import java.util.List; + +/** + * 分页数据的应答返回对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MyPageData { + /** + * 数据列表。 + */ + private List dataList; + /** + * 数据总数量。 + */ + private Long totalCount; + + private String tableName; + + public MyPageData(List dataList,Long totalCount){ + + this.dataList = dataList; + this.totalCount = totalCount; + + } + + /** + * 为了保持前端的数据格式兼容性,在没有数据的时候,需要返回空分页对象。 + * @return 空分页对象。 + */ + public static MyPageData emptyPageData() { + return new MyPageData<>(new LinkedList<>(), 0L,null); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyPageParam.java b/common/common-core/src/main/java/apelet/common/core/object/MyPageParam.java new file mode 100644 index 0000000..2028315 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyPageParam.java @@ -0,0 +1,74 @@ +package apelet.common.core.object; + +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import lombok.Getter; + +/** + * Controller参数中的分页请求对象 + * + * @author guifc + * @date 2023-08-04 + */ +@Getter +public class MyPageParam { + + public static final int DEFAULT_PAGE_NUM = 1; + public static final int DEFAULT_PAGE_SIZE = 10; + public static final int DEFAULT_MAX_SIZE = 2000; + + /** + * 分页号码,从1开始计数。 + */ + @McpToolParam(fieldName = "pageNum", type = McpToolParamType.INTEGER, description = "分页页号,从1开始") + private Integer pageNum; + + /** + * 每页大小。 + */ + @McpToolParam(fieldName = "pageSize", type = McpToolParamType.INTEGER, description = "每页大小") + private Integer pageSize; + + /** + * 是否统计totalCount + */ + @McpToolParam(fieldName = "count", type = McpToolParamType.BOOLEAN, description = "是否统计 totalCount", required = false) + private Boolean count = true; + + /** + * 设置当前分页页号。 + * + * @param pageNum 页号,如果传入非法值,则使用缺省值。 + */ + public void setPageNum(Integer pageNum) { + if (pageNum == null) { + return; + } + if (pageNum <= 0) { + pageNum = DEFAULT_PAGE_NUM; + } + this.pageNum = pageNum; + } + + /** + * 设置分页的大小。 + * + * @param pageSize 分页大小,如果传入非法值,则使用缺省值。 + */ + public void setPageSize(Integer pageSize) { + if (pageSize == null) { + return; + } + if (pageSize <= 0) { + pageSize = DEFAULT_PAGE_SIZE; + } + if (pageSize > DEFAULT_MAX_SIZE) { + pageSize = DEFAULT_MAX_SIZE; + } + this.pageSize = pageSize; + } + + public void setCount(Boolean count) { + this.count = count; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyPrintInfo.java b/common/common-core/src/main/java/apelet/common/core/object/MyPrintInfo.java new file mode 100644 index 0000000..184da5c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyPrintInfo.java @@ -0,0 +1,32 @@ +package apelet.common.core.object; + +import com.alibaba.fastjson.JSONArray; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 打印信息对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@NoArgsConstructor +public class MyPrintInfo { + + /** + * 打印模板Id。 + */ + private Long printId; + /** + * 打印参数列表。对应于common-report模块的ReportPrintParam对象。 + */ + private List printParams; + + public MyPrintInfo(Long printId, List printParams) { + this.printId = printId; + this.printParams = printParams; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyQueryParam.java b/common/common-core/src/main/java/apelet/common/core/object/MyQueryParam.java new file mode 100644 index 0000000..0e8867a --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyQueryParam.java @@ -0,0 +1,129 @@ +package apelet.common.core.object; + +import cn.hutool.core.bean.BeanUtil; +import lombok.Data; + +import java.util.*; + +/** + * 查询参数。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class MyQueryParam { + + /** + * Select中返回的Java属性列表。 + */ + private List selectFieldList; + + /** + * 用于数据过滤的DTO对象。 + */ + private Map filterMap; + + /** + * 聚合计算是否使用数据权限进行过滤。true表示数据过滤将产生作用,否则SQL中不会包含数据过滤。 + * 目前数据过滤包括数据权限过滤和租户数据过滤。 + */ + private Boolean useDataFilter = true; + + /** + * (In-list) 实体对象中的过滤字段(而非数据表列名),需和下面的inFilterValues字段一起使用。 + * NOTE: MyWhereCriteria中的IN类型过滤条件,完全可以替代该字段。之所以保留主要是为了保证更好的接口可读性。 + */ + private String inFilterField; + + /** + * (In-list) 过滤数据集合。 + */ + private Set inFilterValues; + + /** + * 过滤条件列表。 + */ + private List criteriaList; + + /** + * 排序对象。 + */ + private MyOrderParam orderParam; + + /** + * 分页对象。 + */ + private MyPageParam pageParam; + + /** + * 是否包含关联字典数据 + */ + private Boolean withDict = false; + + /** + * 参与模糊搜索的Java字段列表。 + */ + private List searchStringFieldList; + + /** + * 模糊搜索的字符串值,该值要包含百分号,这样可以精确控制搜索参数中百分号的配置。 + */ + private String searchStringValue; + + /** + * 忽略的脱敏字段名集合。 + */ + private Set ignoreMaskFieldSet; + + /** + * 缺省构造函数。 + */ + public MyQueryParam() { + } + + /** + * 构造函数。 + * + * @param withDict 是否关联字典数据。 + */ + public MyQueryParam(Boolean withDict) { + this.withDict = withDict; + } + + /** + * 将参数中Dto类型的过滤对象转换为内部的Map对象,便于服务间传输。 + * + * @param filterDto 过滤对象。 + * @param Dto对象的类型。 + */ + public void setFilterDto(T filterDto) { + this.filterMap = BeanUtil.beanToMap(filterDto); + } + + /** + * 将内部的过滤Map对象转换为指定类型的Dto对象并返回。 + * + * @param filterClazz Dto对象的Class对象。 + * @param Dto对象的类型。 + * @return 如果filterMap字段为空,则返回空对象。 + */ + public T getFilterDto(Class filterClazz) { + if (filterMap == null) { + return null; + } + return BeanUtil.toBeanIgnoreError(this.filterMap, filterClazz); + } + + /** + * 添加自定义过滤条件。 + * + * @param criteria 自定义过滤条件。 + */ + public void addCriteriaList(MyWhereCriteria criteria) { + if (this.criteriaList == null) { + criteriaList = new LinkedList<>(); + } + criteriaList.add(criteria); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyRelationParam.java b/common/common-core/src/main/java/apelet/common/core/object/MyRelationParam.java new file mode 100644 index 0000000..0a0eadb --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyRelationParam.java @@ -0,0 +1,153 @@ +package apelet.common.core.object; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 实体对象数据组装参数构建器。 + * BaseService中的实体对象数据组装函数,会根据该参数对象进行数据组装。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@Builder +public class MyRelationParam { + + /** + * 是否组装字典关联的标记。 + * 组装RelationDict和RelationConstDict注解标记的字段。 + */ + private boolean buildDict; + + /** + * 是否组装远程字典关联的标记。 + * 组装RelationDict和RelationConstDict注解标记的字段。 + */ + private boolean buildRemoteDict; + + /** + * 是否组装一对一关联的标记。 + * 组装RelationOneToOne注解标记的字段。 + */ + private boolean buildOneToOne; + + /** + * 是否组装远程一对一关联的标记。 + * 组装RelationOneToOne注解标记的字段。 + */ + private boolean buildRemoteOneToOne; + + /** + * 在组装一对一关联的同时,是否继续关联从表中的字典。 + * 从表中RelationDict和RelationConstDict注解标记的字段。 + * 该字段为true时,无需设置buildOneToOne了。 + */ + private boolean buildOneToOneWithDict; + + /** + * 在组装远程一对一关联的同时,是否继续关联从表中的字典。 + * 从表中RelationDict和RelationConstDict注解标记的字段。 + * 该字段为true时,无需设置buildOneToOne了。 + */ + private boolean buildRemoteOneToOneWithDict; + + /** + * 是否组装本地一对多关联的标记。 + * 组装RelationOneToMany注解标记的字段。 + */ + private boolean buildOneToMany; + + /** + * 是否组装主表对多对多中间表关联的标记。 + * 组装RelationManyToMany注解标记的字段。 + */ + private boolean buildRelationManyToMany; + + /** + * 是否组装聚合计算关联的标记。 + * 组装RelationOneToManyAggregation和RelationManyToManyAggregation注解标记的字段。 + */ + private boolean buildAggregation; + + /** + * 是否组装远程聚合计算关联的标记。 + * 组装RelationOneToManyAggregation和RelationManyToManyAggregation注解标记的字段。 + */ + private boolean buildRemoteAggregation; + + /** + * 关联表中,需要忽略的脱敏字段名。key是关联表实体对象名,如SysUser,value是对象字段名的集合,如userId。 + */ + @Getter + private Map> ignoreMaskFieldMap; + + /** + * 关联表中需要忽略的脱敏字段结合。 + * @param ignoreRelationMaskFieldSet 数据项格式为"实体对象名.对象属性名",如 sysUser.userId。 + */ + public void setIgnoreMaskFieldSet(Set ignoreRelationMaskFieldSet) { + if (CollUtil.isEmpty(ignoreRelationMaskFieldSet)) { + return; + } + ignoreMaskFieldMap = MapUtil.newHashMap(); + for (String ignoreField : ignoreRelationMaskFieldSet) { + String[] fullFieldName = StrUtil.splitToArray(ignoreField, "."); + Set ignoreMaskFieldSet = + ignoreMaskFieldMap.computeIfAbsent(fullFieldName[0], k -> new HashSet<>()); + ignoreMaskFieldSet.add(fullFieldName[1]); + } + } + + /** + * 便捷方法,返回仅做字典关联的参数对象。 + * + * @return 返回仅做字典关联的参数对象。 + */ + public static MyRelationParam dictOnly() { + return MyRelationParam.builder().buildDict(true).buildRemoteDict(true).build(); + } + + /** + * 便捷方法,返回仅做字典关联、一对一从表及其字典和聚合计算的参数对象。 + * NOTE: 对于一对多和多对多,这种从表数据是列表结果的关联,均不返回。 + * + * @return 返回仅做字典关联、一对一从表及其字典和聚合计算的参数对象。 + */ + public static MyRelationParam normal() { + return MyRelationParam.builder() + .buildDict(true) + .buildRemoteDict(true) + .buildOneToOneWithDict(true) + .buildRemoteOneToOneWithDict(true) + .buildAggregation(true) + .buildRemoteAggregation(true) + .build(); + } + + /** + * 便捷方法,返回全部关联的参数对象。 + * + * @return 返回全部关联的参数对象。 + */ + public static MyRelationParam full() { + return MyRelationParam.builder() + .buildDict(true) + .buildRemoteDict(true) + .buildOneToOneWithDict(true) + .buildRemoteOneToOneWithDict(true) + .buildAggregation(true) + .buildRemoteAggregation(true) + .buildRelationManyToMany(true) + .buildOneToMany(true) + .build(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyTreeConfig.java b/common/common-core/src/main/java/apelet/common/core/object/MyTreeConfig.java new file mode 100644 index 0000000..dd8a9fc --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyTreeConfig.java @@ -0,0 +1,24 @@ +package apelet.common.core.object; + +import com.alibaba.fastjson.JSONObject; +import lombok.Data; + +/** + * 分页数据的应答返回对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class MyTreeConfig { + + private Boolean isTree; + private Boolean isZere; + + private String treeField; + private String treeParentField; + + private JSONObject row; + + +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/MyWhereCriteria.java b/common/common-core/src/main/java/apelet/common/core/object/MyWhereCriteria.java new file mode 100644 index 0000000..7f9b313 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/MyWhereCriteria.java @@ -0,0 +1,377 @@ +package apelet.common.core.object; + +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.exception.InvalidDataFieldException; +import apelet.common.core.exception.InvalidDataModelException; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.util.MyModelUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ReflectUtil; +import com.alibaba.fastjson.annotation.JSONField; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.regex.Matcher; + +/** + * Where中的条件语句。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +@Data +@NoArgsConstructor +public class MyWhereCriteria { + + /** + * 等于 + */ + public static final int OPERATOR_EQUAL = 0; + + /** + * 不等于 + */ + public static final int OPERATOR_NOT_EQUAL = 1; + + /** + * 大于等于 + */ + public static final int OPERATOR_GE = 2; + + /** + * 大于 + */ + public static final int OPERATOR_GT = 3; + + /** + * 小于等于 + */ + public static final int OPERATOR_LE = 4; + + /** + * 小于 + */ + public static final int OPERATOR_LT = 5; + + /** + * LIKE + */ + public static final int OPERATOR_LIKE = 6; + + /** + * NOT NULL + */ + public static final int OPERATOR_NOT_NULL = 7; + + /** + * IS NULL + */ + public static final int OPERATOR_IS_NULL = 8; + + /** + * IN + */ + public static final int OPERATOR_IN = 9; + + /** + * 参与过滤的实体对象的Class。 + */ + @JSONField(serialize = false) + private Class modelClazz; + + /** + * 数据库表名。 + */ + private String tableName; + + /** + * Java属性名称。 + */ + private String fieldName; + + /** + * 数据表字段名。 + */ + private String columnName; + + /** + * 数据表字段类型。 + */ + private Integer columnType; + + /** + * 操作符类型,取值范围见上面的常量值。 + */ + private Integer operatorType; + + /** + * 条件数据值。 + */ + private Object value; + + public MyWhereCriteria(Class modelClazz, String fieldName, Integer operatorType, Object value) { + this.modelClazz = modelClazz; + this.fieldName = fieldName; + this.operatorType = operatorType; + this.value = value; + } + + /** + * 设置条件值。 + * + * @param fieldName 条件所属的实体对象的字段名。 + * @param operatorType 条件操作符。具体值可参考当前对象的静态变量。 + * @param value 条件过滤值。 + * @return 验证结果对象,如果有错误将会返回具体的错误信息。 + */ + public CallResult setCriteria(String fieldName, Integer operatorType, Object value) { + this.operatorType = operatorType; + this.fieldName = fieldName; + this.value = value; + return doVerify(); + } + + /** + * 设置条件值。 + * + * @param modelClazz 数据表对应实体对象的Class. + * @param fieldName 条件所属的实体对象的字段名。 + * @param operatorType 条件操作符。具体值可参考当前对象的静态变量。 + * @param value 条件过滤值。 + * @return 验证结果对象,如果有错误将会返回具体的错误信息。 + */ + public CallResult setCriteria(Class modelClazz, String fieldName, Integer operatorType, Object value) { + this.modelClazz = modelClazz; + this.operatorType = operatorType; + this.fieldName = fieldName; + this.value = value; + return doVerify(); + } + + /** + * 设置条件值,通过该构造方法设置时,通常是直接将表名、字段名、字段类型等赋值,无需在通过modelClazz进行推演。 + * + * @param tableName 数据表名。 + * @param columnName 数据字段名。 + * @param columnType 数据字段类型。 + * @param operatorType 操作类型。具体值可参考当前对象的静态变量。 + * @param value 条件过滤值。 + */ + public void setCriteria( + String tableName, String columnName, String columnType, Integer operatorType, Object value) { + this.tableName = tableName; + this.columnName = columnName; + this.columnType = MyModelUtil.NUMERIC_FIELD_TYPE; + if (String.class.getSimpleName().equals(columnType)) { + this.columnType = MyModelUtil.STRING_FIELD_TYPE; + } else if (Date.class.getSimpleName().equals(columnType)) { + this.columnType = MyModelUtil.DATE_FIELD_TYPE; + } + this.operatorType = operatorType; + this.value = value; + } + + /** + * 在执行该函数之前,该对象的所有数据均已经赋值完毕。 + * 该函数主要验证操作符字段和条件值字段对应关系的合法性。 + * + * @return 验证结果对象,如果有错误将会返回具体的错误信息。 + */ + public CallResult doVerify() { + if (fieldName == null) { + return CallResult.error("过滤字段名称 [fieldName] 不能为空!"); + } + if (modelClazz != null && ReflectUtil.getField(modelClazz, fieldName) == null) { + return CallResult.error( + "过滤字段 [" + fieldName + "] 在实体对象 [" + modelClazz.getSimpleName() + "] 中并不存在!"); + } + if (!checkOperatorType()) { + return CallResult.error("无效的操作符类型 [" + operatorType + "]!"); + } + // 其他操作符必须包含value值 + if (operatorType != OPERATOR_IS_NULL && operatorType != OPERATOR_NOT_NULL && value == null) { + String operatorString = this.getOperatorString(); + return CallResult.error("操作符 [" + operatorString + "] 的条件值不能为空!"); + } + if (this.operatorType == OPERATOR_IN) { + if (!(value instanceof Collection)) { + return CallResult.error("操作符 [IN] 的条件值必须为集合对象!"); + } + if (CollUtil.isEmpty((Collection) value)) { + return CallResult.error("操作符 [IN] 的条件值不能为空!"); + } + } + return CallResult.ok(); + } + + /** + * 判断操作符类型是否合法。 + * + * @return 合法返回true,否则false。 + */ + public boolean checkOperatorType() { + return operatorType != null + && (operatorType >= OPERATOR_EQUAL && operatorType <= OPERATOR_IN); + } + + /** + * 获取操作符的字符串形式。 + * + * @return 操作符的字符串。 + */ + public String getOperatorString() { + switch (operatorType) { + case OPERATOR_EQUAL: + return " = "; + case OPERATOR_NOT_EQUAL: + return " != "; + case OPERATOR_GE: + return " >= "; + case OPERATOR_GT: + return " > "; + case OPERATOR_LE: + return " <= "; + case OPERATOR_LT: + return " < "; + case OPERATOR_LIKE: + return " LIKE "; + case OPERATOR_NOT_NULL: + return " IS NOT NULL "; + case OPERATOR_IS_NULL: + return " IS NULL "; + case OPERATOR_IN: + return " IN "; + default: + return null; + } + } + + /** + * 获取组装后的SQL Where从句,如 table_name.column_name = 'value'。 + * 与查询数据表对应的实体对象Class为当前对象的modelClazz字段。 + * + * @exception InvalidDataFieldException selectFieldList中存在非法实体字段时,抛出该异常。 + * @return 组装后的SQL条件从句。 + */ + public String makeCriteriaString() { + return makeCriteriaString(this.modelClazz); + } + + /** + * 获取组装后的SQL Where从句,如 table_name.column_name = 'value'。 + * + * @param modelClazz 与查询数据表对应的实体对象的Class。 + * @exception InvalidDataFieldException selectFieldList中存在非法实体字段时,抛出该异常。 + * @exception InvalidDataModelException 参数modelClazz没有对应的table,抛出该异常。 + * @return 组装后的SQL条件从句。 + */ + public String makeCriteriaString(Class modelClazz) { + String localTableName; + String localColumnName; + Integer localColumnType; + if (modelClazz != null) { + Tuple2 fieldInfo = MyModelUtil.mapToColumnInfo(fieldName, modelClazz); + if (fieldInfo == null) { + throw new InvalidDataFieldException(modelClazz.getSimpleName(), fieldName); + } + localColumnName = fieldInfo.getFirst(); + localColumnType = fieldInfo.getSecond(); + localTableName = MyModelUtil.mapToTableName(modelClazz); + if (localTableName == null) { + throw new InvalidDataModelException(modelClazz.getSimpleName()); + } + } else { + localTableName = this.tableName; + localColumnName = this.columnName; + localColumnType = this.columnType; + } + return this.buildClauseString(localTableName, localColumnName, localColumnType); + } + + /** + * 获取组装后的SQL Where从句。如 table_name.column_name = 'value'。 + * + * @param criteriaList 条件列表,所有条件直接目前仅支持 AND 的关系。 + * @exception InvalidDataFieldException selectFieldList中存在非法实体字段时,抛出该异常。 + * @return 组装后的SQL条件从句。 + */ + public static String makeCriteriaString(List criteriaList) { + return makeCriteriaString(criteriaList, null); + } + + /** + * 获取组装后的SQL Where从句。如 table_name.column_name = 'value'。 + * + * @param criteriaList 条件列表,所有条件直接目前仅支持 AND 的关系。 + * @param modelClazz 与数据表对应的实体对象的Class。 + * 如果不为NULL实体对象Class使用该值,否则使用每个MyWhereCriteria自身的modelClazz。 + * @exception InvalidDataFieldException selectFieldList中存在非法实体字段时,抛出该异常。 + * @return 组装后的SQL条件从句。 + */ + public static String makeCriteriaString(List criteriaList, Class modelClazz) { + if (CollUtil.isEmpty(criteriaList)) { + return null; + } + StringBuilder sb = new StringBuilder(256); + int i = 0; + for (MyWhereCriteria whereCriteria : criteriaList) { + Class clazz = modelClazz; + if (clazz == null) { + clazz = whereCriteria.modelClazz; + } + if (i++ != 0) { + sb.append(" AND "); + } + String criteriaString = whereCriteria.makeCriteriaString(clazz); + sb.append(criteriaString); + } + return sb.length() == 0 ? null : sb.toString(); + } + + private String buildClauseString(String tableName, String columnName, Integer columnType) { + StringBuilder sb = new StringBuilder(64); + sb.append(tableName).append(".").append(columnName).append(getOperatorString()); + if (operatorType == OPERATOR_IN) { + Collection filterValues = (Collection) value; + sb.append("("); + int i = 0; + for (Object filterValue : filterValues) { + this.doSqlInjectVerify(filterValue.toString()); + if (columnType.equals(MyModelUtil.NUMERIC_FIELD_TYPE)) { + sb.append(filterValue); + } else { + sb.append("'").append(filterValue).append("'"); + } + if (i++ != filterValues.size() - 1) { + sb.append(", "); + } + } + sb.append(")"); + return sb.toString(); + } + if (value == null) { + return sb.toString(); + } + this.doSqlInjectVerify(value.toString()); + if (columnType.equals(MyModelUtil.NUMERIC_FIELD_TYPE)) { + sb.append(value); + } else { + sb.append("'").append(value).append("'"); + } + return sb.toString(); + } + + private void doSqlInjectVerify(String v) { + Matcher matcher = ApplicationConstant.SQL_INJECT_PATTERN.matcher(v); + if (matcher.find()) { + String msg = String.format( + "The filterValue [%s] has SQL Inject Words", v); + throw new MyRuntimeException(msg); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ObjectCollection.java b/common/common-core/src/main/java/apelet/common/core/object/ObjectCollection.java new file mode 100644 index 0000000..0c4d3a0 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ObjectCollection.java @@ -0,0 +1,363 @@ +package apelet.common.core.object; + +import java.util.*; + +/** + * @author Soon + * @version 1.0 + * Create by 2024/4/14 22:06 + */ +public class ObjectCollection { + + protected List list; + protected volatile transient Map indexes; + + public int size() + { + return this.list.size(); + } + + public boolean isEmpty() + { + return this.list.isEmpty(); + } + + public boolean containsObject(ObjectValue o) + { + return this.list.contains(o); + } + + public boolean containsKey(Object key) + { + return getIndex().containsKey(key); + } + + public ObjectCollection(){ + list = new ArrayList<>(); + } + + private Map getIndex() + { + if (this.indexes != null) + { + return this.indexes; + } + + synchronized (this) + { + if (this.indexes == null) + { + HashMap idx = new HashMap(size()); + Iterator it = this.list.iterator(); + ObjectValue o = null; + int i = 0; + while (it.hasNext()) + { + o = (ObjectValue)it.next(); + if ((o.getPKField() != null) && (o.get(o.getPKField()) != null)) + { + idx.put(o.get(o.getPKField()), Integer.valueOf(i)); + } + else { + idx.put(Integer.valueOf(o.hashCode()), Integer.valueOf(i)); + } + + i += 1; + } + this.indexes = idx; + return idx; + } + + return this.indexes; + } + } + + public Iterator iterator() + { + return this.list.iterator(); + } + + public boolean addObject(ObjectValue o) + { + boolean add = false; + if ((o.getPKField() != null) && (o.get(o.getPKField()) != null)) + { + Object tmp = o.get(o.getPKField()); + int i = indexOf(tmp); + if (i != -1) + { + this.list.set(i, o); + add = true; + return add; + } + } + add = this.list.add(o); + Integer size = Integer.valueOf(this.list.size() - 1); + if ((o.getPKField() != null) && (o.get(o.getPKField()) != null)) + { + getIndex().put(o.get(o.getPKField()), size); + } + else { + getIndex().put(Integer.valueOf(o.hashCode()), size); + } + return add; + } + + public boolean addObjectCollection(ObjectCollection c) + { + if (c == null) + { + return false; + } + + if (c == this) + { + return true; + } + + int i = 0; for (int size = c.size(); i < size; ++i) + { + addObject(c.getObject(i)); + } + return true; + } + + public boolean removeObject(ObjectValue o) + { + + int seq = this.list.indexOf(o); + boolean remove = this.list.remove(o); + innerUpdateIndex(); + return remove; + } + + private final void innerUpdateIndex() + { + Map i = this.indexes; + this.indexes = null; + if (i == null) + return; + i.clear(); + } + + public int indexOf(ObjectValue o) + { + return this.list.indexOf(o); + } + + public int indexOf(Object key) + { + Integer i = (Integer)getIndex().get(key); + if (i != null) { + return i.intValue(); + } + return -1; + } + + public int lastIndexOf(ObjectValue o) + { + return this.list.lastIndexOf(o); + } + + public void clear() + { + this.list.clear(); + this.indexes = null; + } + + public boolean equals(Object that) + { + if (that == this) + { + return true; + } + + if ((that == null) || (!(that instanceof ObjectCollection))) + { + return false; + } + + if (!(super.getClass().getName().equals(that.getClass().getName()))) + { + return false; + } + + return this.list.equals(((ObjectCollection)that).list); + } + + public int hashCode() + { + return this.list.hashCode(); + } + + public String toString() + { + return this.list.toString(); + } + + public ObjectValue getObject(int index) + { + if ((index >= this.list.size()) || (index < 0)) + { + return null; + } + + Object o = this.list.get(index); + if (o == null) + { + return null; + } + + return ((ObjectValue)o); + } + + public ObjectValue getObject(Object key) + { + int i = indexOf(key); + if (i == -1) + { + return null; + } + return getObject(i); + } + + public ObjectValue removeObject(Object key) + { + int i = indexOf(key); + if (i == -1) + { + return null; + } + return removeObject(i); + } + + public ObjectValue setObject(int index, ObjectValue element) + { + return setObject(index, element, true); + } + + private ObjectValue setObject(int index, ObjectValue element, boolean checkDup) + { + + ObjectValue oldObj = getObject(index); + + + if ((checkDup) && + (element.getPKField() != null) && (element.get(element.getPKField()) != null)) + { + Object tmp = element.get(element.getPKField()); + int i = indexOf(tmp); + if (i != -1) + { + if (i > index) + { + this.list.remove(i); + } else if (i < index) + { + this.list.remove(i); + --index; + } + } + + } + + ObjectValue set = (ObjectValue)this.list.set(index, element); + innerUpdateIndex(); + + + + return set; + } + + public void appendObject(ObjectValue element) + { + addObject(size(), element); + } + + public void addObject(int index, ObjectValue element) + { + + + if ((element.getPKField() != null) && (element.get(element.getPKField()) != null)) + { + Object tmp = element.get(element.getPKField()); + int i = indexOf(tmp); + if (i != -1) + { + this.list.remove(i); + if (i <= index) { + --index; + } + } + } + if (index < 0) + { + index = 0; + } + + this.list.add(index, element); + innerUpdateIndex(); + + } + + public void insertObject(int index, ObjectValue ov) + { + addObject(index + 1, ov); + } + + public void insertObject(Object key, ObjectValue ov) + { + if (key == null) + { + appendObject(ov); + } + + int i = indexOf(key); + if (i == -1) + { + appendObject(ov); + } + else + addObject(i + 1, ov); + } + + public void insertObject(ObjectValue old, ObjectValue ov) + { + if (old == null) + { + appendObject(ov); + } + + int i = indexOf(old); + if (i == -1) + { + appendObject(ov); + } + else + addObject(i + 1, ov); + } + + public ObjectValue removeObject(int index) + { + ObjectValue item = getObject(index); + + + ObjectValue objVal = (ObjectValue)this.list.remove(index); + innerUpdateIndex(); + + + return objVal; + } + + public void changeIndex(int oldIndex, int newIndex) + { + ObjectValue o = getObject(oldIndex); + + + this.list.remove(oldIndex); + this.list.add(newIndex, o); + innerUpdateIndex(); + + } + +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java b/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java new file mode 100644 index 0000000..603c611 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java @@ -0,0 +1,412 @@ +package apelet.common.core.object; + +import apelet.common.core.exception.IllegalConversionException; +import apelet.common.core.util.TypeConversionUtils; +import cn.hutool.core.util.ObjectUtil; +import lombok.Data; + +import java.io.IOException; +import java.io.Serializable; +import java.math.BigDecimal; +import java.sql.Time; +import java.sql.Timestamp; +import java.util.*; + +/** + * 动态实体对象 + * @author Soon + * @version 1.0 + * Create by 2024/4/12 9:14 + */ +@Data +public class ObjectValue implements Serializable { + + private Long metaId; //表单元数据id + private final String tableName; + + private String pKField = "id";//TODO pk主键为id + + protected Map values; + + public ObjectValue(String tableName){ + this.tableName = tableName; + values = new HashMap(); + } + + public void setPkValue(Long id){ + setLong(pKField,id); + } + + public long getPkValue(){ + Object o1 = values.get(pKField); + if(o1 == null){ + return 0L; + }else if(values.get(pKField) instanceof String && !values.get(pKField).equals("null") ){ + try{ + return Long.valueOf((String) values.get(pKField)); + } catch (Exception e) { + return 0L; + } + + }else{ + return (Long) values.get(pKField); + } + } + + public int size() { + return this.values.size(); + } + + public boolean isEmpty() { + return this.values.isEmpty(); + } + + public Object get(String key) + { + Object obj = this.values.get(key.toLowerCase()); + return obj; + } + + public Object get(String key, Class clazz) { + Object obj = get(key); + if (obj == null) + return null; + if (clazz.isInstance(obj)) { + return obj; + } + throw new IllegalConversionException(); + } + + public Object get(String key, Object defaultValue) { + Object obj = this.values.get(key.toLowerCase()); + if (obj == null) { + return defaultValue; + } + return obj; + } + + public Object put(String key, Object value) + { + String k = key.toLowerCase(); + if (value == null) { + this.values.put(k, null); + return null; + } + return this.values.put(k, value); + } + + public ObjectValue getObjectValue(String key){ + return ((ObjectValue)get(key)); + } + + public ObjectValue putObjectValue(String key, ObjectValue value) + { + String k = key.toLowerCase(); + if (value == null) { + this.values.put(k, null); + return null; + } + return (ObjectValue) this.values.put(k, value); + } + + public ObjectCollection getObjectCollection(String key){ + return ((ObjectCollection)get(key)); + } + + public ObjectCollection putObjectCollection(String key, ObjectCollection value) + { + String k = key.toLowerCase(); + if (value == null) { + this.values.put(k, null); + return null; + } + return (ObjectCollection) this.values.put(k, value); + } + + public Object remove(String key) { + return this.values.remove(key.toLowerCase()); + } + + public boolean containsKey(String key) { + return this.values.containsKey(key.toLowerCase()); + } + + + + + public String getString(String name) { + return TypeConversionUtils.objToString(get(name)); + } + + public BigDecimal getBigDecimal(String name) throws IllegalConversionException + { + return TypeConversionUtils.objToBigDecimal(get(name)); + } + + public BigDecimal getBigDecimal(String name, int scale) throws IllegalConversionException + { + return TypeConversionUtils.objToBigDecimal(get(name), scale); + } + + public boolean getBoolean(String name) throws IllegalConversionException { + return TypeConversionUtils.objToBoolean(get(name)); + } + + public byte getByte(String name) throws IllegalConversionException { + return TypeConversionUtils.objToByte(get(name)); + } + + public short getShort(String name) throws IllegalConversionException { + return TypeConversionUtils.objToShort(get(name)); + } + + public int getInt(String name) throws IllegalConversionException { + return TypeConversionUtils.objToInt(get(name)); + } + + public long getLong(String name) throws IllegalConversionException { + return TypeConversionUtils.objToLong(get(name)); + } + + public float getFloat(String name) throws IllegalConversionException { + return TypeConversionUtils.objToFloat(get(name)); + } + + public double getDouble(String name) throws IllegalConversionException { + return TypeConversionUtils.objToDouble(get(name)); + } + + public Time getTime(String name) throws IllegalConversionException + { + return TypeConversionUtils.objToTime(get(name)); + } + + public java.sql.Date getDate(String name) throws IllegalConversionException + { + return TypeConversionUtils.objToDate(get(name)); + } + + public java.util.Date getUtilDate(String name) throws IllegalConversionException + { + return TypeConversionUtils.objToUtilDate(get(name)); + } + + public Timestamp getTimestamp(String name) throws IllegalConversionException + { + return TypeConversionUtils.objToTimestamp(get(name)); + } + + public byte[] getBytes(String name) throws IllegalConversionException { + return TypeConversionUtils.objToBytes(get(name)); + } + + + + public void setString(String key, String val) { + put(key, val); + } + + public void setBigDecimal(String key, BigDecimal val) { + put(key, val); + } + + public void setBoolean(String key, boolean b) + { + put(key, new Boolean(b)); + } + + public void setByte(String name, byte b) { + put(name, new Byte(b)); + } + + public void setShort(String name, short s) { + put(name, new Short(s)); + } + + public void setInt(String name, int i) { + put(name, Integer.valueOf(i)); + } + + public void setLong(String name, long l) { + put(name, Long.valueOf(l)); + } + + public void setFloat(String name, float f) { + put(name, new Float(f)); + } + + public void setDouble(String name, double d) { + put(name, new Double(d)); + } + + public void setBytes(String name, byte[] bytes) { + put(name, bytes); + } + + public void setDate(String name, java.sql.Date date) { + put(name, date); + } + + public void setTime(String name, Time time) { + put(name, time); + } + + public void setTimestamp(String name, Timestamp timestamp) { + put(name, timestamp); + } + + public void setDate(String name, java.util.Date date) { + put(name, date); + } + + public void setNull(String name) { + put(name, null); + } + + public boolean equals(Object that) { + IdentityStack thisRefStack = new IdentityStack(); + IdentityStack thatRefStack = new IdentityStack(); + return equals(this, that, thisRefStack, thatRefStack); + } + + private static final boolean equals(ObjectValue thisObject, Object thatObject, IdentityStack thisRefStack, IdentityStack thatRefStack) + { + if (thatObject == thisObject) + return true; + if (!(thatObject instanceof ObjectValue)) + return false; + if (!(thisObject.getClass().getName().equals(thatObject.getClass().getName()))) { + return false; + } + + int thisRefDeepth = thisRefStack.search(thisObject); + int thatRefDeepth = thatRefStack.search(thatObject); + if (thisRefDeepth >= 0) { + return (thisRefDeepth == thatRefDeepth); + } + thisRefStack.push(thisObject); + thatRefStack.push(thatObject); + + Map thisValues = thisObject.values; + Map thatValues = ((ObjectValue)thatObject).values; + + if (thisValues.size() != thatValues.size()) + return false; + try + { + Iterator i = thisValues.entrySet().iterator(); + while (i.hasNext()) { + Map.Entry e = (Map.Entry)i.next(); + Object key = e.getKey(); + Object value = e.getValue(); + if (value == null) { + if ((thatValues.get(key) != null) || (!(thatValues.containsKey(key)))) + return false; + } + else if (value instanceof ObjectValue) + if (!(equals((ObjectValue)value, thatValues.get(key), thisRefStack, thatRefStack))) + { + return false; } + else if (!(value.equals(thatValues.get(key)))) + return false; + } + } + catch (ClassCastException unused) { + return false; + } catch (NullPointerException unused) { + return false; + } + + thisRefStack.pop(); + thatRefStack.pop(); + + return true; + } + + public String toString() + { + int max = this.values.size() - 1; + StringBuffer buf = new StringBuffer(); + + TreeMap treeMap = new TreeMap(this.values); + Iterator it = treeMap.entrySet().iterator(); + buf.append("{"); + for (int i = 0; i <= max; ++i) { + Map.Entry e = (Map.Entry)(Map.Entry)it.next(); + Object key = e.getKey(); + Object value = e.getValue(); + + if (value instanceof ObjectValue) + buf.append(new StringBuilder().append(key).append("=").append(value.getClass().toString()).toString()); + else { + buf.append(new StringBuilder().append(key).append("=").append((value == this) ? "(this Map)" : value).toString()); + } + if (i < max) + buf.append(", "); + } + buf.append("}"); + + return buf.toString(); + } + + public int hashCode() { + return this.values.keySet().hashCode(); + } + //TODO 待实现 + // public Object clone() { + // try { + // return ObjectUtils.createCopy(this); + // } catch (CloneNotSupportedException cnse) { + // throw new InternalError(); + // } + // } +//TODO 待实现 +// protected Map valueClone() { +// if (this.values instanceof SortedStringObjectMap) +// return ((Map)((SortedStringObjectMap)(SortedStringObjectMap)this.values).clone()); +// try +// { +// return ((Map)(Map)ObjectUtils.createCopy(this.values)); +// } catch (CloneNotSupportedException cnse) { +// throw new InternalError(); +// } +// } + + + public static class IdentityStack + { + private ArrayList stack; + + public IdentityStack() + { + this.stack = new ArrayList(3); } + + public Object push(Object value) { + this.stack.add(value); + return value; + } + + public Object pop() { + int size = this.stack.size(); + if (size == 0) + return null; + return this.stack.remove(size - 1); + } + + public Object get(int i) { + return this.stack.get(i); + } + + public int size() { + return this.stack.size(); + } + + public int search(Object o) + { + int i = 0; for (int n = this.stack.size(); i < n; ++i) + if (this.stack.get(i) == o) + return i; + return -1; + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/ResponseResult.java b/common/common-core/src/main/java/apelet/common/core/object/ResponseResult.java new file mode 100644 index 0000000..99fcdb9 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/ResponseResult.java @@ -0,0 +1,247 @@ +package apelet.common.core.object; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.util.ContextUtil; +import com.alibaba.fastjson.JSON; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * 接口返回对象 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +@Data +public class ResponseResult { + + /** + * 为了优化性能,所有没有携带数据的正确结果,均可用该对象表示。 + */ + private static final ResponseResult OK = new ResponseResult<>(); + /** + * 是否成功标记。 + */ + private boolean success = true; + /** + * 错误码。 + */ + private String errorCode = "NO-ERROR"; + /** + * 错误信息描述。 + */ + private String errorMessage = "NO-MESSAGE"; + /** + * 实际数据。 + */ + private T data = null; + + /** + * 根据参数errorCodeEnum的枚举值,判断创建成功对象还是错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCodeEnum 的 name() 和 getErrorMessage()。 + * + * @param errorCodeEnum 错误码枚举 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult create(ErrorCodeEnum errorCodeEnum) { + return create(errorCodeEnum, errorCodeEnum.getErrorMessage()); + } + + /** + * 根据参数errorCodeEnum的枚举值,判断创建成功对象还是错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCodeEnum 的 name() 和参数 errorMessage。 + * + * @param errorCodeEnum 错误码枚举。 + * @param errorMessage 如果该参数为null,错误信息取自errorCodeEnum参数内置的errorMessage,否则使用当前参数。 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult create(ErrorCodeEnum errorCodeEnum, String errorMessage) { + errorMessage = errorMessage != null ? errorMessage : errorCodeEnum.getErrorMessage(); + return errorCodeEnum == ErrorCodeEnum.NO_ERROR ? success() : error(errorCodeEnum.name(), errorMessage); + } + + /** + * 根据参数errorCode是否为空,判断创建成功对象还是错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCode 和参数 errorMessage。 + * + * @param errorCode 自定义的错误码 + * @param errorMessage 自定义的错误信息 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult create(String errorCode, String errorMessage) { + return errorCode == null ? success() : error(errorCode, errorMessage); + } + + /** + * 根据参数errorCodeEnum的枚举值,判断创建成功对象还是错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCodeEnum 的 name() 和参数 errorMessage。 + * + * @param errorCodeEnum 错误码枚举。 + * @param errorMessage 如果该参数为null,错误信息取自errorCodeEnum参数内置的errorMessage,否则使用当前参数。 + * @param data 如果错误枚举值为NO_ERROR,则返回该数据。 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult create(ErrorCodeEnum errorCodeEnum, String errorMessage, T data) { + errorMessage = errorMessage != null ? errorMessage : errorCodeEnum.getErrorMessage(); + return errorCodeEnum == ErrorCodeEnum.NO_ERROR ? success(data) : error(errorCodeEnum.name(), errorMessage); + } + + /** + * 创建成功对象。 + * 如果需要绑定返回数据,可以在实例化后调用setDataObject方法。 + * + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult success() { + return OK; + } + + /** + * 创建带有返回数据的成功对象。 + * + * @param data 返回的数据对象 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult success(T data) { + ResponseResult resp = new ResponseResult<>(); + resp.data = data; + return resp; + } + + /** + * 创建错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCodeEnum 的 name() 和 getErrorMessage()。 + * + * @param errorCodeEnum 错误码枚举 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult error(ErrorCodeEnum errorCodeEnum) { + return error(errorCodeEnum.name(), errorCodeEnum.getErrorMessage()); + } + + /** + * 创建错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCodeEnum 的 name() 和参数 errorMessage。 + * + * @param errorCodeEnum 错误码枚举 + * @param errorMessage 自定义的错误信息 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult error(ErrorCodeEnum errorCodeEnum, String errorMessage) { + return error(errorCodeEnum.name(), errorMessage); + } + + /** + * 创建错误对象。 + * 如果返回错误对象,errorCode 和 errorMessage 分别取自于参数 errorCode 和参数 errorMessage。 + * + * @param errorCode 自定义的错误码 + * @param errorMessage 自定义的错误信息 + * @return 返回创建的ResponseResult实例对象 + */ + public static ResponseResult error(String errorCode, String errorMessage) { + return new ResponseResult<>(errorCode, errorMessage); + } + + /** + * 根据参数中出错的ResponseResult,创建新的错误应答对象。 + * + * @param errorCause 导致错误原因的应答对象。 + * @return 返回创建的ResponseResult实例对象。 + */ + public static ResponseResult errorFrom(ResponseResult errorCause) { + return error(errorCause.errorCode, errorCause.getErrorMessage()); + } + + /** + * 根据参数中出错的CallResult,创建新的错误应答对象。 + * + * @param errorCause 导致错误原因的应答对象。 + * @return 返回创建的ResponseResult实例对象。 + */ + public static ResponseResult errorFrom(CallResult errorCause) { + return error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorCause.getErrorMessage()); + } + + /** + * 根据参数中CallResult,创建新的应答对象。 + *f + * @param result CallResult对象。 + * @return 返回创建的ResponseResult实例对象。 + */ + public static ResponseResult from(CallResult result) { + if (result.isSuccess()) { + return success(); + } + return error(ErrorCodeEnum.DATA_VALIDATED_FAILED, result.getErrorMessage()); + } + + /** + * 是否成功。 + * + * @return true成功,否则false。 + */ + public boolean isSuccess() { + return success; + } + + /** + * 通过HttpServletResponse直接输出应该信息的工具方法。 + * + * @param httpStatus http状态码。 + * @param responseResult 应答内容。 + * @param 数据对象类型。 + * @throws IOException 异常错误。 + */ + public static void output(int httpStatus, ResponseResult responseResult) throws IOException { + if (httpStatus != HttpServletResponse.SC_OK) { + log.error(JSON.toJSONString(responseResult)); + } else { + log.info(JSON.toJSONString(responseResult)); + } + HttpServletResponse response = ContextUtil.getHttpResponse(); + PrintWriter out = response.getWriter(); + response.setContentType("application/json; charset=utf-8"); + response.setStatus(httpStatus); + if (responseResult != null) { + out.print(JSON.toJSONString(responseResult)); + } + out.flush(); + } + + /** + * 通过HttpServletResponse直接输出应该信息的工具方法。 + * + * @param httpStatus http状态码。 + * @throws IOException 异常错误。 + */ + public static void output(int httpStatus) throws IOException { + output(httpStatus, null); + } + + /** + * 通过HttpServletResponse直接输出应该信息的工具方法。Http状态码为200。 + * + * @param responseResult 应答内容。 + * @param 数据对象类型。 + * @throws IOException 异常错误。 + */ + public static void output(ResponseResult responseResult) throws IOException { + output(HttpServletResponse.SC_OK, responseResult); + } + + private ResponseResult() { + + } + + private ResponseResult(String errorCode, String errorMessage) { + this.success = false; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/TableModelInfo.java b/common/common-core/src/main/java/apelet/common/core/object/TableModelInfo.java new file mode 100644 index 0000000..4de40ff --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/TableModelInfo.java @@ -0,0 +1,33 @@ +package apelet.common.core.object; + +import lombok.Data; + +/** + * 数据表模型基础信息。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class TableModelInfo { + + /** + * 数据表名。 + */ + private String tableName; + + /** + * 实体对象名。 + */ + private String modelName; + + /** + * 主键的表字段名。 + */ + private String keyColumnName; + + /** + * 主键在实体对象中的属性名。 + */ + private String keyFieldName; +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/TokenData.java b/common/common-core/src/main/java/apelet/common/core/object/TokenData.java new file mode 100644 index 0000000..d1c49ad --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/TokenData.java @@ -0,0 +1,179 @@ +package apelet.common.core.object; + +import apelet.common.core.util.ContextUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import lombok.Data; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.http.HttpServletRequest; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * 基于Jwt,用于前后端传递的令牌对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +@ToString +@Slf4j +public class TokenData { + + /** + * 在HTTP Request对象中的属性键。 + */ + public static final String REQUEST_ATTRIBUTE_NAME = "tokenData"; + /** + * 是否为百分号编码后的TokenData数据。 + */ + public static final String REQUEST_ENCODED_TOKEN = "encodedTokenData"; + /** + * 用户Id。 + */ + private Long userId; + /** + * 用户所属角色。多个角色之间逗号分隔。 + */ + private String roleIds; + /** + * 用户所在部门Id。 + * 仅当系统支持uaa时可用,否则可以直接忽略该字段。保留该字段是为了保持单体和微服务通用代码部分的兼容性。 + */ + private Long deptId; + /** + * 用户所属岗位Id。多个岗位之间逗号分隔。仅当系统支持岗位时有值。 + */ + private String postIds; + /** + * 用户的部门岗位Id。多个岗位之间逗号分隔。仅当系统支持岗位时有值。 + */ + private String deptPostIds; + /** + * 租户Id。 + * 仅当系统支持uaa时可用,否则可以直接忽略该字段。保留该字段是为了保持单体和微服务通用代码部分的兼容性。 + */ + private Long tenantId; + /** + * 是否为超级管理员。 + */ + private Boolean isAdmin; + /** + * 用户登录名。 + */ + private String loginName; + /** + * 用户显示名称。 + */ + private String showName; + /** + * 所在部门名。 + */ + private String deptName; + /** + * 设备类型。参考 AppDeviceType。 + */ + private Integer deviceType; + /** + * 用户类型 + */ + private Integer userType; + /** + * 标识不同登录的会话Id。 + */ + private String sessionId; + + private String personId; + + /** + * 访问uaa的授权token。 + * 仅当系统支持uaa时可用,否则可以直接忽略该字段。保留该字段是为了保持单体和微服务通用代码部分的兼容性。 + */ + private String uaaAccessToken; + /** + * 数据库路由键(仅当水平分库时使用)。 + */ + private Integer datasourceType; + /** + * 登录IP。 + */ + private String loginIp; + /** + * 登录时间。 + */ + private Date loginTime; + /** + * 登录头像地址。 + */ + private String headImageUrl; + /** + * 原始的请求Token。 + */ + private String token; + /** + * 应用编码。空值表示非第三方应用。 + */ + private String appCode; + /** + * + */ + private String appKey; + /** + * + */ + private String appSecurity; + /** + * 当前用户项目 + */ + private String project; + /** + * 将令牌对象添加到Http请求对象。 + * + * @param tokenData 令牌对象。 + */ + public static void addToRequest(TokenData tokenData) { + HttpServletRequest request = ContextUtil.getHttpRequest(); + if (request != null) { + request.setAttribute(TokenData.REQUEST_ATTRIBUTE_NAME, tokenData); + } + } + + /** + * 从Http Request对象中获取令牌对象。 + * + * @return 令牌对象。 + */ + public static TokenData takeFromRequest() { + HttpServletRequest request = ContextUtil.getHttpRequest(); + if (request == null) { + return null; + } + TokenData tokenData = (TokenData) request.getAttribute(REQUEST_ATTRIBUTE_NAME); + if (tokenData != null) { + return tokenData; + } + String token = request.getHeader(REQUEST_ATTRIBUTE_NAME); + if (StrUtil.isBlank(token)) { + token = request.getParameter(REQUEST_ATTRIBUTE_NAME); + } + if (StrUtil.isNotBlank(token)) { + try { + String encoded = request.getHeader(REQUEST_ENCODED_TOKEN); + if (StrUtil.isNotBlank(encoded) && BooleanUtil.isTrue(Boolean.valueOf(encoded))) { + token = URLDecoder.decode(token, StandardCharsets.UTF_8.name()); + } + tokenData = JSON.parseObject(token, TokenData.class); + tokenData.showName = URLDecoder.decode(tokenData.showName, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + log.error("Failed to call TokenData.takeFromRequest", e); + } + addToRequest(tokenData); + } + return tokenData; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/TraceParamDto.java b/common/common-core/src/main/java/apelet/common/core/object/TraceParamDto.java new file mode 100644 index 0000000..a29dc62 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/TraceParamDto.java @@ -0,0 +1,18 @@ +package apelet.common.core.object; + +//import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@Data +public class TraceParamDto { + + +// @ApiModelProperty("联查方式") + private String traceType; + +// @ApiModelProperty("单据标识(表名)") + private String billType; + +// @ApiModelProperty("单据id") + private String billId; +} diff --git a/common/common-core/src/main/java/apelet/common/core/object/Tuple2.java b/common/common-core/src/main/java/apelet/common/core/object/Tuple2.java new file mode 100644 index 0000000..6d4fd48 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/Tuple2.java @@ -0,0 +1,49 @@ +package apelet.common.core.object; + +/** + * 二元组对象。主要用于可以一次返回多个结果的场景,同时还能避免强制转换。 + * + * @author guifc + * @date 2023-08-04 + */ +public class Tuple2 { + + /** + * 第一个变量。 + */ + private final T1 first; + /** + * 第二个变量。 + */ + private final T2 second; + + /** + * 构造函数。 + * + * @param first 第一个变量。 + * @param second 第二个变量。 + */ + public Tuple2(T1 first, T2 second) { + this.first = first; + this.second = second; + } + + /** + * 获取第一个变量。 + * + * @return 返回第一个变量。 + */ + public T1 getFirst() { + return first; + } + + /** + * 获取第二个变量。 + * + * @return 返回第二个变量。 + */ + public T2 getSecond() { + return second; + } +} + diff --git a/common/common-core/src/main/java/apelet/common/core/object/Tuple3.java b/common/common-core/src/main/java/apelet/common/core/object/Tuple3.java new file mode 100644 index 0000000..26445ba --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/Tuple3.java @@ -0,0 +1,65 @@ +package apelet.common.core.object; + +/** + * 三元组对象。主要用于可以一次返回多个结果的场景,同时还能避免强制转换。 + * + * @author guifc + * @date 2023-08-04 + */ +public class Tuple3 { + + /** + * 第一个变量。 + */ + private final T1 first; + /** + * 第二个变量。 + */ + private final T2 second; + + /** + * 第三个变量。 + */ + private final T3 third; + + /** + * 构造函数。 + * + * @param first 第一个变量。 + * @param second 第二个变量。 + * @param third 第三个变量。 + */ + public Tuple3(T1 first, T2 second, T3 third) { + this.first = first; + this.second = second; + this.third = third; + } + + /** + * 获取第一个变量。 + * + * @return 返回第一个变量。 + */ + public T1 getFirst() { + return first; + } + + /** + * 获取第二个变量。 + * + * @return 返回第二个变量。 + */ + public T2 getSecond() { + return second; + } + + /** + * 获取第三个变量。 + * + * @return 返回第三个变量。 + */ + public T3 getThird() { + return third; + } +} + diff --git a/common/common-core/src/main/java/apelet/common/core/object/TypedCallResult.java b/common/common-core/src/main/java/apelet/common/core/object/TypedCallResult.java new file mode 100644 index 0000000..ae7c882 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/object/TypedCallResult.java @@ -0,0 +1,109 @@ +package apelet.common.core.object; + +import lombok.Data; + +/** + * 业务方法调用结果对象。可以同时返回具体的错误和自定义类型的数据对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class TypedCallResult { + + /** + * 为了优化性能,所有没有携带数据的正确结果,均可用该对象表示。 + */ + private static final TypedCallResult OK = new TypedCallResult<>(); + /** + * 是否成功标记。 + */ + private boolean success = true; + /** + * 错误信息描述。 + */ + private String errorMessage = null; + /** + * 在验证同时,仍然需要附加的关联数据对象。 + */ + private T data; + + /** + * 创建验证结果对象。 + * + * @param errorMessage 错误描述信息。 + * @return 如果参数为空,表示成功,否则返回代码错误信息的错误对象实例。 + */ + public static TypedCallResult create(String errorMessage) { + return errorMessage == null ? ok() : error(errorMessage); + } + + /** + * 创建验证结果对象。 + * + * @param errorMessage 错误描述信息。 + * @param data 附带的数据对象。 + * @return 如果参数为空,表示成功,否则返回代码错误信息的错误对象实例。 + */ + public static TypedCallResult create(String errorMessage, T data) { + return errorMessage == null ? ok(data) : error(errorMessage, data); + } + + /** + * 创建表示验证成功的对象实例。 + * + * @return 验证成功对象实例。 + */ + public static TypedCallResult ok() { + return OK; + } + + /** + * 创建表示验证成功的对象实例。 + * + * @param data 附带的数据对象。 + * @return 验证成功对象实例。 + */ + public static TypedCallResult ok(T data) { + TypedCallResult result = new TypedCallResult<>(); + result.data = data; + return result; + } + + /** + * 创建表示验证失败的对象实例。 + * + * @param errorMessage 错误描述。 + * @return 验证失败对象实例。 + */ + public static TypedCallResult error(String errorMessage) { + TypedCallResult result = new TypedCallResult<>(); + result.success = false; + result.errorMessage = errorMessage; + return result; + } + + /** + * 创建表示验证失败的对象实例。 + * + * @param errorMessage 错误描述。 + * @param data 附带的数据对象。 + * @return 验证失败对象实例。 + */ + public static TypedCallResult error(String errorMessage, T data) { + TypedCallResult result = new TypedCallResult<>(); + result.success = false; + result.errorMessage = errorMessage; + result.data = data; + return result; + } + + /** + * 根据参数中出错的TypedCallResult,创建新的错误调用结果对象。 + * @param result 错误调用结果对象。 + * @return 新的错误调用结果对象。 + */ + public static TypedCallResult errorFrom(TypedCallResult result) { + return error(result.getErrorMessage()); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/upload/BaseUpDownloader.java b/common/common-core/src/main/java/apelet/common/core/upload/BaseUpDownloader.java new file mode 100644 index 0000000..d0c768c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/upload/BaseUpDownloader.java @@ -0,0 +1,181 @@ +package apelet.common.core.upload; + +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.util.ContextUtil; +import apelet.common.core.util.DateTimeUtils; +import apelet.common.core.util.MyCommonUtil; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.BooleanUtil; +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +/** + * 上传或下载文件抽象父类。 + * 包含存储本地文件的功能,以及上传和下载所需的通用方法。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public abstract class BaseUpDownloader { + + /** + * 构建上传文件的完整目录。 + * + * @param rootBaseDir 文件下载的根目录。 + * @param modelName 所在数据表的实体对象名。 + * @param fieldName 关联字段的实体对象属性名。 + * @param asImage 是否为图片对象。图片是无需权限验证的,因此和附件存放在不同的子目录。 + * @return 上传文件的完整路径名。 + */ + public String makeFullPath( + String rootBaseDir, String modelName, String fieldName, Boolean asImage) { + StringBuilder uploadPathBuilder = new StringBuilder(128); + if (StringUtils.isNotBlank(rootBaseDir)) { + uploadPathBuilder.append(rootBaseDir).append("/"); + } + if (Boolean.TRUE.equals(asImage)) { + uploadPathBuilder.append(ApplicationConstant.UPLOAD_IMAGE_PARENT_PATH); + } else { + uploadPathBuilder.append(ApplicationConstant.UPLOAD_ATTACHMENT_PARENT_PATH); + } + if (StringUtils.isNotBlank(modelName)) { + uploadPathBuilder.append("/").append(modelName); + } + if (StringUtils.isNotBlank(fieldName)) { + uploadPathBuilder.append("/").append(fieldName); + } + return uploadPathBuilder.toString(); + } + + /** + * 构建上传操作的返回对象。 + * + * @param serviceContextPath 微服务的上下文路径,如: /admin/upms。 + * @param originalFilename 上传文件的原始文件名(包含扩展名)。 + */ + protected void fillUploadResponseInfo( + UploadResponseInfo responseInfo, String serviceContextPath, String originalFilename) { + // 根据请求上传的uri构建下载uri,只是将末尾的/upload改为/download即可。 + HttpServletRequest request = ContextUtil.getHttpRequest(); + String uri = request.getRequestURI(); + uri = StringUtils.removeEnd(uri, "/"); + uri = StringUtils.removeEnd(uri, "/upload"); + String downloadUri; + if (StringUtils.isBlank(serviceContextPath)) { + downloadUri = uri + "/download"; + } else { + downloadUri = serviceContextPath + uri + "/download"; + } + StringBuilder filenameBuilder = new StringBuilder(64); + filenameBuilder.append(MyCommonUtil.generateUuid()) + .append(".").append(originalFilename); + responseInfo.setDownloadUri(downloadUri); + responseInfo.setFilename(originalFilename); + } + + /** + * 执行下载操作,从本地文件系统读取数据,并将读取的数据直接写入到HttpServletResponse应答对象。 + * + * @param rootBaseDir 文件下载的根目录。 + * @param modelName 所在数据表的实体对象名。 + * @param fieldName 关联字段的实体对象属性名。 + * @param fileName 文件名。 + * @param asImage 是否为图片对象。图片是无需权限验证的,因此和附件存放在不同的子目录。 + * @param response Http 应答对象。 + * @throws IOException 操作错误。 + */ + public abstract void doDownload( + String rootBaseDir, + String modelName, + String fieldName, + String fileName, + Boolean asImage, + HttpServletResponse response) throws IOException; + + /** + * 执行下载操作,从本地文件系统读取数据,并将读取的数据直接写入到HttpServletResponse应答对象。 + * + * @param rootBaseDir 文件下载的根目录。 + * @param uriPath uri中的路径名。 + * @param fileName 文件名。 + * @param response Http 应答对象。 + * @throws IOException 操作错误。 + */ + public abstract void doDownload( + String rootBaseDir, + String uriPath, + String fileName, + HttpServletResponse response) throws IOException; + + /** + * 执行文件上传操作,并存入本地文件系统,再将与该文件下载对应的Url直接写入到HttpServletResponse应答对象,返回给前端。 + * + * @param serviceContextPath 微服务的上下文路径,如: /admin/upms。 + * @param rootBaseDir 存放上传文件的根目录。 + * @param modelName 所在数据表的实体对象名。 + * @param fieldName 关联字段的实体对象属性名。 + * @param uploadFile Http请求中上传的文件对象。 + * @param asImage 是否为图片对象。图片是无需权限验证的,因此和附件存放在不同的子目录。 + * @return 存储在本地上传文件名。 + * @throws IOException 操作错误。 + */ + public abstract UploadResponseInfo doUpload( + String serviceContextPath, + String rootBaseDir, + String modelName, + String fieldName, + Boolean asImage, + MultipartFile uploadFile) throws IOException; + + /** + * 判断filename参数指定的文件名,是否被包含在fileInfoJson参数中。 + * + * @param fileInfoJson 内部类UploadFileInfo的JSONArray数组。 + * @param filename 被包含的文件名。 + * @return 存在返回true,否则false。 + */ + public static boolean containFile(String fileInfoJson, String filename) { + if (StringUtils.isAnyBlank(fileInfoJson, filename)) { + return false; + } + List fileInfoList = JSON.parseArray(fileInfoJson, UploadResponseInfo.class); + if (CollectionUtils.isNotEmpty(fileInfoList)) { + for (UploadResponseInfo fileInfo : fileInfoList) { + if (StringUtils.equals(filename, fileInfo.getFilename())) { + return true; + } + } + } + return false; + } + + protected UploadResponseInfo verifyUploadArgument( + Boolean asImage, MultipartFile uploadFile) throws IOException { + UploadResponseInfo responseInfo = new UploadResponseInfo(); + if (Objects.isNull(uploadFile) || uploadFile.isEmpty()) { + responseInfo.setUploadFailed(true); + responseInfo.setErrorMessage(ErrorCodeEnum.INVALID_UPLOAD_FILE_ARGUMENT.getErrorMessage()); + return responseInfo; + } + if (BooleanUtil.isTrue(asImage) && ImageIO.read(uploadFile.getInputStream()) == null) { + responseInfo.setUploadFailed(true); + responseInfo.setErrorMessage(ErrorCodeEnum.INVALID_UPLOAD_FILE_FORMAT.getErrorMessage()); + return responseInfo; + } + return responseInfo; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/upload/LocalUpDownloader.java b/common/common-core/src/main/java/apelet/common/core/upload/LocalUpDownloader.java new file mode 100644 index 0000000..5dccc6f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/upload/LocalUpDownloader.java @@ -0,0 +1,236 @@ +package apelet.common.core.upload; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.util.MyCommonUtil; +import cn.hutool.core.util.BooleanUtil; +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.PostConstruct; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.List; + +/** + * 存储本地文件的上传下载实现类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +@Component +public class LocalUpDownloader extends BaseUpDownloader { + + @Autowired + private UpDownloaderFactory factory; + + @PostConstruct + public void doRegister() { + factory.registerUpDownloader(UploadStoreTypeEnum.LOCAL_SYSTEM, this); + } + + /** + * 执行下载操作,从本地文件系统读取数据,并将读取的数据直接写入到HttpServletResponse应答对象。 + * + * @param rootBaseDir 文件下载的根目录。 + * @param modelName 所在数据表的实体对象名。 + * @param fieldName 关联字段的实体对象属性名。 + * @param fileName 文件名。 + * @param asImage 是否为图片对象。图片是无需权限验证的,因此和附件存放在不同的子目录。 + * @param response Http 应答对象。 + */ + @Override + public void doDownload( + String rootBaseDir, + String modelName, + String fieldName, + String fileName, + Boolean asImage, + HttpServletResponse response) { + String uploadPath = makeFullPath(rootBaseDir, modelName, fieldName, asImage); + String fullFileanme = uploadPath + "/" + fileName; + this.downloadInternal(fullFileanme, fileName, response); + } + + @Override + public void doDownload( + String rootBaseDir, + String uriPath, + String fileName, + HttpServletResponse response) throws IOException { + StringBuilder pathBuilder = new StringBuilder(128); + if (StringUtils.isNotBlank(rootBaseDir)) { + pathBuilder.append(rootBaseDir); + } + if (!uriPath.startsWith("/")) { + pathBuilder.append("/"); + } + String fullFileanme = pathBuilder.append(uriPath).append("/").append(fileName).toString(); + this.downloadInternal(fullFileanme, fileName, response); + } + + private void downloadInternal(String fullFileanme, String fileName, HttpServletResponse response) { + File file = new File(fullFileanme); + if (!file.exists()) { + log.warn("Download file [" + fullFileanme + "] failed, no file found!"); + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + readFile(response, file); + } + + /** + * 执行文件上传操作,并存入本地文件系统,再将与该文件下载对应的Url直接写入到HttpServletResponse应答对象,返回给前端。 + * + * @param serviceContextPath 微服务的上下文路径,如: /admin/upms。 + * @param rootBaseDir 存放上传文件的根目录。 + * @param modelName 所在数据表的实体对象名。 + * @param fieldName 关联字段的实体对象属性名。 + * @param uploadFile Http请求中上传的文件对象。 + * @param asImage 是否为图片对象。图片是无需权限验证的,因此和附件存放在不同的子目录。 + * @return 存储在本地上传文件名。 + * @throws IOException 文件操作错误。 + */ + @Override + public UploadResponseInfo doUpload( + String serviceContextPath, + String rootBaseDir, + String modelName, + String fieldName, + Boolean asImage, + MultipartFile uploadFile) throws IOException { + UploadResponseInfo responseInfo = super.verifyUploadArgument(asImage, uploadFile); + if (BooleanUtil.isTrue(responseInfo.getUploadFailed())) { + return responseInfo; + } + String uploadPath = makeFullPath(rootBaseDir, modelName, fieldName, asImage); + responseInfo.setUploadPath(uploadPath); + fillUploadResponseInfo(responseInfo, serviceContextPath, uploadFile.getOriginalFilename()); + try { + byte[] bytes = uploadFile.getBytes(); + StringBuilder sb = new StringBuilder(256); + String s = MyCommonUtil.generateUuid() + "@" + responseInfo.getFilename(); + responseInfo.setFileRealName(responseInfo.getFilename()); + responseInfo.setFilename(s); + + sb.append(uploadPath).append("/").append(s); + Path path = Paths.get(sb.toString()); + // 如果没有files文件夹,则创建 + if (!Files.isWritable(path)) { + Files.createDirectories(Paths.get(uploadPath)); + } + // 文件写入指定路径 + Files.write(path, bytes); + } catch (IOException e) { + log.error("Failed to write uploaded file [" + uploadFile.getOriginalFilename() + " ].", e); + responseInfo.setUploadFailed(true); + responseInfo.setErrorMessage(ErrorCodeEnum.INVALID_UPLOAD_FILE_IOERROR.getErrorMessage()); + return responseInfo; + } + return responseInfo; + } + + /** + * 判断filename参数指定的文件名,是否被包含在fileInfoJson参数中。 + * + * @param fileInfoJson 内部类UploadFileInfo的JSONArray数组。 + * @param filename 被包含的文件名。 + * @return 存在返回true,否则false。 + */ + public static boolean containFile(String fileInfoJson, String filename) { + if (StringUtils.isAnyBlank(fileInfoJson, filename)) { + return false; + } + List fileInfoList = JSON.parseArray(fileInfoJson, UploadResponseInfo.class); + if (CollectionUtils.isNotEmpty(fileInfoList)) { + for (UploadResponseInfo fileInfo : fileInfoList) { + if (StringUtils.equals(filename, fileInfo.getFilename())) { + return true; + } + } + } + return false; + } + + public static void fileWriteIn(String uploadPath, String fileRealName, byte[] bytes) throws IOException { + Path path = Paths.get(uploadPath + fileRealName); + // 如果没有files文件夹,则创建 + if (!Files.isWritable(path)) { + Files.createDirectories(Paths.get(uploadPath)); + } + // 文件写入指定路径 + Files.write(path, bytes); + } + + public static void wirteReponseFile(HttpServletResponse response, String base64, String fileRealName) { + response.setHeader("content-type", "application/octet-stream"); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileRealName)); + byte[] bytes = Base64.getDecoder().decode(base64); + byte[] buff = new byte[2048]; + try { + ServletOutputStream os = response.getOutputStream(); + os.write(bytes); + os.flush(); + } catch (IOException e) { + log.error("Failed to call LocalUpDownloader.doDownload", e); + } + } + + public static void readFile(HttpServletResponse response, File file) { + response.setHeader("content-type", "application/octet-stream"); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(file.getName())); + byte[] buff = new byte[2048]; + BufferedInputStream bis = null; + ServletOutputStream os = null; + InputStream inputStream = null; + try { + os = response.getOutputStream(); + inputStream = Files.newInputStream(file.toPath()); + bis = new BufferedInputStream(inputStream); + int i = bis.read(buff); + while (i != -1) { + os.write(buff, 0, i); + os.flush(); + i = bis.read(buff); + } + } catch (IOException e) { + log.error("Failed to call LocalUpDownloader.doDownload", e); + } finally { + try { + if (bis != null) { + bis.close(); + } + if (os != null) { + os.close(); + } + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + throw new MyRuntimeException(e); + } + } + } + + public static String getSuffix(String fileName) { + int lastIndex = fileName.lastIndexOf("."); + if (lastIndex != -1) { + return fileName.substring(lastIndex + 1); + } + return ""; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/upload/UpDownloaderFactory.java b/common/common-core/src/main/java/apelet/common/core/upload/UpDownloaderFactory.java new file mode 100644 index 0000000..8eb29a8 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/upload/UpDownloaderFactory.java @@ -0,0 +1,49 @@ +package apelet.common.core.upload; + +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +/** + * 业务对象根据上传下载存储类型,获取上传下载对象的工厂类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Component +public class UpDownloaderFactory { + + private final Map upDownloaderMap = new EnumMap<>(UploadStoreTypeEnum.class); + + /** + * 根据存储类型获取上传下载对象。 + * @param storeType 存储类型。 + * @return 匹配的上传下载对象。 + */ + public BaseUpDownloader get(UploadStoreTypeEnum storeType) { + BaseUpDownloader upDownloader = upDownloaderMap.get(storeType); + if (upDownloader == null) { + throw new UnsupportedOperationException( + "The storeType [" + storeType.name() + "] isn't supported, please add dependency jar first."); + } + return upDownloader; + } + + /** + * 注册上传下载对象到工厂。 + * + * @param storeType 存储类型。 + * @param upDownloader 上传下载对象。 + */ + public void registerUpDownloader(UploadStoreTypeEnum storeType, BaseUpDownloader upDownloader) { + if (storeType == null || upDownloader == null) { + throw new IllegalArgumentException("The Argument can't be NULL."); + } + if (upDownloaderMap.containsKey(storeType)) { + throw new UnsupportedOperationException( + "The storeType [" + storeType.name() + "] has been registered already."); + } + upDownloaderMap.put(storeType, upDownloader); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/upload/UploadResponseInfo.java b/common/common-core/src/main/java/apelet/common/core/upload/UploadResponseInfo.java new file mode 100644 index 0000000..f8532b2 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/upload/UploadResponseInfo.java @@ -0,0 +1,38 @@ +package apelet.common.core.upload; + +import lombok.Data; + +/** + * 数据上传操作的应答信息对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class UploadResponseInfo { + /** + * 上传是否出现错误。 + */ + private Boolean uploadFailed = false; + /** + * 具体错误信息。 + */ + private String errorMessage; + /** + * 返回前端的下载url。 + */ + private String downloadUri; + /** + * 上传文件所在路径。 + */ + private String uploadPath; + /** + * 返回给前端的文件名。 + */ + private String filename; + + private String baseUrl; + + + private String fileRealName; +} diff --git a/common/common-core/src/main/java/apelet/common/core/upload/UploadStoreInfo.java b/common/common-core/src/main/java/apelet/common/core/upload/UploadStoreInfo.java new file mode 100644 index 0000000..d77241c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/upload/UploadStoreInfo.java @@ -0,0 +1,22 @@ +package apelet.common.core.upload; + +import lombok.Data; + +/** + * 上传数据存储信息对象。这里之所以使用对象,主要是便于今后扩展。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class UploadStoreInfo { + + /** + * 是否支持上传。 + */ + private boolean supportUpload; + /** + * 上传数据存储类型。 + */ + private UploadStoreTypeEnum storeType; +} diff --git a/common/common-core/src/main/java/apelet/common/core/upload/UploadStoreTypeEnum.java b/common/common-core/src/main/java/apelet/common/core/upload/UploadStoreTypeEnum.java new file mode 100644 index 0000000..d07918e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/upload/UploadStoreTypeEnum.java @@ -0,0 +1,31 @@ +package apelet.common.core.upload; + +/** + * 上传数据存储介质类型枚举。 + * + * @author guifc + * @date 2023-08-04 + */ +public enum UploadStoreTypeEnum { + + /** + * 本地系统。 + */ + LOCAL_SYSTEM, + /** + * minio分布式存储。 + */ + MINIO_SYSTEM, + /** + * 阿里云OSS存储。 + */ + ALIYUN_OSS_SYTEM, + /** + * 腾讯云COS存储。 + */ + QCLOUD_COS_SYTEM, + /** + * 华为云OBS存储。 + */ + HUAWEI_OBS_SYSTEM +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/AopTargetUtil.java b/common/common-core/src/main/java/apelet/common/core/util/AopTargetUtil.java new file mode 100644 index 0000000..2f320e7 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/AopTargetUtil.java @@ -0,0 +1,81 @@ +package apelet.common.core.util; + +import cn.hutool.core.util.ReflectUtil; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.aop.framework.AdvisedSupport; +import org.springframework.aop.framework.AopProxy; +import org.springframework.aop.support.AopUtils; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * 获取JDK动态代理/CGLIB代理对象代理的目标对象的工具类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class AopTargetUtil { + + /** + * 获取参数对象代理的目标对象。 + * + * @param proxy 代理对象 + * @return 代理的目标对象。 + */ + public static Object getTarget(Object proxy) { + if (!AopUtils.isAopProxy(proxy)) { + return proxy; + } + try { + if (AopUtils.isJdkDynamicProxy(proxy)) { + return getJdkDynamicProxyTargetObject(proxy); + } else { + return getCglibProxyTargetObject(proxy); + } + } catch (Exception e) { + log.error("Failed to call getJdkDynamicProxyTargetObject or getCglibProxyTargetObject", e); + return null; + } + } + + /** + * 获取被织入完整的方法名。 + * + * @param joinPoint 织入方法对象。 + * @return 被织入完整的方法名。 + */ + public static String getFullMethodName(ProceedingJoinPoint joinPoint) { + StringBuilder sb = new StringBuilder(512); + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + sb.append(methodSignature.getMethod().getName()).append("("); + String paramTypes = Arrays.stream(methodSignature.getParameterTypes()) + .map(Class::getSimpleName).collect(Collectors.joining(", ")); + sb.append(paramTypes).append(")"); + return sb.toString(); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private AopTargetUtil() { + } + + private static Object getCglibProxyTargetObject(Object proxy) throws Exception { + Field h = proxy.getClass().getDeclaredField("CGLIB$CALLBACK_0"); + Object dynamicAdvisedInterceptor = ReflectUtil.getFieldValue(proxy, h); + Field advised = dynamicAdvisedInterceptor.getClass().getDeclaredField("advised"); + return ((AdvisedSupport) ReflectUtil.getFieldValue(dynamicAdvisedInterceptor, advised)).getTargetSource().getTarget(); + } + + private static Object getJdkDynamicProxyTargetObject(Object proxy) throws Exception { + Field h = proxy.getClass().getSuperclass().getDeclaredField("h"); + AopProxy aopProxy = (AopProxy) ReflectUtil.getFieldValue(proxy, h); + Field advised = aopProxy.getClass().getDeclaredField("advised"); + return ((AdvisedSupport) ReflectUtil.getFieldValue(aopProxy, advised)).getTargetSource().getTarget(); + } +} \ No newline at end of file diff --git a/common/common-core/src/main/java/apelet/common/core/util/ApplicationContextHolder.java b/common/common-core/src/main/java/apelet/common/core/util/ApplicationContextHolder.java new file mode 100644 index 0000000..0335569 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/ApplicationContextHolder.java @@ -0,0 +1,88 @@ +package apelet.common.core.util; + +import apelet.common.core.exception.MyRuntimeException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +import java.util.Collection; + +/** + * Spring 系统启动应用感知对象,主要用于获取Spring Bean的上下文对象,后续的代码中可以直接查找系统中加载的Bean对象。 + * + * @author guifc + * @date 2023-08-04 + */ +@Component +public class ApplicationContextHolder implements ApplicationContextAware { + + private static ApplicationContext applicationContext; + + /** + * Spring 启动的过程中会自动调用,并将应用上下文对象赋值进来。 + * + * @param applicationContext 应用上下文对象,可通过该对象查找Spring中已经加载的Bean。 + */ + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) { + doSetApplicationContext(applicationContext); + } + + /** + * 获取应用上下文对象。 + * + * @return 应用上下文。 + */ + public static ApplicationContext getApplicationContext() { + assertApplicationContext(); + return applicationContext; + } + + /** + * 根据BeanName,获取Bean对象。 + * + * @param beanName Bean名称。 + * @param 返回的Bean类型。 + * @return Bean对象。 + */ + @SuppressWarnings("unchecked") + public static T getBean(String beanName) { + assertApplicationContext(); + return (T) applicationContext.getBean(beanName); + } + + /** + * 根据Bean的ClassType,获取Bean对象。 + * + * @param beanType Bean的Class类型。 + * @param 返回的Bean类型。 + * @return Bean对象。 + */ + public static T getBean(Class beanType) { + assertApplicationContext(); + return applicationContext.getBean(beanType); + } + + /** + * 根据Bean的ClassType,获取Bean对象列表。 + * + * @param beanType Bean的Class类型。 + * @param 返回的Bean类型。 + * @return Bean对象列表。 + */ + public static Collection getBeanListOfType(Class beanType) { + assertApplicationContext(); + return applicationContext.getBeansOfType(beanType).values(); + } + + private static void assertApplicationContext() { + if (ApplicationContextHolder.applicationContext == null) { + throw new MyRuntimeException("applicaitonContext属性为null,请检查是否注入了ApplicationContextHolder!"); + } + } + + private static void doSetApplicationContext(ApplicationContext applicationContext) { + ApplicationContextHolder.applicationContext = applicationContext; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/ContextUtil.java b/common/common-core/src/main/java/apelet/common/core/util/ContextUtil.java new file mode 100644 index 0000000..81204ab --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/ContextUtil.java @@ -0,0 +1,51 @@ +package apelet.common.core.util; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 获取Servlet HttpRequest和HttpResponse的工具类。 + * + * @author guifc + * @date 2023-08-04 + */ +public class ContextUtil { + + /** + * 判断当前是否处于HttpServletRequest上下文环境。 + * + * @return 是返回true,否则false。 + */ + public static boolean hasRequestContext() { + return RequestContextHolder.getRequestAttributes() != null; + } + + /** + * 获取Servlet请求上下文的HttpRequest对象。 + * + * @return 请求上下文中的HttpRequest对象。 + */ + public static HttpServletRequest getHttpRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes == null ? null : attributes.getRequest(); + } + + /** + * 获取Servlet请求上下文的HttpResponse对象。 + * + * @return 请求上下文中的HttpResponse对象。 + */ + public static HttpServletResponse getHttpResponse() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes == null ? null : attributes.getResponse(); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private ContextUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/DataSourceResolver.java b/common/common-core/src/main/java/apelet/common/core/util/DataSourceResolver.java new file mode 100644 index 0000000..0c002d2 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/DataSourceResolver.java @@ -0,0 +1,20 @@ +package apelet.common.core.util; + +/** + * 基于自定义解析规则的多数据源解析接口。 + * + * @author guifc + * @date 2023-08-04 + */ +public interface DataSourceResolver { + + /** + * 动态解析方法。实现类可以根据当前的请求,或者上下文环境进行动态解析。 + * + * @param arg 可选的入参。MyDataSourceResolver注解中的arg参数。 + * @param intArg 可选的整型入参。MyDataSourceResolver注解中的intArg参数。 + * @param methodArgs 被织入方法的所有参数。 + * @return 返回用于多数据源切换的类型值。DataSourceResolveAspect 切面方法会根据该返回值和配置信息,进行多数据源切换。 + */ + Integer resolve(String arg, Integer intArg, Object[] methodArgs); +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/DateTimeUtils.java b/common/common-core/src/main/java/apelet/common/core/util/DateTimeUtils.java new file mode 100644 index 0000000..6f8c194 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/DateTimeUtils.java @@ -0,0 +1,486 @@ + +package apelet.common.core.util; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.*; + +public class DateTimeUtils +{ + private static TimeZone gmt = new SimpleTimeZone(0, "GMT"); + private static final String EX_GMT_TO_DATE = "gmtToDate: invalid gmt date string!"; + private static final String EX_GET_MONTH_NAME = "getMonthName : month value must be between 1 and 12!"; + private static final String EX_GET_ELAPSED_TIME = "getElapsedTime : begin date must be inferior to end date !"; + private static final String EX_GET_DAY_NAME = "getDayName: day value must be between 1 and 7!"; + private static final String EX_FORMAT_MONTH = "Invalid month name"; + private static final String EX_DATE_TO_GMT = "dateToGmt: unknown RFC format"; + + public static String format(Date d, String fmt) + { + return format(d, fmt, null); + } + + public static String format(Date d, String fmt, TimeZone timezone) + { + return format(d, fmt, timezone, null); + } + + public static String format(Date d, String fmt, TimeZone timezone, Locale locale) + { + if (fmt == null) + fmt = "yyyy-MM-dd HH:mm:ss"; + SimpleDateFormat df; + if (locale == null) + df = new SimpleDateFormat(fmt); + else + df = new SimpleDateFormat(fmt, locale); + if (timezone == null) + df.setTimeZone(TimeZone.getDefault()); + else + df.setTimeZone(timezone); + return df.format(d); + } + + public static String format(Date d) + { + return format(d, null, null); + } + + public static String format(Date d, TimeZone timezone) + { + return format(d, null, timezone); + } + + public static String formatDate(Date d, TimeZone timezone) + { + return format(d, "yyyy-MM-dd", timezone); + } + + public static String formatDate(Date d) + { + return formatDate(d, null); + } + + public static String formatTime(Date d, TimeZone timezone) + { + return format(d, "HH:mm:ss", timezone); + } + + public static String formatTime(Date d) + { + return formatTime(d, null); + } + + public static String defaultDateString(Date d) + { + return defaultDateString(d, null); + } + + public static String defaultDateString(Date d, TimeZone timezone) + { + if (timezone == null) + timezone = TimeZone.getDefault(); + long l = timezone.getRawOffset(); + StringBuffer temp = new StringBuffer(format(d, timezone)); + if (l > 0L) + { + temp.append(" +"); + } + else + { + temp.append(" -"); + l = -l; + } + temp.append(format(new Date(l), "HH:mm", gmt)); + return temp.toString(); + } + + public static Date parseDate(String s) throws ParseException + { + try + { + return parseDate(s, "yyyy-MM-dd HH:mm:ss"); + } + catch (ParseException e) + { + try + { + return parseDate(s, "yyyy-MM-dd"); + } + catch (ParseException e1) + { + try + { + return parseDate(s, "MM/dd/yyyy HH:mm:ss"); + } + catch (ParseException e2) + { + try + { + return parseDate(s, "MM/dd/yyyy"); + } + catch (ParseException e3) + { + try + { + return parseDate(s, "HH:mm:ss"); + } + catch (ParseException e4) + { + throw new ParseException("can not understand your format", -1); } } } } } + } + + public static Date parseDate(String s, String fmt) throws ParseException { + return parseDate(s, fmt, null); + } + + public static Date parseDate(String s, String fmt, TimeZone timezone) + throws ParseException + { + return parseDate(s, fmt, timezone, null); + } + + public static Date parseDate(String s, String fmt, TimeZone timezone, Locale locale) + throws ParseException + { + SimpleDateFormat df; + if (locale == null) + df = new SimpleDateFormat(fmt); + else + df = new SimpleDateFormat(fmt, locale); + if (timezone == null) + timezone = TimeZone.getDefault(); + df.setTimeZone(timezone); + df.setLenient(false); + return df.parse(s); + } + + public static Date parseDefaultDate(String s) throws ParseException + { + int i = s.lastIndexOf(32); + char c = s.charAt(i + 1); + long l = parseDate(s.substring(i + 2), "HH:mm", gmt).getTime(); + if (c == '-') + l = -l; + return parseDate(s.substring(0, i), "yyyy-MM-dd HH:mm:ss", new SimpleTimeZone((int)l, "XXX")); + } + + public static String emailDate(Date d) + { + return emailDate(d, null); + } + + public static String emailDate(Date d, TimeZone timezone) + { + if (timezone == null) + timezone = TimeZone.getDefault(); + long l = timezone.getRawOffset(); + StringBuffer temp = new StringBuffer(format(d, "E, d MMM yyyy HH:mm:ss", timezone, Locale.UK)); + if (l > 0L) + { + temp.append(" +"); + } + else + { + temp.append(" -"); + l = -l; + } + temp.append(format(new Date(l), "HHmm", gmt)); + return temp.toString(); + } + + public static Date parseEmailDate(String s) throws ParseException + { + try + { + int i = s.lastIndexOf(32); + char c = s.charAt(i + 1); + long l = parseDate(s.substring(i + 2), "HHmm", gmt).getTime(); + if (c == '-') + l = -l; + return parseDate(s.substring(0, i), "E, d MMM yyyy HH:mm:ss", new SimpleTimeZone((int)l, "XXX"), Locale.UK); + } + catch (Exception e) + { + } + + return parseDate(s, "E, d MMM yyyy HH:mm:ss z", gmt, Locale.UK); + } + + public static boolean dayBefore(Date dt, Date dt1) + { + Calendar c = Calendar.getInstance(); + c.setTime(dt); + int year = c.get(1); + int date = c.get(6); + c.setTime(dt1); + int year1 = c.get(1); + int date1 = c.get(6); + + return ((year < year1) || ((year == year1) && (date < date1))); + } + + public static boolean dayAfter(Date dt, Date dt1) + { + Calendar c = Calendar.getInstance(); + c.setTime(dt); + int year = c.get(1); + int date = c.get(6); + c.setTime(dt1); + int year1 = c.get(1); + int date1 = c.get(6); + + return ((year > year1) || ((year == year1) && (date > date1))); + } + + public static boolean dayEquals(Date dt, Date dt1) + { + Calendar c = Calendar.getInstance(); + c.setTime(dt); + int year = c.get(1); + int date = c.get(6); + c.setTime(dt1); + int year1 = c.get(1); + int date1 = c.get(6); + + return ((year == year1) && (date == date1)); + } + + + + + + public static Date addDate(Date dDate1, Date dDate2) + { + long date1Ms = dDate1.getTime(); + long date2Ms = dDate2.getTime(); + long add = date1Ms + date2Ms; + return new Date(add); + } + + public static Date addDay(Date dDate, long iNbDay) + { + Calendar cal = Calendar.getInstance(); + cal.setTime(dDate); + cal.add(5, (int)iNbDay); + Date result = cal.getTime(); + return result; + } + + public static Date addDuration(Date dDate, int iNbYear, int iNbMonth) + { + Date result = dDate; + result = addMonth(result, iNbMonth); + result = addYear(result, iNbYear); + return result; + } + + public static Date addDuration(Date dDate, int iNbYear, int iNbMonth, int iNbDay) + { + Date result = dDate; + result = addDay(result, iNbDay); + result = addMonth(result, iNbMonth); + result = addYear(result, iNbYear); + return result; + } + + public static Date addDuration(Date dDate, int iNbYear, int iNbMonth, int iNbDay, int iNbHour) + { + Date result = dDate; + result = addHour(result, iNbHour); + result = addDay(result, iNbDay); + result = addMonth(result, iNbMonth); + result = addYear(result, iNbYear); + return result; + } + + public static Date addDuration(Date dDate, int iNbYear, int iNbMonth, int iNbDay, int iNbHour, int iNbMinute) + { + Date result = dDate; + result = addMinute(result, iNbMinute); + result = addHour(result, iNbHour); + result = addDay(result, iNbDay); + result = addMonth(result, iNbMonth); + result = addYear(result, iNbYear); + return result; + } + + public static Date addDuration(Date dDate, int iNbYear, int iNbMonth, int iNbDay, int iNbHour, int iNbMinute, int iNbSecond) + { + Date result = dDate; + result = addSecond(result, iNbSecond); + result = addMinute(result, iNbMinute); + result = addHour(result, iNbHour); + result = addDay(result, iNbDay); + result = addMonth(result, iNbMonth); + result = addYear(result, iNbYear); + return result; + } + + public static Date addHour(Date dDate, long iNbHour) + { + long datems = dDate.getTime(); + long hourMs = iNbHour * 60L * 60L * 1000L; + long newDateMs = datems + hourMs; + Date result = new Date(newDateMs); + return result; + } + + public static Date addMinute(Date dDate, long iNbMinute) + { + long datems = dDate.getTime(); + long minuteMs = iNbMinute * 60L * 1000L; + long newDateMs = datems + minuteMs; + Date result = new Date(newDateMs); + return result; + } + + public static Date addMonth(Date dDate, int iNbMonth) + { + Calendar cal = Calendar.getInstance(); + cal.setTime(dDate); + int month = cal.get(2); + month += iNbMonth; + int year = month / 12; + month %= 12; + cal.set(2, month); + if (year != 0) + { + int oldYear = cal.get(1); + cal.set(1, year + oldYear); + } + return cal.getTime(); + } + + public static Date addSecond(Date dDate, long iNbSecond) + { + long datems = dDate.getTime(); + long secondms = iNbSecond * 1000L; + long newDateMs = datems + secondms; + Date result = new Date(newDateMs); + return result; + } + + public static Date addYear(Date dDate, int iNbYear) + { + Calendar cal = Calendar.getInstance(); + cal.setTime(dDate); + int oldYear = cal.get(1); + cal.set(1, iNbYear + oldYear); + return cal.getTime(); + } + + public static long dateDiff(String interval, Date dDate1, Date dDate2) + { + int desiredField = 0; + int coef = 1; + Date date2; + Date date1; + if (dDate1.getTime() > dDate2.getTime()) + { + coef = -1; + date1 = dDate2; + date2 = dDate1; + } + else { + date1 = dDate1; + date2 = dDate2; + } + int field = 0; + if (interval.equals("yyyy")) { + field = 1; + } + else + { + if (interval.equals("m")) { + field = 2; + } + else + { + if (interval.equals("d")) { + field = 5; + } + else + { + if (interval.equals("y")) { + field = 5; + } + else + { + if (interval.equals("w")) { + field = 4; + } + else + { + if (interval.equals("ww")) { + field = 3; + } + else if (interval.equals("h")) + { + desiredField = 11; + } + else if (interval.equals("n")) + { + desiredField = 12; + } + else if (interval.equals("s")) + { + desiredField = 13; + } + else { + return -1L; + } + } + } + } + } + } + Calendar calTmp = Calendar.getInstance(); + calTmp.setTime(date1); + long nbOccurence = 0L; + calTmp.add(field, 1); + for (Date dateTemp = calTmp.getTime(); dateTemp.getTime() <= date2.getTime(); ) + { + calTmp.add(field, 1); + dateTemp = calTmp.getTime(); + nbOccurence += 1L; + } + Date dateTemp; + if ((desiredField == 11) || (desiredField == 12) || (desiredField == 13)) + { + calTmp.setTime(date1); + calTmp.add(field, (int)nbOccurence); + dateTemp = calTmp.getTime(); + switch (desiredField) + { + case 11: + nbOccurence *= 24L; + break; + case 12: + nbOccurence = nbOccurence * 24L * 60L; + break; + case 13: + nbOccurence = nbOccurence * 24L * 60L * 60L; + } + + calTmp.add(desiredField, 1); + for (dateTemp = calTmp.getTime(); dateTemp.getTime() <= date2.getTime(); ) + { + calTmp.add(desiredField, 1); + dateTemp = calTmp.getTime(); + nbOccurence += 1L; + } + } + + return (nbOccurence * coef); + } + + public static long dateDiff(Date date1, Date date2) + { + long date1ms = date1.getTime(); + long date2ms = date2.getTime(); + return (date2ms - date1ms); + } + + +} \ No newline at end of file diff --git a/common/common-core/src/main/java/apelet/common/core/util/DefaultDataSourceResolver.java b/common/common-core/src/main/java/apelet/common/core/util/DefaultDataSourceResolver.java new file mode 100644 index 0000000..cc7a2b5 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/DefaultDataSourceResolver.java @@ -0,0 +1,55 @@ +package apelet.common.core.util; + +import org.springframework.stereotype.Component; + +/** + * 常量值指向的数据源。 + * + * @author guifc + * @date 2023-08-04 + */ +@Component +public class DefaultDataSourceResolver implements DataSourceResolver { + + private static final ThreadLocal DEFAULT_CONTEXT_HOLDER = new ThreadLocal<>(); + + @Override + public Integer resolve(String arg, Integer intArg, Object[] methodArgs) { + Integer datasourceType = DEFAULT_CONTEXT_HOLDER.get(); + return datasourceType != null ? datasourceType : intArg; + } + + /** + * 设置报表数据源类型值。 + * + * @param type 数据源类型 + * @return 原有数据源类型,如果第一次设置则返回null。 + */ + public static Integer setDataSourceType(Integer type) { + Integer datasourceType = DEFAULT_CONTEXT_HOLDER.get(); + DEFAULT_CONTEXT_HOLDER.set(type); + return datasourceType; + } + + /** + * 获取当前报表数据库操作执行线程的数据源类型,同时由动态数据源的路由函数调用。 + * + * @return 数据源类型。 + */ + public static Integer getDataSourceType() { + return DEFAULT_CONTEXT_HOLDER.get(); + } + + /** + * 清除线程本地变量,以免内存泄漏。 + + * @param originalType 原有的数据源类型,如果该值为null,则情况本地化变量。 + */ + public static void unset(Integer originalType) { + if (originalType == null) { + DEFAULT_CONTEXT_HOLDER.remove(); + } else { + DEFAULT_CONTEXT_HOLDER.set(originalType); + } + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/ExportUtil.java b/common/common-core/src/main/java/apelet/common/core/util/ExportUtil.java new file mode 100644 index 0000000..0b38bce --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/ExportUtil.java @@ -0,0 +1,113 @@ +package apelet.common.core.util; + +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.exception.MyRuntimeException; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.poi.excel.ExcelUtil; +import cn.hutool.poi.excel.ExcelWriter; +import cn.jimmyshi.beanquery.BeanQuery; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.apache.commons.io.FilenameUtils; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 导出工具类,目前支持xlsx和csv两种类型。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class ExportUtil { + + /** + * 数据导出。目前仅支持xlsx和csv。 + * + * @param dataList 导出数据列表。 + * @param selectFieldMap 导出的数据字段,key为对象字段名称,value为中文标题名称。 + * @param filename 导出文件名。 + * @param 数据对象类型。 + * @throws IOException 文件操作失败。 + */ + public static void doExport( + Collection dataList, Map selectFieldMap, String filename) throws IOException { + if (CollUtil.isEmpty(dataList)) { + return; + } + StringBuilder sb = new StringBuilder(128); + for (Map.Entry e : selectFieldMap.entrySet()) { + sb.append(e.getKey()).append(" as ").append(e.getValue()).append(", "); + } + // 去掉末尾的逗号 + String selectFieldString = sb.substring(0, sb.length() - 2); + // 写出数据到xcel格式的输出流 + List> resultList = BeanQuery.select(selectFieldString).executeFrom(dataList); + normalizeMultiSelectList(resultList); + // 构建HTTP输出流参数 + HttpServletResponse response = ContextUtil.getHttpResponse(); + response.setHeader("content-type", "application/octet-stream"); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment;filename=" + filename); + if (ApplicationConstant.XLSX_EXT.equals(FilenameUtils.getExtension(filename))) { + ServletOutputStream out = response.getOutputStream(); + ExcelWriter writer = ExcelUtil.getWriter(true); + writer.setRowHeight(-1, 30); + writer.setColumnWidth(-1, 30); + writer.setColumnWidth(1, 20); + writer.write(resultList); + writer.flush(out); + writer.close(); + IoUtil.close(out); + } else if (ApplicationConstant.CSV_EXT.equals(FilenameUtils.getExtension(filename))) { + Collection headerList = selectFieldMap.values(); + String[] headerArray = new String[headerList.size()]; + headerList.toArray(headerArray); + CSVFormat format = CSVFormat.DEFAULT.withHeader(headerArray); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + try (Writer out = response.getWriter(); CSVPrinter printer = new CSVPrinter(out, format)) { + for (Map o : resultList) { + for (Map.Entry entry : o.entrySet()) { + printer.print(entry.getValue()); + } + printer.println(); + } + printer.flush(); + } catch (Exception e) { + log.error("Failed to call ExportUtil.doExport", e); + } + } else { + throw new MyRuntimeException("不支持的导出文件类型!"); + } + } + + @SuppressWarnings("unchecked") + private static void normalizeMultiSelectList(List> resultList) { + for (Map data : resultList) { + for (Map.Entry entry : data.entrySet()) { + if (entry.getValue() instanceof List) { + List> dictMapList = ((List>) entry.getValue()); + List nameList = dictMapList.stream() + .map(item -> item.get("name").toString()).collect(Collectors.toList()); + data.put(entry.getKey(), CollUtil.join(nameList, ",")); + } + } + } + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private ExportUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/ImportUtil.java b/common/common-core/src/main/java/apelet/common/core/util/ImportUtil.java new file mode 100644 index 0000000..b469ded --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/ImportUtil.java @@ -0,0 +1,352 @@ +package apelet.common.core.util; + +import apelet.common.core.annotation.RelationConstDict; +import apelet.common.core.annotation.RelationDict; +import apelet.common.core.annotation.RelationGlobalDict; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.exception.MyRuntimeException; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.poi.excel.ExcelUtil; +import cn.hutool.poi.excel.sax.handler.RowHandler; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.joda.time.DateTime; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 导入工具类,目前支持xlsx和xls两种类型。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class ImportUtil { + + /** + * 根据实体类的Class类型,生成导入的头信息。 + * + * @param modelClazz 实体对象的Class类型。 + * @param ignoreFields 忽略的字段名集合,如创建时间、创建人、更新时间、更新人等。 + * @param 实体对象类型。 + * @return 创建后的导入头信息列表。 + */ + public static List makeHeaderInfoList(Class modelClazz, Set ignoreFields) { + List resultList = new LinkedList<>(); + Field[] fields = ReflectUtil.getFields(modelClazz); + int index = 0; + for (Field field : fields) { + int modifiers = field.getModifiers(); + // transient类型的字段不能作为查询条件,静态字段和逻辑删除都不考虑。需要忽略的字段也要跳过。 + int transientMask = 128; + if ((modifiers & transientMask) == 1 + || Modifier.isStatic(modifiers) + || field.getAnnotation(TableId.class) != null + || field.getAnnotation(TableLogic.class) != null + || CollUtil.contains(ignoreFields, field.getName())) { + continue; + } + TableField tableField = field.getAnnotation(TableField.class); + if (tableField == null || tableField.exist()) { + ImportHeaderInfo headerInfo = new ImportHeaderInfo(); + headerInfo.fieldName = field.getName(); + headerInfo.index = index++; + makeHeaderInfoFieldTypeByField(field, headerInfo); + resultList.add(headerInfo); + } + } + return resultList; + } + + /** + * 保存导入文件。 + * + * @param baseDir 导入文件本地缓存的根目录。 + * @param subDir 导入文件本地缓存的子目录。 + * @param importFile 导入的文件。 + * @return 保存的本地文件名。 + */ + public static String saveImportFile( + String baseDir, String subDir, MultipartFile importFile) throws IOException { + StringBuilder sb = new StringBuilder(256); + sb.append(baseDir); + if (!StrUtil.endWith(baseDir, "/")) { + sb.append("/"); + } + sb.append("importedFile/"); + if (StrUtil.isNotBlank(subDir)) { + sb.append(subDir); + if (!StrUtil.endWith(subDir, "/")) { + sb.append("/"); + } + } + String pathname = sb.toString(); + sb.append(new DateTime().toString("yyyy-MM-dd-HH-mm-")); + sb.append(MyCommonUtil.generateUuid()) + .append(".").append(FileNameUtil.getSuffix(importFile.getOriginalFilename())); + String fullname = sb.toString(); + try { + byte[] bytes = importFile.getBytes(); + Path path = Paths.get(fullname); + // 如果没有files文件夹,则创建 + if (!Files.isWritable(path)) { + Files.createDirectories(Paths.get(pathname)); + } + // 文件写入指定路径 + Files.write(path, bytes); + } catch (IOException e) { + log.error("Failed to write imported file [" + importFile.getOriginalFilename() + " ].", e); + throw e; + } + return fullname; + } + + /** + * 导入指定的excel,基于SAX方式解析后返回数据列表。 + * + * @param headers 头信息数组。 + * @param skipHeader 是否跳过第一行,通常改行为头信息。 + * @param filename 文件名。 + * @return 解析后数据列表。 + */ + public static List> doImport( + ImportHeaderInfo[] headers, boolean skipHeader, String filename) { + Assert.notNull(headers); + Assert.isTrue(StrUtil.isNotBlank(filename)); + List> resultList = new LinkedList<>(); + ExcelUtil.readBySax(new File(filename), 0, createRowHandler(headers, skipHeader, resultList)); + return resultList; + } + + /** + * 导入指定的excel,基于SAX方式解析后返回Bean类型的数据列表。 + * + * @param headers 头信息数组。 + * @param skipHeader 是否跳过第一行,通常改行为头信息。 + * @param filename 文件名。 + * @param clazz Bean的Class类型。 + * @param translateDictFieldSet 需要进行反向翻译的字典字段集合。 + * @return 解析后数据列表。 + */ + public static List doImport( + ImportHeaderInfo[] headers, + boolean skipHeader, + String filename, + Class clazz, + Set translateDictFieldSet) { + // 这里将需要进行字典反向翻译的字段类型改为String,否则使用原有的字典Id类型时,无法正确执行下面的doImport方法。 + if (CollUtil.isNotEmpty(translateDictFieldSet)) { + for (ImportHeaderInfo header : headers) { + if (translateDictFieldSet.contains(header.fieldName)) { + header.fieldType = STRING_TYPE; + } + } + } + List> resultList = doImport(headers, skipHeader, filename); + if (CollUtil.isNotEmpty(translateDictFieldSet)) { + translateDictFieldSet.forEach(c -> doTranslateDict(resultList, clazz, c)); + } + return MyModelUtil.mapToBeanList(resultList, clazz); + } + + /** + * 转换数据列表中,需要进行反向字典翻译的字段。 + * + * @param dataList 数据列表。 + * @param modelClass 对象模型。 + * @param fieldName 需要进行字典反向翻译的字段名。注意,该字段为需要翻译替换的Java字段名,与此同时, + * 该字段 + DictMap后缀的字段名,必须被RelationConstDict和RelationDict注解标记。 + */ + @SuppressWarnings("unchecked") + public static void doTranslateDict(List> dataList, Class modelClass, String fieldName) { + if (CollUtil.isEmpty(dataList)) { + return; + } + Field field = ReflectUtil.getField(modelClass, fieldName + "DictMap"); + Assert.notNull(field); + Map inversedDictMap; + if (field.isAnnotationPresent(RelationConstDict.class)) { + RelationConstDict r = field.getAnnotation(RelationConstDict.class); + Field f = ReflectUtil.getField(r.constantDictClass(), "DICT_MAP"); + Map dictMap = (Map) ReflectUtil.getStaticFieldValue(f); + inversedDictMap = MapUtil.inverse(dictMap); + } else if (field.isAnnotationPresent(RelationDict.class)) { + RelationDict r = field.getAnnotation(RelationDict.class); + String slaveServiceName = r.slaveServiceName(); + if (StrUtil.isBlank(slaveServiceName)) { + slaveServiceName = r.slaveModelClass().getSimpleName() + "Service"; + } + BaseService service = + ApplicationContextHolder.getBean(StrUtil.lowerFirst(slaveServiceName)); + List dictDataList = service.getAllList(); + List> dataMapList = MyModelUtil.beanToMapList(dictDataList); + inversedDictMap = new HashMap<>(dataMapList.size()); + dataMapList.forEach(d -> + inversedDictMap.put(d.get(r.slaveNameField()).toString(), d.get(r.slaveIdField()))); + } else if (field.isAnnotationPresent(RelationGlobalDict.class)) { + RelationGlobalDict r = field.getAnnotation(RelationGlobalDict.class); + BaseService s = ApplicationContextHolder.getBean("globalDictService"); + Method m = ReflectUtil.getMethodByName(s.getClass(), "getGlobalDictItemDictMapFromCache"); + Map dictMap = ReflectUtil.invoke(s, m, r.dictCode(), null); + inversedDictMap = MapUtil.inverse(dictMap); + } else { + throw new UnsupportedOperationException("Only Support RelationConstDict and RelationDict Field"); + } + if (MapUtil.isEmpty(inversedDictMap)) { + log.warn("Dict Data List is EMPTY."); + return; + } + for (Map data : dataList) { + Object value = data.get(fieldName); + if (value != null) { + Object newValue = inversedDictMap.get(value.toString()); + if (newValue != null) { + data.put(fieldName, newValue); + } + } + } + } + + private static void makeHeaderInfoFieldTypeByField(Field field, ImportHeaderInfo headerInfo) { + if (field.getType().equals(Integer.class)) { + headerInfo.fieldType = INT_TYPE; + } else if (field.getType().equals(Long.class)) { + headerInfo.fieldType = LONG_TYPE; + } else if (field.getType().equals(String.class)) { + headerInfo.fieldType = STRING_TYPE; + } else if (field.getType().equals(Boolean.class)) { + headerInfo.fieldType = BOOLEAN_TYPE; + } else if (field.getType().equals(Date.class)) { + headerInfo.fieldType = DATE_TYPE; + } else if (field.getType().equals(Double.class)) { + headerInfo.fieldType = DOUBLE_TYPE; + } else if (field.getType().equals(Float.class)) { + headerInfo.fieldType = FLOAT_TYPE; + } else if (field.getType().equals(BigDecimal.class)) { + headerInfo.fieldType = BIG_DECIMAL_TYPE; + } else { + throw new MyRuntimeException("Unsupport Import FieldType"); + } + } + + private static RowHandler createRowHandler( + ImportHeaderInfo[] headers, boolean skipHeader, List> resultList) { + return new MyRowHandler(headers, skipHeader, resultList); + } + + public static final int INT_TYPE = 0; + public static final int LONG_TYPE = 1; + public static final int STRING_TYPE = 2; + public static final int BOOLEAN_TYPE = 3; + public static final int DATE_TYPE = 4; + public static final int DOUBLE_TYPE = 5; + public static final int FLOAT_TYPE = 6; + public static final int BIG_DECIMAL_TYPE = 7; + + @NoArgsConstructor + @AllArgsConstructor + @Data + public static class ImportHeaderInfo { + /** + * 对应的Java实体对象属性名。 + */ + private String fieldName; + /** + * 对应的Java实体对象类型。 + */ + private Integer fieldType; + /** + * 0 表示excel中的第一列。 + */ + private Integer index; + } + + private static class MyRowHandler implements RowHandler { + private ImportHeaderInfo[] headers; + private Map headerInfoMap; + private boolean skipHeader; + private List> resultList; + + public MyRowHandler(ImportHeaderInfo[] headers, boolean skipHeader, List> resultList) { + this.headers = headers; + this.skipHeader = skipHeader; + this.resultList = resultList; + this.headerInfoMap = Arrays.stream(headers) + .collect(Collectors.toMap(ImportHeaderInfo::getIndex, c -> c)); + } + + @Override + public void handle(int sheetIndex, long rowIndex, List rowList) { + if (this.skipHeader && rowIndex == 0) { + return; + } + int i = 0; + Map data = new HashMap<>(headers.length); + for (Object rowData : rowList) { + ImportHeaderInfo headerInfo = this.headerInfoMap.get(i++); + if (headerInfo == null) { + continue; + } + switch (headerInfo.fieldType) { + case INT_TYPE: + data.put(headerInfo.fieldName, Convert.toInt(rowData)); + break; + case LONG_TYPE: + data.put(headerInfo.fieldName, Convert.toLong(rowData)); + break; + case STRING_TYPE: + data.put(headerInfo.fieldName, Convert.toStr(rowData)); + break; + case BOOLEAN_TYPE: + data.put(headerInfo.fieldName, Convert.toBool(rowData)); + break; + case DATE_TYPE: + data.put(headerInfo.fieldName, Convert.toDate(rowData)); + break; + case DOUBLE_TYPE: + data.put(headerInfo.fieldName, Convert.toDouble(rowData)); + break; + case FLOAT_TYPE: + data.put(headerInfo.fieldName, Convert.toFloat(rowData)); + break; + case BIG_DECIMAL_TYPE: + data.put(headerInfo.fieldName, Convert.toBigDecimal(rowData)); + break; + default: + throw new MyRuntimeException( + "Invalid ImportHeaderInfo.fieldType [" + headerInfo.fieldType + "]."); + } + } + resultList.add(data); + } + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private ImportUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/IpUtil.java b/common/common-core/src/main/java/apelet/common/core/util/IpUtil.java new file mode 100644 index 0000000..b80472b --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/IpUtil.java @@ -0,0 +1,182 @@ +package apelet.common.core.util; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.reactive.ServerHttpRequest; + +import javax.servlet.http.HttpServletRequest; +import java.net.*; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; + +/** + * Ip工具类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class IpUtil { + + private static final String UNKNOWN = "unknown"; + + /** + * 通过Servlet的HttpRequest对象获取Ip地址。 + * + * @param request HttpRequest对象。 + * @return 本次请求的Ip地址。 + */ + public static String getRemoteIpAddress(HttpServletRequest request) { + String ip = null; + // X-Forwarded-For:Squid 服务代理 + String ipAddresses = request.getHeader("X-Forwarded-For"); + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // Proxy-Client-IP:apache 服务代理 + ipAddresses = request.getHeader("Proxy-Client-IP"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + ipAddresses = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // WL-Proxy-Client-IP:weblogic 服务代理 + ipAddresses = request.getHeader("WL-Proxy-Client-IP"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // HTTP_CLIENT_IP:有些代理服务器 + ipAddresses = request.getHeader("HTTP_CLIENT_IP"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // X-Real-IP:nginx服务代理 + ipAddresses = request.getHeader("X-Real-IP"); + } + // 有些网络通过多层代理,那么获取到的ip就会有多个,一般都是通过逗号(,)分割开来,并且第一个ip为客户端的真实IP + if (StrUtil.isNotBlank(ipAddresses)) { + ip = ipAddresses.split(",")[0]; + } + // 还是不能获取到,最后再通过request.getRemoteAddr();获取 + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + ip = request.getRemoteAddr(); + } + return ip; + } + + /** + * 通过Reactive的ServerHttpRequest对象获取Ip地址。 + * + * @param request ServerHttpRequest对象。 + * @return 本次请求的Ip地址。 + */ + public static String getRemoteIpAddress(ServerHttpRequest request) { + String ip = null; + // X-Forwarded-For:Squid 服务代理 + String ipAddresses = request.getHeaders().getFirst("X-Forwarded-For"); + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // Proxy-Client-IP:apache 服务代理 + ipAddresses = request.getHeaders().getFirst("Proxy-Client-IP"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // WL-Proxy-Client-IP:weblogic 服务代理 + ipAddresses = request.getHeaders().getFirst("WL-Proxy-Client-IP"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // HTTP_CLIENT_IP:有些代理服务器 + ipAddresses = request.getHeaders().getFirst("HTTP_CLIENT_IP"); + } + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + // X-Real-IP:nginx服务代理 + ipAddresses = request.getHeaders().getFirst("X-Real-IP"); + } + // 有些网络通过多层代理,那么获取到的ip就会有多个,一般都是通过逗号(,)分割开来,并且第一个ip为客户端的真实IP + if (ipAddresses != null) { + ip = ipAddresses.split(",")[0]; + } + // 还是不能获取到,最后再通过request.getRemoteAddr();获取 + if (StrUtil.isBlank(ipAddresses) || UNKNOWN.equalsIgnoreCase(ipAddresses)) { + InetSocketAddress address = request.getRemoteAddress(); + if (address == null) { + return ip; + } + InetAddress a = address.getAddress(); + if (a == null) { + return ip; + } + ip = a.getHostAddress(); + } + return ip; + } + + public static String getFirstLocalIpAddress() { + String ip; + try { + List ipList = getHostAddress(); + // default the first + ip = (!ipList.isEmpty()) ? ipList.get(0) : ""; + } catch (Exception ex) { + ip = ""; + log.error("Failed to call ", ex); + } + return ip; + } + + private static List getHostAddress() throws SocketException { + List ipList = new ArrayList<>(5); + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface ni = interfaces.nextElement(); + Enumeration allAddress = ni.getInetAddresses(); + while (allAddress.hasMoreElements()) { + InetAddress address = allAddress.nextElement(); + // skip the IPv6 addr + // skip the IPv6 addr + if (address.isLoopbackAddress() || address instanceof Inet6Address) { + continue; + } + String hostAddress = address.getHostAddress(); + ipList.add(hostAddress); + } + } + return ipList; + } + + public static String getIp() { + try { + InetAddress candidateAddress = null; + + Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + while (networkInterfaces.hasMoreElements()) { + NetworkInterface iface = networkInterfaces.nextElement(); + // 该网卡接口下的ip会有多个,也需要一个个的遍历,找到自己所需要的 + for (Enumeration inetAddrs = iface.getInetAddresses(); inetAddrs.hasMoreElements(); ) { + InetAddress inetAddr = inetAddrs.nextElement(); + // 排除loopback回环类型地址(不管是IPv4还是IPv6 只要是回环地址都会返回true) + if (!inetAddr.isLoopbackAddress()) { + if (inetAddr.isSiteLocalAddress()) { + // 如果是site-local地址,就是它了 就是我们要找的 + // ~~~~~~~~~~~~~绝大部分情况下都会在此处返回你的ip地址值~~~~~~~~~~~~~ + return inetAddr.getHostAddress(); + } + + // 若不是site-local地址 那就记录下该地址当作候选 + if (candidateAddress == null) { + candidateAddress = inetAddr; + } + + } + } + } + // 如果出去loopback回环地之外无其它地址了,那就回退到原始方案吧 + InetAddress inetAddress = candidateAddress == null ? InetAddress.getLocalHost() : candidateAddress; + return inetAddress.getHostAddress(); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private IpUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/JwtUtil.java b/common/common-core/src/main/java/apelet/common/core/util/JwtUtil.java new file mode 100644 index 0000000..7c884a4 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/JwtUtil.java @@ -0,0 +1,112 @@ +package apelet.common.core.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.Map; + +/** + * 基于JWT的Token生成工具类 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class JwtUtil { + + private static final String TOKEN_PREFIX = "Bearer "; + private static final String CLAIM_KEY_CREATEDTIME = "CreatedTime"; + + /** + * Token缺省过期时间是30分钟 + */ + private static final Long TOKEN_EXPIRATION = 1800000L; + /** + * 缺省情况下,Token会每5分钟被刷新一次 + */ + private static final Long REFRESH_TOKEN_INTERVAL = 300000L; + + /** + * 生成加密后的JWT令牌,生成的结果中包含令牌前缀,如"Bearer " + * + * @param claims 令牌中携带的数据 + * @param expirationMillisecond 过期的毫秒数 + * @return 生成后的令牌信息 + */ + public static String generateToken(Map claims, long expirationMillisecond, String signingKey) { + // 自动添加token的创建时间 + long createTime = System.currentTimeMillis(); + claims.put(CLAIM_KEY_CREATEDTIME, createTime); + SecretKey sk = Keys.hmacShaKeyFor(signingKey.getBytes()); + String token = Jwts.builder().claims(claims) + .signWith(sk, Jwts.SIG.HS256) + .expiration(new Date(createTime + expirationMillisecond)) + .compact(); + return TOKEN_PREFIX + token; + } + + /** + * 生成加密后的JWT令牌,生成的结果中包含令牌前缀,如"Bearer " + * + * @param claims 令牌中携带的数据 + * @return 生成后的令牌信息 + */ + public static String generateToken(Map claims, String signingKey) { + return generateToken(claims, TOKEN_EXPIRATION, signingKey); + } + + /** + * 获取token中的数据对象 + * + * @param token 令牌信息(需要包含令牌前缀,如"Bearer ") + * @return 令牌中的数据对象,解析视频返回null。 + */ + public static Claims parseToken(String token, String signingKey) { + if (token == null || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + String tokenKey = token.substring(TOKEN_PREFIX.length()); + Claims claims = null; + try { + SecretKey sk = Keys.hmacShaKeyFor(signingKey.getBytes()); + claims = Jwts.parser().verifyWith(sk).build().parseSignedClaims(tokenKey).getPayload(); + } catch (Exception e) { + log.error("Token Expired", e); + } + return claims; + } + + /** + * 判断令牌是否过期 + * + * @param claims 令牌解密后的Map对象。 + * @return true 过期,否则false。 + */ + public static boolean isNullOrExpired(Claims claims) { + return claims == null || claims.getExpiration().before(new Date()); + } + + /** + * 判断解密后的Token payload是否需要被强制刷新,如果需要,则调用generateToken方法重新生成Token。 + * + * @param claims Token解密后payload数据 + * @return true 需要刷新,否则false + */ + public static boolean needToRefresh(Claims claims) { + if (claims == null) { + return false; + } + Long createTime = (Long) claims.get(CLAIM_KEY_CREATEDTIME); + return createTime == null || System.currentTimeMillis() - createTime > REFRESH_TOKEN_INTERVAL; + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private JwtUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/LogMessageUtil.java b/common/common-core/src/main/java/apelet/common/core/util/LogMessageUtil.java new file mode 100644 index 0000000..645b0d8 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/LogMessageUtil.java @@ -0,0 +1,33 @@ +package apelet.common.core.util; + +/** + * 拼接日志消息的工具类。 + * 主要目标是,尽量保证日志输出的统一性,同时也可以有效减少与日志信息相关的常量字符串, + * 提高代码的规范度和可维护性。 + * + * @author guifc + * @date 2023-08-04 + */ +public class LogMessageUtil { + + /** + * RPC调用错误格式。 + */ + private static final String RPC_ERROR_MSG_FORMAT = "RPC Failed with Error message [%s]"; + + /** + * 组装RPC调用的错误信息。 + * + * @param errorMsg 具体的错误信息。 + * @return 格式化后的错误信息。 + */ + public static String makeRpcError(String errorMsg) { + return String.format(RPC_ERROR_MSG_FORMAT, errorMsg); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private LogMessageUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MaskFieldHandler.java b/common/common-core/src/main/java/apelet/common/core/util/MaskFieldHandler.java new file mode 100644 index 0000000..1c2f3ef --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MaskFieldHandler.java @@ -0,0 +1,21 @@ +package apelet.common.core.util; + +/** + * 自定义脱敏处理器接口。 + * + * @author guifc + * @date 2023-08-04 + */ +public interface MaskFieldHandler { + + /** + * 处理自定义的脱敏数据。可以根据表名和字段名,使用不同的自定义脱敏规则。 + * + * @param modelName 脱敏字段所在实体对象名。 + * @param fieldName 脱敏实体对象名中的字段属性名。 + * @param data 待脱敏的数据。 + * @param maskChar 脱敏掩码字符。 + * @return 脱敏后的数据。 + */ + String handleMask(String modelName, String fieldName, String data, char maskChar); +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MaskFieldUtil.java b/common/common-core/src/main/java/apelet/common/core/util/MaskFieldUtil.java new file mode 100644 index 0000000..256acca --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MaskFieldUtil.java @@ -0,0 +1,203 @@ +package apelet.common.core.util; + +import cn.hutool.core.util.CharUtil; +import cn.hutool.core.util.StrUtil; + +/** + * 脱敏的工具类。具体实现的源码基本来自hutool的DesensitizedUtil, + * 只是因为我们需要支持自定义脱敏字符,因此需要重写hutool中的工具类方法。 + * + * @author guifc + * @date 2023-08-04 + */ +public class MaskFieldUtil { + + /** + * 【中文姓名】只显示第一个汉字,其他隐藏为2个星号,比如:李**。 + * + * @param fullName 姓名。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的姓名。 + */ + public static String chineseName(String fullName, char maskChar) { + if (StrUtil.isBlank(fullName)) { + return StrUtil.EMPTY; + } + return StrUtil.replace(fullName, 1, fullName.length(), maskChar); + } + + /** + * 【身份证号】前1位 和后2位。 + * + * @param idCardNum 身份证。 + * @param front 保留:前面的front位数;从1开始。 + * @param end 保留:后面的end位数;从1开始。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的身份证。 + */ + public static String idCardNum(String idCardNum, int front, int end, char maskChar) { + return noMaskPrefixAndSuffix(idCardNum, front, end, maskChar); + } + + /** + * 字符串的前front位和后end位的字符,不会被脱敏。 + * + * @param str 原字符串。 + * @param front 保留:前面的front位数;从1开始。 + * @param end 保留:后面的end位数;从1开始。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的结果字符串。 + */ + public static String noMaskPrefixAndSuffix(String str, int front, int end, char maskChar) { + //身份证不能为空 + if (StrUtil.isBlank(str)) { + return StrUtil.EMPTY; + } + //需要截取的长度不能大于身份证号长度 + if ((front + end) > str.length()) { + return StrUtil.EMPTY; + } + //需要截取的不能小于0 + if (front < 0 || end < 0) { + return StrUtil.EMPTY; + } + return StrUtil.replace(str, front, str.length() - end, maskChar); + } + + /** + * 【固定电话 前四位,后两位。 + * + * @param num 固定电话。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的固定电话。 + */ + public static String fixedPhone(String num, char maskChar) { + if (StrUtil.isBlank(num)) { + return StrUtil.EMPTY; + } + return StrUtil.replace(num, 4, num.length() - 2, maskChar); + } + + /** + * 【手机号码】前三位,后4位,其他隐藏,比如135****2210。 + * + * @param num 移动电话。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的移动电话。 + */ + public static String mobilePhone(String num, char maskChar) { + if (StrUtil.isBlank(num)) { + return StrUtil.EMPTY; + } + return StrUtil.replace(num, 3, num.length() - 4, maskChar); + } + + /** + * 【地址】只显示到地区,不显示详细地址,比如:北京市海淀区****。 + * + * @param address 家庭住址。 + * @param sensitiveSize 敏感信息长度。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的家庭地址。 + */ + public static String address(String address, int sensitiveSize, char maskChar) { + if (StrUtil.isBlank(address)) { + return StrUtil.EMPTY; + } + int length = address.length(); + return StrUtil.replace(address, length - sensitiveSize, length, maskChar); + } + + /** + * 【电子邮箱】邮箱前缀仅显示第一个字母,前缀其他隐藏,用星号代替,@及后面的地址显示,比如:d**@126.com。 + * + * @param email 邮箱。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的邮箱。 + */ + public static String email(String email, char maskChar) { + if (StrUtil.isBlank(email)) { + return StrUtil.EMPTY; + } + int index = StrUtil.indexOf(email, '@'); + if (index <= 1) { + return email; + } + return StrUtil.replace(email, 1, index, maskChar); + } + + /** + * 【密码】密码的全部字符都用*代替,比如:******。 + * + * @param password 密码。 + * @return 脱敏后的密码。 + */ + public static String password(String password) { + if (StrUtil.isBlank(password)) { + return StrUtil.EMPTY; + } + return StrUtil.repeat('*', password.length()); + } + + /** + * 【中国车牌】车牌中间用*代替。 + * eg1:null -》 "" + * eg1:"" -》 "" + * eg3:苏D40000 -》 苏D4***0 + * eg4:陕A12345D -》 陕A1****D + * eg5:京A123 -》 京A123 如果是错误的车牌,不处理。 + * + * @param carLicense 完整的车牌号。 + * @param maskChar 遮掩字符。 + * @return 脱敏后的车牌。 + */ + public static String carLicense(String carLicense, char maskChar) { + if (StrUtil.isBlank(carLicense)) { + return StrUtil.EMPTY; + } + // 普通车牌 + if (carLicense.length() == 7) { + carLicense = StrUtil.replace(carLicense, 3, 6, maskChar); + } else if (carLicense.length() == 8) { + // 新能源车牌 + carLicense = StrUtil.replace(carLicense, 3, 7, maskChar); + } + return carLicense; + } + + /** + * 银行卡号脱敏。 + * eg: 1101 **** **** **** 3256。 + * + * @param bankCardNo 银行卡号。 + * @param maskChar 遮掩字符。 + * @return 脱敏之后的银行卡号。 + */ + public static String bankCard(String bankCardNo, char maskChar) { + if (StrUtil.isBlank(bankCardNo)) { + return bankCardNo; + } + bankCardNo = StrUtil.trim(bankCardNo); + if (bankCardNo.length() < 9) { + return bankCardNo; + } + final int length = bankCardNo.length(); + final int midLength = length - 8; + final StringBuilder buf = new StringBuilder(); + buf.append(bankCardNo, 0, 4); + for (int i = 0; i < midLength; ++i) { + if (i % 4 == 0) { + buf.append(CharUtil.SPACE); + } + buf.append(maskChar); + } + buf.append(CharUtil.SPACE).append(bankCardNo, length - 4, length); + return buf.toString(); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private MaskFieldUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MyCommonUtil.java b/common/common-core/src/main/java/apelet/common/core/util/MyCommonUtil.java new file mode 100644 index 0000000..d04e788 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MyCommonUtil.java @@ -0,0 +1,414 @@ +package apelet.common.core.util; + +import apelet.common.core.constant.AppDeviceType; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.validator.AddGroup; +import apelet.common.core.validator.UpdateGroup; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.alibaba.fastjson.JSON; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.groups.Default; +import java.lang.reflect.Field; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 脚手架中常用的基本工具方法集合,一般而言工程内部使用的方法。 + * + * @author guifc + * @date 2023-08-04 + */ +public class MyCommonUtil { + + private static final Validator VALIDATOR; + + static { + VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator(); + } + + /** + * 创建uuid。 + * + * @return 返回uuid。 + */ + public static String generateUuid() { + return UUID.randomUUID().toString().replace("-", ""); + } + + /** + * 对用户密码进行加盐后加密。 + * + * @param password 明文密码。 + * @param passwordSalt 盐值。 + * @return 加密后的密码。 + */ + public static String encrptedPassword(String password, String passwordSalt) { + return DigestUtil.md5Hex(password + passwordSalt); + } + + /** + * 这个方法一般用于Controller对于入口参数的基本验证。 + * 对于字符串,如果为空字符串,也将视为Blank,同时返回true。 + * + * @param objs 一组参数。 + * @return 返回是否存在null或空字符串的参数。 + */ + public static boolean existBlankArgument(Object...objs) { + for (Object obj : objs) { + if (MyCommonUtil.isBlankOrNull(obj)) { + return true; + } + } + return false; + } + + /** + * 结果和 existBlankArgument 相反。 + * + * @param objs 一组参数。 + * @return 返回是否存在null或空字符串的参数。 + */ + public static boolean existNotBlankArgument(Object...objs) { + for (Object obj : objs) { + if (!MyCommonUtil.isBlankOrNull(obj)) { + return true; + } + } + return false; + } + + /** + * 验证参数是否为空。 + * + * @param obj 待判断的参数。 + * @return 空或者null返回true,否则false。 + */ + public static boolean isBlankOrNull(Object obj) { + if (obj instanceof Collection) { + return CollUtil.isEmpty((Collection) obj); + } + return obj == null || (obj instanceof CharSequence && StrUtil.isBlank((CharSequence) obj)); + } + + /** + * 验证参数是否为非空。 + * + * @param obj 待判断的参数。 + * @return 空或者null返回false,否则true。 + */ + public static boolean isNotBlankOrNull(Object obj) { + return !isBlankOrNull(obj); + } + + /** + * 判断source是否等于其中任何一个对象值。 + * + * @param source 源对象。 + * @param others 其他对象。 + * @return 等于其中任何一个返回true,否则false。 + */ + public static boolean equalsAny(Object source, Object...others) { + for (Object one : others) { + if (ObjectUtil.equal(source, one)) { + return true; + } + } + return false; + } + + /** + * 判断模型对象是否通过校验,没有通过返回具体的校验错误信息。 + * + * @param model 带校验的model。 + * @param groups Validate绑定的校验组。 + * @return 没有错误返回null,否则返回具体的错误信息。 + */ + public static String getModelValidationError(T model, Class...groups) { + if (model != null) { + Set> constraintViolations = VALIDATOR.validate(model, groups); + if (!constraintViolations.isEmpty()) { + Iterator> it = constraintViolations.iterator(); + ConstraintViolation constraint = it.next(); + return constraint.getMessage(); + } + } + return null; + } + + /** + * 判断模型对象是否通过校验,没有通过返回具体的校验错误信息。 + * + * @param model 带校验的model。 + * @param forUpdate 是否为更新。 + * @return 没有错误返回null,否则返回具体的错误信息。 + */ + public static String getModelValidationError(T model, boolean forUpdate) { + if (model != null) { + Set> constraintViolations; + if (forUpdate) { + constraintViolations = VALIDATOR.validate(model, Default.class, UpdateGroup.class); + } else { + constraintViolations = VALIDATOR.validate(model, Default.class, AddGroup.class); + } + if (!constraintViolations.isEmpty()) { + Iterator> it = constraintViolations.iterator(); + ConstraintViolation constraint = it.next(); + return constraint.getMessage(); + } + } + return null; + } + + /** + * 判断模型对象是否通过校验,没有通过返回具体的校验错误信息。 + * + * @param modelList 带校验的model列表。 + * @param groups Validate绑定的校验组。 + * @return 没有错误返回null,否则返回具体的错误信息。 + */ + public static String getModelValidationError(List modelList, Class... groups) { + if (CollUtil.isNotEmpty(modelList)) { + for (T model : modelList) { + String errorMessage = getModelValidationError(model, groups); + if (StrUtil.isNotBlank(errorMessage)) { + return errorMessage; + } + } + } + return null; + } + + /** + * 判断模型对象是否通过校验,没有通过返回具体的校验错误信息。 + * + * @param modelList 带校验的model列表。 + * @param forUpdate 是否为更新。 + * @return 没有错误返回null,否则返回具体的错误信息。 + */ + public static String getModelValidationError(List modelList, boolean forUpdate) { + if (CollUtil.isNotEmpty(modelList)) { + for (T model : modelList) { + String errorMessage = getModelValidationError(model, forUpdate); + if (StrUtil.isNotBlank(errorMessage)) { + return errorMessage; + } + } + } + return null; + } + + /** + * 拼接参数中的字符串列表,用指定分隔符进行分割,同时每个字符串对象用单引号括起来。 + * + * @param dataList 字符串集合。 + * @param separator 分隔符。 + * @return 拼接后的字符串。 + */ + public static String joinString(Collection dataList, final char separator) { + int index = 0; + StringBuilder sb = new StringBuilder(128); + for (String data : dataList) { + sb.append("'").append(data).append("'"); + if (index++ != dataList.size() - 1) { + sb.append(separator); + } + } + return sb.toString(); + } + + /** + * 将SQL Like中的通配符替换为字符本身的含义,以便于比较。 + * + * @param str 待替换的字符串。 + * @return 替换后的字符串。 + */ + public static String replaceSqlWildcard(String str) { + if (StrUtil.isBlank(str)) { + return str; + } + return StrUtil.replaceChars(StrUtil.replaceChars(str, "_", "\\_"), "%", "\\%"); + } + + /** + * 获取对象中,非空字段的名字列表。 + * + * @param object 数据对象。 + * @param clazz 数据对象的class类型。 + * @param 数据对象类型。 + * @return 数据对象中,值不为NULL的字段数组。 + */ + public static String[] getNotNullFieldNames(T object, Class clazz) { + Field[] fields = ReflectUtil.getFields(clazz); + List fieldNameList = Arrays.stream(fields) + .filter(f -> ReflectUtil.getFieldValue(object, f) != null) + .map(Field::getName).collect(Collectors.toList()); + if (CollUtil.isNotEmpty(fieldNameList)) { + return fieldNameList.toArray(new String[]{}); + } + return new String[]{}; + } + + /** + * 在调用分布式事务产生错误时,通过该方法可构建一对多关联插入错误的字符串。 + * + * @param clazz 服务实现类的Class。 + * @param errorMessage 错误信息。 + * @param mainData 主数据。 + * @param relationData 一对多关联数据列表。 + * @param 主数据类型。 + * @param 一对多关联对象类型。 + * @return 拼接后的错误信息。 + */ + public static String makeAddRelationGlobalTransError( + Class clazz, String errorMessage, M mainData, List relationData) { + return String.format("Failed to remote call {%s} cascadeAddRelationList for error {%s} with main data {%s} and OneToMany relation data {%s}", + clazz.getSimpleName(), errorMessage, JSON.toJSONString(mainData), JSON.toJSONString(relationData)); + } + + /** + * 在调用分布式事务产生错误时,通过该方法可构建一对一关联插入错误的字符串。 + * + * @param clazz 服务实现类的Class。 + * @param errorMessage 错误信息。 + * @param mainData 主数据。 + * @param relationData 一对一关联数据列表。 + * @param 主数据类型。 + * @param 一对一关联对象类型。 + * @return 拼接后的错误信息。 + */ + public static String makeAddRelationGlobalTransError( + Class clazz, String errorMessage, M mainData, R relationData) { + return String.format("Failed to remote call {%s} cascadeAddRelation for error {%s} with main data {%s} and OneToOne relation data {%s}", + clazz.getSimpleName(), errorMessage, JSON.toJSONString(mainData), JSON.toJSONString(relationData)); + } + + /** + * 在调用分布式事务产生错误时,通过该方法可构建删除关联数据错误的字符串。 + * + * @param clazz 服务实现类的Class。 + * @param remoteClazz 远程客户端的Class。 + * @param errorMessage 错误信息。 + * @param id 主表主键值。 + * @param 主表主键类型。 + * @return 拼接后的错误信息。 + */ + public static String makeDeleteRelationGlobalTransError( + Class clazz, Class remoteClazz, String errorMessage, K id) { + return String.format("Failed to remote call {%s} cascadeDeleteRelation for error {%s} with remote client {%s} and id {%s}", + clazz.getSimpleName(), remoteClazz.getSimpleName(), errorMessage, id.toString()); + } + + /** + * 获取请求头中的设备信息。 + * + * @return 设备类型,具体值可参考AppDeviceType常量类。 + */ + public static int getDeviceType() { + // 缺省都按照Web登录方式设置,如果前端header中的值为不合法值,这里也不会报错,而是使用Web缺省方式。 + int deviceType = AppDeviceType.WEB; + String deviceTypeString = ContextUtil.getHttpRequest().getHeader("deviceType"); + if (StrUtil.isNotBlank(deviceTypeString)) { + Integer type = Integer.valueOf(deviceTypeString); + if (AppDeviceType.isValid(type)) { + deviceType = type; + } + } + return deviceType; + } + + /** + * 转换为字典格式的数据列表。 + * + * @param dataList 源数据列表。 + * @param idGetter 获取字典Id字段值的函数方法。 + * @param nameGetter 获取字典名字段值的函数方法。 + * @param 源数据对象类型。 + * @param 字典Id的类型。 + * @return 字典格式的数据列表。 + */ + public static List> toDictDataList( + Collection dataList, Function idGetter, Function nameGetter) { + if (CollUtil.isEmpty(dataList)) { + return new LinkedList<>(); + } + return dataList.stream().map(item -> { + Map dataMap = new HashMap<>(2); + dataMap.put(ApplicationConstant.DICT_ID, idGetter.apply(item)); + dataMap.put(ApplicationConstant.DICT_NAME, nameGetter.apply(item)); + return dataMap; + }).collect(Collectors.toList()); + } + + /** + * 转换为树形字典格式的数据列表。 + * + * @param dataList 源数据列表。 + * @param idGetter 获取字典Id字段值的函数方法。 + * @param nameGetter 获取字典名字段值的函数方法。 + * @param parentIdGetter 获取字典Id父字段值的函数方法。 + * @param 源数据对象类型。 + * @param 字典Id的类型。 + * @return 字典格式的数据列表。 + */ + public static List> toDictDataList( + Collection dataList, + Function idGetter, + Function nameGetter, + Function parentIdGetter) { + if (CollUtil.isEmpty(dataList)) { + return new LinkedList<>(); + } + return dataList.stream().map(item -> { + Map dataMap = new HashMap<>(2); + dataMap.put(ApplicationConstant.DICT_ID, idGetter.apply(item)); + dataMap.put(ApplicationConstant.DICT_NAME, nameGetter.apply(item)); + dataMap.put(ApplicationConstant.PARENT_ID, parentIdGetter.apply(item)); + return dataMap; + }).collect(Collectors.toList()); + } + + /** + * 转换为字典格式的数据列表,同时支持一个附加字段。 + * + * @param dataList 源数据列表。 + * @param idGetter 获取字典Id字段值的函数方法。 + * @param nameGetter 获取字典名字段值的函数方法。 + * @param extraName 附加字段名。。 + * @param extraGetter 获取附加字段值的函数方法。 + * @param 源数据对象类型。 + * @param 字典Id的类型。 + * @param 附加字段值的类型。 + * @return 字典格式的数据列表。 + */ + public static List> toDictDataList( + Collection dataList, + Function idGetter, + Function nameGetter, + String extraName, + Function extraGetter) { + if (CollUtil.isEmpty(dataList)) { + return new LinkedList<>(); + } + return dataList.stream().map(item -> { + Map dataMap = new HashMap<>(2); + dataMap.put(ApplicationConstant.DICT_ID, idGetter.apply(item)); + dataMap.put(ApplicationConstant.DICT_NAME, nameGetter.apply(item)); + dataMap.put(extraName, extraGetter.apply(item)); + return dataMap; + }).collect(Collectors.toList()); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private MyCommonUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MyCustomMaskFieldHandler.java b/common/common-core/src/main/java/apelet/common/core/util/MyCustomMaskFieldHandler.java new file mode 100644 index 0000000..3ce496f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MyCustomMaskFieldHandler.java @@ -0,0 +1,23 @@ +package apelet.common.core.util; + +import org.springframework.stereotype.Component; + +/** + * 缺省的自定义脱敏处理器的实现类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Component +public class MyCustomMaskFieldHandler implements MaskFieldHandler { + + @Override + public String handleMask(String modelName, String fieldName, String data, char maskChar) { + // 这里是我们默认提供的躺平实现方式。 + // 在默认生成的代码中,如果脱敏字段的处理类型为CUSTOM的时候,就会暂时使用 + // 该类为默认实现,其实这里就是一个占位符实现类。用户可根据需求自行实现自己所需的脱敏处理器实现类。 + // 实现后,可在脱敏字段的MaskField注解的handler参数中,改为自己的实现类。 + // 最后一句很重要,实现类必须是bean对象,如当前类用@Component注解标记。 + throw new UnsupportedOperationException("请仔细阅读上面的代码注解,并实现自己的处理类,以替代默认生成的自定义实现类!!"); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MyDateUtil.java b/common/common-core/src/main/java/apelet/common/core/util/MyDateUtil.java new file mode 100644 index 0000000..e9f485d --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MyDateUtil.java @@ -0,0 +1,320 @@ +package apelet.common.core.util; + +import apelet.common.core.object.Tuple2; +import org.apache.commons.lang3.time.DateUtils; +import org.joda.time.DateTime; +import org.joda.time.Period; +import org.joda.time.format.DateTimeFormat; +import org.joda.time.format.DateTimeFormatter; + +import java.util.Calendar; +import java.util.Date; + +import static org.joda.time.PeriodType.days; + +/** + * 日期工具类,主要封装了部分joda-time中的方法,让很多代码一行完成,同时统一了日期到字符串的pattern格式。 + * + * @author guifc + * @date 2023-08-04 + */ +public class MyDateUtil { + + /** + * 统一的日期pattern,今后可以根据自己的需求去修改。 + */ + public static final String COMMON_DATE_FORMAT = "yyyy-MM-dd"; + /** + * 统一的日期时间pattern,今后可以根据自己的需求去修改。 + */ + public static final String COMMON_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS"; + /** + * 统一的短日期时间pattern,今后可以根据自己的需求去修改。 + */ + public static final String COMMON_SHORT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + /** + * 缺省日期格式化器,提前获取提升运行时效率。 + */ + private static final DateTimeFormatter DATE_PARSE_FORMATTER = + DateTimeFormat.forPattern(MyDateUtil.COMMON_DATE_FORMAT); + /** + * 缺省日期时间格式化器,提前获取提升运行时效率。 + */ + private static final DateTimeFormatter DATETIME_PARSE_FORMATTER = + DateTimeFormat.forPattern(MyDateUtil.COMMON_DATETIME_FORMAT); + + /** + * 缺省短日期时间格式化器,提前获取提升运行时效率。 + */ + private static final DateTimeFormatter DATETIME_SHORT_PARSE_FORMATTER = + DateTimeFormat.forPattern(MyDateUtil.COMMON_SHORT_DATETIME_FORMAT); + + /** + * 获取一天的开始时间的字符串格式,如2019-08-03 00:00:00.000。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginTimeOfDay(DateTime dateTime) { + return dateTime.withTimeAtStartOfDay().toString(COMMON_DATETIME_FORMAT); + } + + /** + * 获取一天的结束时间的字符串格式,如2019-08-03 23:59:59.999。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getEndTimeOfDay(DateTime dateTime) { + return dateTime.withTime(23, 59, 59, 999).toString(COMMON_DATETIME_FORMAT); + } + + /** + * 获取一天的开始时间的字符串短格式,如2019-08-03 00:00:00。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginTimeOfDayWithShort(DateTime dateTime) { + return dateTime.withTimeAtStartOfDay().toString(COMMON_SHORT_DATETIME_FORMAT); + } + + /** + * 获取一天的结束时间的字符串短格式,如2019-08-03 23:59:59。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getEndTimeOfDayWithShort(DateTime dateTime) { + return dateTime.withTime(23, 59, 59, 999).toString(COMMON_SHORT_DATETIME_FORMAT); + } + + /** + * 获取参数时间对象所在周的第一天的日期时间短格式。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginDateTimeOfWeek(DateTime dateTime) { + return getBeginTimeOfDayWithShort(dateTime.dayOfWeek().withMinimumValue()); + } + + /** + * 获取参数时间对象所在周的结束时间的字符串短格式。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getEndDateTimeOfWeek(DateTime dateTime) { + return getEndTimeOfDayWithShort(dateTime.dayOfWeek().withMaximumValue()); + } + + /** + * 获取参数时间对象所在月份第一天的日期时间短格式。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginDateTimeOfMonth(DateTime dateTime) { + return getBeginTimeOfDayWithShort(dateTime.dayOfMonth().withMinimumValue()); + } + + /** + * 获取参数时间对象所在月份的结束时间的字符串短格式, + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getEndDateTimeOfMonth(DateTime dateTime) { + return getEndTimeOfDayWithShort(dateTime.dayOfMonth().withMaximumValue()); + } + + /** + * 获取参数时间对象所在年的第一天的日期时间短格式。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginDateTimeOfYear(DateTime dateTime) { + return getBeginTimeOfDayWithShort(dateTime.dayOfYear().withMinimumValue()); + } + + /** + * 获取参数时间对象所在年的结束时间的字符串短格式。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getEndDateTimeOfYear(DateTime dateTime) { + return getEndTimeOfDayWithShort(dateTime.dayOfYear().withMaximumValue()); + } + + + /** + * 获取参数时间对象所在季度的第一天的日期时间短格式。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginDateTimeOfQuarter(DateTime dateTime) { + int m = dateTime.getMonthOfYear(); + int m2 = 10; + if (m >= 1 && m <= 3) { + m2 = 1; + } else if (m >= 4 && m <= 6) { + m2 = 4; + } else if (m >= 7 && m <= 9) { + m2 = 7; + } + return getBeginTimeOfDayWithShort(dateTime.withMonthOfYear(m2).dayOfMonth().withMinimumValue()); + } + + /** + * 获取参数时间对象所在季度的结束时间的字符串短格式, + * + * @param dateTime 待格式化的日期时间对象。 + * @return 格式化后的字符串。 + */ + public static String getEndDateTimeOfQuarter(DateTime dateTime) { + int m = dateTime.getMonthOfYear(); + int m2 = 12; + if (m >= 1 && m <= 3) { + m2 = 3; + } else if (m >= 4 && m <= 6) { + m2 = 6; + } else if (m >= 7 && m <= 9) { + m2 = 9; + } + return getEndTimeOfDayWithShort(dateTime.withMonthOfYear(m2).dayOfMonth().withMaximumValue()); + } + + /** + * 获取一天中的开始时间和结束时间的字符串格式,如2019-08-03 00:00:00.000 和 2019-08-03 23:59:59.999。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 包含格式后字符串的二元组对象。 + */ + public static Tuple2 getDateTimeRangeOfDay(DateTime dateTime) { + return new Tuple2<>(getBeginTimeOfDay(dateTime), getEndTimeOfDay(dateTime)); + } + + /** + * 获取本月第一天的日期格式。如2019-08-01。 + * + * @param dateTime 待格式化的日期对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginDateOfMonth(DateTime dateTime) { + return dateTime.withDayOfMonth(1).toString(COMMON_DATE_FORMAT); + } + + /** + * 获取本月第一天的日期格式。如2019-08-01。 + * + * @param dateString 待格式化的日期字符串对象。 + * @return 格式化后的字符串。 + */ + public static String getBeginDateOfMonth(String dateString) { + DateTime dateTime = toDate(dateString); + return dateTime.withDayOfMonth(1).toString(COMMON_DATE_FORMAT); + } + + /** + * 计算指定日期距离今天相差的天数。 + * + * @param dateTime 待格式化的日期时间对象。 + * @return 相差天数。 + */ + public static int getDayDiffToNow(DateTime dateTime) { + return new Period(dateTime, new DateTime(), days()).getDays(); + } + + /** + * 将日期对象格式化为缺省的字符串格式。 + * + * @param dateTime 待格式化的日期对象。 + * @return 格式化后的字符串。 + */ + public static String toDateString(DateTime dateTime) { + return dateTime.toString(COMMON_DATE_FORMAT); + } + + /** + * 将日期时间对象格式化为缺省的字符串格式。 + * + * @param dateTime 待格式化的日期对象。 + * @return 格式化后的字符串。 + */ + public static String toDateTimeString(DateTime dateTime) { + return dateTime.toString(COMMON_DATETIME_FORMAT); + } + + /** + * 将缺省格式的日期字符串解析为日期对象。 + * + * @param dateString 待解析的字符串。 + * @return 解析后的日期对象。 + */ + public static DateTime toDate(String dateString) { + return DATE_PARSE_FORMATTER.parseDateTime(dateString); + } + + /** + * 将缺省格式的日期字符串解析为日期对象。 + * + * @param dateTimeString 待解析的字符串。 + * @return 解析后的日期对象。 + */ + public static DateTime toDateTime(String dateTimeString) { + return DATETIME_PARSE_FORMATTER.parseDateTime(dateTimeString); + } + + /** + * 将缺省格式的(不包含毫秒的)日期时间字符串解析为日期对象。 + * + * @param dateTimeString 待解析的字符串。 + * @return 解析后的日期对象。 + */ + public static DateTime toDateTimeWithoutMs(String dateTimeString) { + return DATETIME_SHORT_PARSE_FORMATTER.parseDateTime(dateTimeString); + } + + /** + * 截取时间到天。如2019-10-03 01:20:30 转换为 2019-10-03 00:00:00。 + * 由于没有字符串的中间转换,因此效率更高。 + * + * @param date 待截取日期对象。 + * @return 转换后日期对象。 + */ + public static Date truncateToDay(Date date) { + return DateUtils.truncate(date, Calendar.DAY_OF_MONTH); + } + + /** + * 截取时间到月。如2019-10-03 01:20:30 转换为 2019-10-01 00:00:00。 + * 由于没有字符串的中间转换,因此效率更高。 + * + * @param date 待截取日期对象。 + * @return 转换后日期对象。 + */ + public static Date truncateToMonth(Date date) { + return DateUtils.truncate(date, Calendar.MONTH); + } + + /** + * 截取时间到年。如2019-10-03 01:20:30 转换为 2019-01-01 00:00:00。 + * 由于没有字符串的中间转换,因此效率更高。 + * + * @param date 待截取日期对象。 + * @return 转换后日期对象。 + */ + public static Date truncateToYear(Date date) { + return DateUtils.truncate(date, Calendar.YEAR); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private MyDateUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MyModelUtil.java b/common/common-core/src/main/java/apelet/common/core/util/MyModelUtil.java new file mode 100644 index 0000000..0d528dc --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MyModelUtil.java @@ -0,0 +1,880 @@ +package apelet.common.core.util; + +import apelet.common.core.annotation.*; +import apelet.common.core.exception.InvalidDataFieldException; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.object.TokenData; +import apelet.common.core.object.Tuple2; +import apelet.common.core.upload.UploadResponseInfo; +import apelet.common.core.upload.UploadStoreInfo; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.google.common.base.CaseFormat; +import lombok.extern.slf4j.Slf4j; + +import java.io.Serializable; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 负责Model数据操作、类型转换和关系关联等行为的工具类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class MyModelUtil { + + /** + * 数值型字段。 + */ + public static final Integer NUMERIC_FIELD_TYPE = 0; + /** + * 字符型字段。 + */ + public static final Integer STRING_FIELD_TYPE = 1; + /** + * 日期型字段。 + */ + public static final Integer DATE_FIELD_TYPE = 2; + /** + * 整个工程的实体对象中,创建者Id字段的Java对象名。 + */ + public static final String CREATE_USER_ID_FIELD_NAME = "createUserId"; + /** + * 整个工程的实体对象中,创建时间字段的Java对象名。 + */ + public static final String CREATE_TIME_FIELD_NAME = "createTime"; + /** + * 整个工程的实体对象中,更新者Id字段的Java对象名。 + */ + public static final String UPDATE_USER_ID_FIELD_NAME = "updateUserId"; + /** + * 整个工程的实体对象中,更新时间字段的Java对象名。 + */ + public static final String UPDATE_TIME_FIELD_NAME = "updateTime"; + /** + * mapToColumnName和mapToColumnInfo使用的缓存。 + */ + private static final Map> CACHED_COLUMNINFO_MAP = new ConcurrentHashMap<>(); + + /** + * 将Bean转换为Map。 + * + * @param data Bean数据对象。 + * @param Bean对象类型。 + * @return 转换后的Map。 + */ + public static Map beanToMap(T data) { + return BeanUtil.beanToMap(data); + } + + /** + * 将Bean的数据列表转换为Map列表。 + * + * @param dataList Bean数据列表。 + * @param Bean对象类型。 + * @return 转换后的Map列表。 + */ + public static List> beanToMapList(List dataList) { + return CollUtil.isEmpty(dataList) ? new LinkedList<>() + : dataList.stream().map(BeanUtil::beanToMap).collect(Collectors.toList()); + } + + /** + * 将Map的数据列表转换为Bean列表。 + * + * @param dataList Map数据列表。 + * @param Bean对象类型。 + * @return 转换后的Bean对象列表。 + */ + public static List mapToBeanList(List> dataList, Class clazz) { + return CollUtil.isEmpty(dataList) ? new LinkedList<>() + : dataList.stream().map(data -> BeanUtil.toBeanIgnoreError(data, clazz)).collect(Collectors.toList()); + } + + /** + * 拷贝源类型的集合数据到目标类型的集合中,其中源类型和目标类型中的对象字段类型完全相同。 + * NOTE: 该函数主要应用于框架中,Dto和Model之间的copy,特别针对一对一关联的深度copy。 + * 在Dto中,一对一对象可以使用Map来表示,而不需要使用从表对象的Dto。 + * + * @param sourceCollection 源类型集合。 + * @param targetClazz 目标类型的Class对象。 + * @param 源类型。 + * @param 目标类型。 + * @return copy后的目标类型对象集合。 + */ + public static List copyCollectionTo(Collection sourceCollection, Class targetClazz) { + List targetList = null; + if (sourceCollection == null) { + return targetList; + } + targetList = new LinkedList<>(); + if (CollUtil.isNotEmpty(sourceCollection)) { + for (S source : sourceCollection) { + try { + T target = targetClazz.newInstance(); + BeanUtil.copyProperties(source, target); + targetList.add(target); + } catch (Exception e) { + log.error("Failed to call MyModelUtil.copyCollectionTo", e); + return Collections.emptyList(); + } + } + } + return targetList; + } + + /** + * 拷贝源类型的对象数据到目标类型的对象中,其中源类型和目标类型中的对象字段类型完全相同。 + * NOTE: 该函数主要应用于框架中,Dto和Model之间的copy,特别针对一对一关联的深度copy。 + * 在Dto中,一对一对象可以使用Map来表示,而不需要使用从表对象的Dto。 + * + * @param source 源类型对象。 + * @param targetClazz 目标类型的Class对象。 + * @param 源类型。 + * @param 目标类型。 + * @return copy后的目标类型对象。 + */ + public static T copyTo(S source, Class targetClazz) { + if (source == null) { + return null; + } + try { + T target = targetClazz.newInstance(); + BeanUtil.copyProperties(source, target); + return target; + } catch (Exception e) { + log.error("Failed to call MyModelUtil.copyTo", e); + return null; + } + } + + /** + * 映射Model对象的字段反射对象,获取与该字段对应的数据库列名称。 + * + * @param field 字段反射对象。 + * @param modelClazz Model对象的Class类。 + * @return 该字段所对应的数据表列名称。 + */ + public static String mapToColumnName(Field field, Class modelClazz) { + return mapToColumnName(field.getName(), modelClazz); + } + + /** + * 映射Model对象的字段名称,获取与该字段对应的数据库列名称。 + * + * @param fieldName 字段名称。 + * @param modelClazz Model对象的Class类。 + * @return 该字段所对应的数据表列名称。 + */ + public static String mapToColumnName(String fieldName, Class modelClazz) { + Tuple2 columnInfo = mapToColumnInfo(fieldName, modelClazz); + return columnInfo == null ? null : columnInfo.getFirst(); + } + + /** + * 映射Model对象的字段反射对象,获取与该字段对应的数据库列名称。 + * 如果没有匹配到ColumnName,则立刻抛出异常。 + * + * @param field 字段反射对象。 + * @param modelClazz Model对象的Class类。 + * @return 该字段所对应的数据表列名称。 + */ + public static String safeMapToColumnName(Field field, Class modelClazz) { + return safeMapToColumnName(field.getName(), modelClazz); + } + + /** + * 映射Model对象的字段名称,获取与该字段对应的数据库列名称。 + * 如果没有匹配到ColumnName,则立刻抛出异常。 + * + * @param fieldName 字段名称。 + * @param modelClazz Model对象的Class类。 + * @return 该字段所对应的数据表列名称。 + */ + public static String safeMapToColumnName(String fieldName, Class modelClazz) { + String columnName = mapToColumnName(fieldName, modelClazz); + if (columnName == null) { + throw new InvalidDataFieldException(modelClazz.getSimpleName(), fieldName); + } + return columnName; + } + + /** + * 映射Model对象的字段名称,获取与该字段对应的数据库列名称和字段类型。 + * + * @param fieldName 字段名称。 + * @param modelClazz Model对象的Class类。 + * @return 该字段所对应的数据表列名称和Java字段类型。 + */ + public static Tuple2 mapToColumnInfo(String fieldName, Class modelClazz) { + if (StrUtil.isBlank(fieldName)) { + return null; + } + StringBuilder sb = new StringBuilder(128); + sb.append(modelClazz.getName()).append("-#-").append(fieldName); + Tuple2 columnInfo = CACHED_COLUMNINFO_MAP.get(sb.toString()); + if (columnInfo != null) { + return columnInfo; + } + Field field = ReflectUtil.getField(modelClazz, fieldName); + if (field == null) { + return null; + } + TableField c = field.getAnnotation(TableField.class); + String columnName = null; + if (c == null) { + TableId id = field.getAnnotation(TableId.class); + if (id != null) { + columnName = id.value(); + } + } + if (StrUtil.isBlank(columnName)) { + columnName = c == null ? CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, fieldName) : c.value(); + if (StrUtil.isBlank(columnName)) { + columnName = CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, fieldName); + } + } + // 这里缺省情况下都是按照整型去处理,因为他覆盖太多的类型了。 + // 如Integer/Long/Double/BigDecimal,可根据实际情况完善和扩充。 + String typeName = field.getType().getSimpleName(); + Integer type = NUMERIC_FIELD_TYPE; + if (String.class.getSimpleName().equals(typeName)) { + type = STRING_FIELD_TYPE; + } else if (Date.class.getSimpleName().equals(typeName)) { + type = DATE_FIELD_TYPE; + } + columnInfo = new Tuple2<>(columnName, type); + CACHED_COLUMNINFO_MAP.put(sb.toString(), columnInfo); + return columnInfo; + } + + /** + * 映射Model主对象的Class名称,到Model所对应的表名称。 + * + * @param modelClazz Model主对象的Class。 + * @return Model对象对应的数据表名称。 + */ + public static String mapToTableName(Class modelClazz) { + TableName t = modelClazz.getAnnotation(TableName.class); + return t == null ? null : t.value(); + } + + /** + * 根据参数中的数据列表和字段提取函数,封装stream api的方式返回指定字段的数据。 + * + * @param dataList 数据对象列表。 + * @param fieldGetterFunc 字段获取函数。 + * @param 数据对象类型。 + * @param 返回字段类型。 + * @return 指定字段Set集合。 + */ + public static Set retrieveFieldSet(Collection dataList, Function fieldGetterFunc) { + return dataList.stream().map(fieldGetterFunc).collect(Collectors.toSet()); + } + + /** + * 主Model类型中,遍历所有包含RelationConstDict注解的字段,并将关联的静态字典中的数据, + * 填充到thisModel对象的被注解字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModel 主对象。 + * @param 主表对象类型。 + */ + @SuppressWarnings("unchecked") + public static void makeConstDictRelation(Class thisClazz, T thisModel) { + if (thisModel == null) { + return; + } + Field[] fields = ReflectUtil.getFields(thisClazz); + for (Field field : fields) { + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, field.getName()); + RelationConstDict r = thisTargetField.getAnnotation(RelationConstDict.class); + if (r == null) { + continue; + } + Field dictMapField = ReflectUtil.getField(r.constantDictClass(), "DICT_MAP"); + Map dictMap = + (Map) ReflectUtil.getFieldValue(r.constantDictClass(), dictMapField); + Object id = ReflectUtil.getFieldValue(thisModel, r.masterIdField()); + if (id != null) { + String name = dictMap.get(id); + if (name != null) { + Map m = new HashMap<>(2); + m.put("id", id); + m.put("name", name); + ReflectUtil.setFieldValue(thisModel, thisTargetField, m); + } + } + } + } + + /** + * 主Model类型中,遍历所有包含RelationConstDict注解的字段,并将关联的静态字典中的数据, + * 填充到thisModelList集合元素对象的被注解字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param 主表对象类型。 + */ + @SuppressWarnings("unchecked") + public static void makeConstDictRelation(Class thisClazz, List thisModelList) { + if (CollUtil.isEmpty(thisModelList)) { + return; + } + List thisModelList2 = thisModelList.stream().filter(Objects::nonNull).collect(Collectors.toList()); + Field[] fields = ReflectUtil.getFields(thisClazz); + for (Field field : fields) { + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, field.getName()); + RelationConstDict r = thisTargetField.getAnnotation(RelationConstDict.class); + if (r == null) { + continue; + } + Field dictMapField = ReflectUtil.getField(r.constantDictClass(), "DICT_MAP"); + Map dictMap = + (Map) ReflectUtil.getFieldValue(r.constantDictClass(), dictMapField); + for (T thisModel : thisModelList2) { + Object id = ReflectUtil.getFieldValue(thisModel, r.masterIdField()); + if (id != null) { + String name = dictMap.get(id); + if (name != null) { + Map m = new HashMap<>(2); + m.put("id", id); + m.put("name", name); + ReflectUtil.setFieldValue(thisModel, thisTargetField, m); + } + } + } + } + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationDict注解参数,将被关联对象thatModel中的数据, + * 关联到thisModel对象的thisRelationField字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModel 主对象。 + * @param thatModel 字典关联对象。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeDictRelation( + Class thisClazz, T thisModel, R thatModel, String thisRelationField) { + if (thatModel == null || thisModel == null) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationDict r = thisTargetField.getAnnotation(RelationDict.class); + Object slaveId = ReflectUtil.getFieldValue(thatModel, r.slaveIdField()); + if (slaveId != null) { + Map m = new HashMap<>(2); + m.put("id", slaveId); + m.put("name", ReflectUtil.getFieldValue(thatModel, r.slaveNameField())); + ReflectUtil.setFieldValue(thisModel, thisTargetField, m); + } + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationDict注解参数,将被关联对象集合thatModelList中的数据, + * 逐个关联到thisModelList每一个元素的thisRelationField字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param thatModelList 字典关联对象列表集合。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeDictRelation( + Class thisClazz, List thisModelList, List thatModelList, String thisRelationField) { + if (CollUtil.isEmpty(thatModelList) || CollUtil.isEmpty(thisModelList)) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationDict r = thisTargetField.getAnnotation(RelationDict.class); + Field masterIdField = ReflectUtil.getField(thisClazz, r.masterIdField()); + Class thatClass = r.slaveModelClass(); + Field slaveIdField = ReflectUtil.getField(thatClass, r.slaveIdField()); + Field slaveNameField = ReflectUtil.getField(thatClass, r.slaveNameField()); + Map thatMap = new HashMap<>(20); + thatModelList.forEach(thatModel -> { + Object id = ReflectUtil.getFieldValue(thatModel, slaveIdField); + if (id != null) { + thatMap.put(id, thatModel); + } + }); + thisModelList.forEach(thisModel -> { + if (thisModel != null) { + Object id = ReflectUtil.getFieldValue(thisModel, masterIdField); + if (id != null) { + R thatModel = thatMap.get(id); + if (thatModel != null) { + Map m = new HashMap<>(4); + m.put("id", id); + m.put("name", ReflectUtil.getFieldValue(thatModel, slaveNameField)); + ReflectUtil.setFieldValue(thisModel, thisTargetField, m); + } + } + } + }); + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationDict注解参数,将被关联对象集合thatModelMap中的数据, + * 逐个关联到thisModelList每一个元素的thisRelationField字段中。 + * 该函数之所以使用Map,主要出于性能优化考虑,在连续使用thatModelMap进行关联时,有效的避免了从多次从List转换到Map的过程。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param thatMadelMap 字典关联对象映射集合。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeDictRelation( + Class thisClazz, List thisModelList, Map thatMadelMap, String thisRelationField) { + if (MapUtil.isEmpty(thatMadelMap) || CollUtil.isEmpty(thisModelList)) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationDict r = thisTargetField.getAnnotation(RelationDict.class); + Field masterIdField = ReflectUtil.getField(thisClazz, r.masterIdField()); + Class thatClass = r.slaveModelClass(); + Field slaveNameField = ReflectUtil.getField(thatClass, r.slaveNameField()); + thisModelList.forEach(thisModel -> { + if (thisModel != null) { + Object id = ReflectUtil.getFieldValue(thisModel, masterIdField); + if (id != null) { + R thatModel = thatMadelMap.get(id); + if (thatModel != null) { + Map m = new HashMap<>(4); + m.put("id", id); + m.put("name", ReflectUtil.getFieldValue(thatModel, slaveNameField)); + ReflectUtil.setFieldValue(thisModel, thisTargetField, m); + } + } + } + }); + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationGlobalDict注解参数,全局字典dictMap中的字典数据, + * 逐个关联到thisModelList每一个元素的thisRelationField字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param dictMap 全局字典数据。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + */ + public static void makeGlobalDictRelation( + Class thisClazz, List thisModelList, Map dictMap, String thisRelationField) { + if (MapUtil.isEmpty(dictMap) || CollUtil.isEmpty(thisModelList)) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationGlobalDict r = thisTargetField.getAnnotation(RelationGlobalDict.class); + Field masterIdField = ReflectUtil.getField(thisClazz, r.masterIdField()); + thisModelList.forEach(thisModel -> { + if (thisModel != null) { + Object id = ReflectUtil.getFieldValue(thisModel, masterIdField); + if (id != null) { + String name = dictMap.get(id.toString()); + if (name != null) { + Map m = new HashMap<>(2); + m.put("id", id); + m.put("name", name); + ReflectUtil.setFieldValue(thisModel, thisTargetField, m); + } + } + } + }); + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationOneToOne注解参数,将被关联对象列表thatModelList中的数据, + * 逐个关联到thisModelList每一个元素的thisRelationField字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param thatModelList 一对一关联对象列表。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeOneToOneRelation( + Class thisClazz, List thisModelList, List thatModelList, String thisRelationField) { + if (CollUtil.isEmpty(thatModelList) || CollUtil.isEmpty(thisModelList)) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationOneToOne r = thisTargetField.getAnnotation(RelationOneToOne.class); + Field masterIdField = ReflectUtil.getField(thisClazz, r.masterIdField()); + Class thatClass = r.slaveModelClass(); + Field slaveIdField = ReflectUtil.getField(thatClass, r.slaveIdField()); + Map thatMap = new HashMap<>(20); + thatModelList.forEach(thatModel -> { + Object id = ReflectUtil.getFieldValue(thatModel, slaveIdField); + if (id != null) { + thatMap.put(id, thatModel); + } + }); + thisModelList.forEach(thisModel -> { + Object id = ReflectUtil.getFieldValue(thisModel, masterIdField); + if (id != null) { + R thatModel = thatMap.get(id); + if (thatModel != null) { + if (thisTargetField.getType().equals(Map.class)) { + ReflectUtil.setFieldValue(thisModel, thisTargetField, BeanUtil.beanToMap(thatModel)); + } else { + ReflectUtil.setFieldValue(thisModel, thisTargetField, thatModel); + } + } + } + }); + } + + /** + * 根据主对象和关联对象各自的关联Id函数,将主对象列表和关联对象列表中的数据关联到一起,并将关联对象 + * 设置到主对象的指定关联字段中。 + * NOTE: 用于主对象关联字段中,没有包含RelationOneToOne注解的场景。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param thisIdGetterFunc 主对象Id的Getter函数。 + * @param thatModelList 关联对象列表。 + * @param thatIdGetterFunc 关联对象Id的Getter函数。 + * @param thisRelationField 主对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeOneToOneRelation( + Class thisClazz, + List thisModelList, + Function thisIdGetterFunc, + List thatModelList, + Function thatIdGetterFunc, + String thisRelationField) { + makeOneToOneRelation(thisClazz, thisModelList, + thisIdGetterFunc, thatModelList, thatIdGetterFunc, thisRelationField, false); + } + + /** + * 根据主对象和关联对象各自的关联Id函数,将主对象列表和关联对象列表中的数据关联到一起,并将关联对象 + * 设置到主对象的指定关联字段中。 + * NOTE: 用于主对象关联字段中,没有包含RelationOneToOne注解的场景。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param thisIdGetterFunc 主对象Id的Getter函数。 + * @param thatModelList 关联对象列表。 + * @param thatIdGetterFunc 关联对象Id的Getter函数。 + * @param thisRelationField 主对象中保存被关联对象的字段名称。 + * @param orderByThatList 如果为true,则按照ThatModelList的顺序输出。同时thisModelList被排序后的新列表替换。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeOneToOneRelation( + Class thisClazz, + List thisModelList, + Function thisIdGetterFunc, + List thatModelList, + Function thatIdGetterFunc, + String thisRelationField, + boolean orderByThatList) { + if (CollUtil.isEmpty(thisModelList)) { + return; + } + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + boolean isMap = thisTargetField.getType().equals(Map.class); + if (orderByThatList) { + List newThisModelList = new LinkedList<>(); + Map thisModelMap = + thisModelList.stream().collect(Collectors.toMap(thisIdGetterFunc, c -> c)); + thatModelList.forEach(thatModel -> { + Object thatId = thatIdGetterFunc.apply(thatModel); + if (thatId != null) { + T thisModel = thisModelMap.get(thatId); + if (thisModel != null) { + ReflectUtil.setFieldValue(thisModel, thisTargetField, normalize(isMap, thatModel)); + newThisModelList.add(thisModel); + } + } + }); + thisModelList.clear(); + thisModelList.addAll(newThisModelList); + return; + } + Map thatMadelMap = + thatModelList.stream().collect(Collectors.toMap(thatIdGetterFunc, c -> c)); + thisModelList.forEach(thisModel -> { + Object thisId = thisIdGetterFunc.apply(thisModel); + if (thisId != null) { + R thatModel = thatMadelMap.get(thisId); + if (thatModel != null) { + ReflectUtil.setFieldValue(thisModel, thisTargetField, normalize(isMap, thatModel)); + } + } + }); + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationOneToMany注解参数,将被关联对象列表thatModelList中的数据, + * 逐个关联到thisModelList每一个元素的thisRelationField字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param thatModelList 一对多关联对象列表。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 从表对象类型。 + */ + public static void makeOneToManyRelation( + Class thisClazz, List thisModelList, List thatModelList, String thisRelationField) { + if (CollUtil.isEmpty(thatModelList) || CollUtil.isEmpty(thisModelList)) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationOneToMany r = thisTargetField.getAnnotation(RelationOneToMany.class); + Field masterIdField = ReflectUtil.getField(thisClazz, r.masterIdField()); + Class thatClass = r.slaveModelClass(); + Field slaveIdField = ReflectUtil.getField(thatClass, r.slaveIdField()); + Map> thatMap = new HashMap<>(20); + thatModelList.forEach(thatModel -> { + Object id = ReflectUtil.getFieldValue(thatModel, slaveIdField); + if (id != null) { + List thatModelSubList = thatMap.computeIfAbsent(id, k -> new LinkedList<>()); + thatModelSubList.add(thatModel); + } + }); + thisModelList.forEach(thisModel -> { + Object id = ReflectUtil.getFieldValue(thisModel, masterIdField); + if (id != null) { + List thatModel = thatMap.get(id); + if (thatModel != null) { + ReflectUtil.setFieldValue(thisModel, thisTargetField, thatModel); + } + } + }); + } + + /** + * 在主Model类型中,根据thisRelationField字段的RelationManyToMany注解参数,将被关联对象列表relationModelList中的数据, + * 逐个关联到thisModelList每一个元素的thisRelationField字段中。 + * + * @param thisClazz 主对象的Class对象。 + * @param thisModelList 主对象列表。 + * @param relationModelList 多对多关联对象列表。 + * @param thisRelationField 主表对象中保存被关联对象的字段名称。 + * @param 主表对象类型。 + * @param 关联表对象类型。 + */ + public static void makeManyToManyRelation( + Class thisClazz, List thisModelList, List relationModelList, String thisRelationField) { + if (CollUtil.isEmpty(relationModelList) || CollUtil.isEmpty(thisModelList)) { + return; + } + // 这里不做任何空值判断,从而让配置错误在调试期间即可抛出 + Field thisTargetField = ReflectUtil.getField(thisClazz, thisRelationField); + RelationManyToMany r = thisTargetField.getAnnotation(RelationManyToMany.class); + Field masterIdField = ReflectUtil.getField(thisClazz, r.relationMasterIdField()); + Class thatClass = r.relationModelClass(); + Field slaveIdField = ReflectUtil.getField(thatClass, r.relationMasterIdField()); + Map> thatMap = new HashMap<>(20); + relationModelList.forEach(thatModel -> { + Object id = ReflectUtil.getFieldValue(thatModel, slaveIdField); + thatMap.computeIfAbsent(id, k -> new LinkedList<>()).add(thatModel); + }); + thisModelList.forEach(thisModel -> { + Object id = ReflectUtil.getFieldValue(thisModel, masterIdField); + List thatModel = thatMap.get(id); + if (thatModel != null) { + ReflectUtil.setFieldValue(thisModel, thisTargetField, thatModel); + } + }); + } + + private static Object normalize(boolean isMap, M model) { + return isMap ? BeanUtil.beanToMap(model) : model; + } + + /** + * 获取上传字段的存储信息。 + * + * @param modelClass model的class对象。 + * @param uploadFieldName 上传字段名。 + * @param model的类型。 + * @return 字段的上传存储信息对象。该值始终不会返回null。 + */ + public static UploadStoreInfo getUploadStoreInfo(Class modelClass, String uploadFieldName) { + UploadStoreInfo uploadStoreInfo = new UploadStoreInfo(); + Field uploadField = ReflectUtil.getField(modelClass, uploadFieldName); + if (uploadField == null) { + throw new UnsupportedOperationException("The Field [" + + uploadFieldName + "] doesn't exist in Model [" + modelClass.getSimpleName() + "]."); + } + uploadStoreInfo.setSupportUpload(false); + UploadFlagColumn anno = uploadField.getAnnotation(UploadFlagColumn.class); + if (anno != null) { + uploadStoreInfo.setSupportUpload(true); + uploadStoreInfo.setStoreType(anno.storeType()); + } + return uploadStoreInfo; + } + + /** + * 在插入实体对象数据之前,可以调用该方法,初始化通用字段的数据。 + * + * @param data 实体对象。 + * @param 实体对象类型。 + */ + public static void fillCommonsForInsert(M data) { + Field createdByField = ReflectUtil.getField(data.getClass(), CREATE_USER_ID_FIELD_NAME); + if (createdByField != null) { + ReflectUtil.setFieldValue(data, createdByField, TokenData.takeFromRequest().getUserId()); + } + Field createTimeField = ReflectUtil.getField(data.getClass(), CREATE_TIME_FIELD_NAME); + if (createTimeField != null) { + ReflectUtil.setFieldValue(data, createTimeField, new Date()); + } + Field updatedByField = ReflectUtil.getField(data.getClass(), UPDATE_USER_ID_FIELD_NAME); + if (updatedByField != null) { + ReflectUtil.setFieldValue(data, updatedByField, TokenData.takeFromRequest().getUserId()); + } + Field updateTimeField = ReflectUtil.getField(data.getClass(), UPDATE_TIME_FIELD_NAME); + if (updateTimeField != null) { + ReflectUtil.setFieldValue(data, updateTimeField, new Date()); + } + } + + /** + * 在更新实体对象数据之前,可以调用该方法,更新通用字段的数据。 + * + * @param data 实体对象。 + * @param originalData 原有实体对象。 + * @param 实体对象类型。 + */ + public static void fillCommonsForUpdate(M data, M originalData) { + Object createdByValue = ReflectUtil.getFieldValue(originalData, CREATE_USER_ID_FIELD_NAME); + if (createdByValue != null) { + ReflectUtil.setFieldValue(data, CREATE_USER_ID_FIELD_NAME, createdByValue); + } + Object createTimeValue = ReflectUtil.getFieldValue(originalData, CREATE_TIME_FIELD_NAME); + if (createTimeValue != null) { + ReflectUtil.setFieldValue(data, CREATE_TIME_FIELD_NAME, createTimeValue); + } + Field updatedByField = ReflectUtil.getField(data.getClass(), UPDATE_USER_ID_FIELD_NAME); + if (updatedByField != null) { + ReflectUtil.setFieldValue(data, updatedByField, TokenData.takeFromRequest().getUserId()); + } + Field updateTimeField = ReflectUtil.getField(data.getClass(), UPDATE_TIME_FIELD_NAME); + if (updateTimeField != null) { + ReflectUtil.setFieldValue(data, updateTimeField, new Date()); + } + } + + /** + * 为实体对象字段设置缺省值。如果data对象中指定字段的值为NULL,则设置缺省值,否则跳过。 + * + * @param data 实体对象。 + * @param fieldName 实体对象字段名。 + * @param defaultValue 缺省值。 + * @param 实体对象类型。 + * @param 缺省值类型。 + */ + public static void setDefaultValue(M data, String fieldName, V defaultValue) { + Object v = ReflectUtil.getFieldValue(data, fieldName); + if (v == null) { + ReflectUtil.setFieldValue(data, fieldName, defaultValue); + } + } + + /** + * 获取当前数据对象中,所有上传文件字段的数据,并将上传后的文件名存到集合中并返回。 + * + * @param data 数据对象。 + * @param clazz 数据对象的Class类型。 + * @param 数据对象类型。 + * @return 当前数据对象中,所有上传文件字段中,文件名属性的集合。 + */ + public static Set extractDownloadFileName(M data, Class clazz) { + Set resultSet = new HashSet<>(); + Field[] fields = ReflectUtil.getFields(clazz); + for (Field field : fields) { + if (field.isAnnotationPresent(UploadFlagColumn.class)) { + String v = (String) ReflectUtil.getFieldValue(data, field); + List fileInfoList = JSON.parseArray(v, UploadResponseInfo.class); + if (CollUtil.isNotEmpty(fileInfoList)) { + fileInfoList.forEach(fileInfo -> resultSet.add(fileInfo.getFilename())); + } + } + } + return resultSet; + } + + /** + * 获取当前数据对象列表中,所有上传文件字段的数据,并将上传后的文件名存到集合中并返回。 + * + * @param dataList 数据对象。 + * @param clazz 数据对象的Class类型。 + * @param 数据对象类型。 + * @return 当前数据对象中,所有上传文件字段中,文件名属性的集合。 + */ + public static Set extractDownloadFileName(List dataList, Class clazz) { + Set resultSet = new HashSet<>(); + if (CollUtil.isEmpty(dataList)) { + return resultSet; + } + dataList.forEach(data -> resultSet.addAll(extractDownloadFileName(data, clazz))); + return resultSet; + } + + /** + * 根据数据对象指定字段的类型,将参数中的字段值集合转换为匹配的值类型集合。 + * @param clazz 数据对象的Class。 + * @param fieldName 字段名。 + * @param fieldValues 字符型的字段值集合。 + * @param 对象类型。 + * @return 转换后的字段值集合。 + */ + public static Set convertToTypeValues( + Class clazz, String fieldName, List fieldValues) { + Field f = ReflectUtil.getField(clazz, fieldName); + if (f == null) { + String errorMsg = "数据对象 [" + clazz.getSimpleName() + " ] 中,不存在该数据字段 [" + fieldName + "]!"; + throw new MyRuntimeException(errorMsg); + } + if (f.getType().equals(Long.class)) { + return fieldValues.stream().map(Long::valueOf).collect(Collectors.toSet()); + } else if (f.getType().equals(Integer.class)) { + return fieldValues.stream().map(Integer::valueOf).collect(Collectors.toSet()); + } + return new HashSet<>(fieldValues); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private MyModelUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/MyPageUtil.java b/common/common-core/src/main/java/apelet/common/core/util/MyPageUtil.java new file mode 100644 index 0000000..6855b05 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/MyPageUtil.java @@ -0,0 +1,135 @@ +package apelet.common.core.util; + +import apelet.common.core.base.mapper.BaseModelMapper; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.Tuple2; +import cn.hutool.core.collection.CollUtil; +import cn.jimmyshi.beanquery.BeanQuery; +import com.alibaba.fastjson.JSONObject; +import com.github.pagehelper.Page; +import org.apache.commons.collections4.CollectionUtils; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 生成带有分页信息的数据列表 + * + * @author guifc + * @date 2023-08-04 + */ +public class MyPageUtil { + + private static final String DATA_LIST_LITERAL = "dataList"; + private static final String TOTAL_COUNT_LITERAL = "totalCount"; + + /** + * 用户构建带有分页信息的数据列表。 + * + * @param dataList 数据列表,该参数必须是调用PageMethod.startPage之后,立即执行mybatis查询操作的结果集。 + * @param includeFields 结果集中需要返回到前端的字段,多个字段之间逗号分隔。 + * @param 源数据类型。 + * @return 返回只是包含includeFields字段的数据列表,以及结果集TotalCount。 + */ + public static JSONObject makeResponseData(List dataList, String includeFields) { + JSONObject pageData = new JSONObject(); + pageData.put(DATA_LIST_LITERAL, BeanQuery.select(includeFields).from(dataList).execute()); + if (dataList instanceof Page) { + pageData.put(TOTAL_COUNT_LITERAL, ((Page)dataList).getTotal()); + } + return pageData; + } + + /** + * 用户构建带有分页信息的数据列表。 + * + * @param dataList 数据列表,该参数必须是调用PageMethod.startPage之后,立即执行mybatis查询操作的结果集。 + * @param 源数据类型。 + * @return 返回分页数据对象。 + */ + public static MyPageData makeResponseData(List dataList) { + MyPageData pageData = new MyPageData<>(); + pageData.setDataList(dataList); + if (dataList instanceof Page) { + pageData.setTotalCount(((Page)dataList).getTotal()); + } + return pageData; + } + + /** + * 用户构建带有分页信息的数据列表。 + * + * @param dataList 数据列表,该参数必须是调用PageMethod.startPage之后,立即执行mybatis查询操作的结果集。 + * @param totalCount 总数量。 + * @param 源数据类型。 + * @return 返回分页数据对象。 + */ + public static MyPageData makeResponseData(List dataList, Long totalCount) { + MyPageData pageData = new MyPageData<>(); + pageData.setDataList(dataList); + if (totalCount != null) { + pageData.setTotalCount(totalCount); + } + return pageData; + } + + /** + * 用户构建带有分页信息的数据列表。 + * + * @param dataList 实体对象数据列表。 + * @param modelMapper 实体对象到DomainVO对象的数据映射器。 + * @param DomainVO对象类型。 + * @param 实体对象类型。 + * @return 返回分页数据对象。 + */ + public static MyPageData makeResponseData(List dataList, BaseModelMapper modelMapper) { + long totalCount = 0L; + if (CollectionUtils.isEmpty(dataList)) { + // 这里需要构建分页数据对象,统一前端数据格式 + return MyPageData.emptyPageData(); + } + if (dataList instanceof Page) { + totalCount = ((Page) dataList).getTotal(); + } + return MyPageUtil.makeResponseData(modelMapper.fromModelList(dataList), totalCount); + } + + /** + * 构建带有分页信息的数据列表。 + * + * @param dataList 实体对象数据列表。 + * @param 结果类型。 + * @param 实体对象类型。 + * @return 返回分页数据对象。 + */ + public static MyPageData makeResponseData(List dataList, Function converter) { + long totalCount = 0L; + if (CollUtil.isEmpty(dataList)) { + // 这里需要构建分页数据对象,统一前端数据格式 + return MyPageData.emptyPageData(); + } + if (dataList instanceof Page) { + totalCount = ((Page) dataList).getTotal(); + } + List resultList = dataList.stream().map(converter).collect(Collectors.toList()); + return MyPageUtil.makeResponseData(resultList, totalCount); + } + + /** + * 用户构建带有分页信息的数据列表。 + * + * @param responseData 第一个数据时数据列表,第二个是列表数量。 + * @param 源数据类型。 + * @return 返回分页数据对象。 + */ + public static MyPageData makeResponseData(Tuple2, Long> responseData) { + return makeResponseData(responseData.getFirst(), responseData.getSecond()); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private MyPageUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/RedisKeyUtil.java b/common/common-core/src/main/java/apelet/common/core/util/RedisKeyUtil.java new file mode 100644 index 0000000..8f73929 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/RedisKeyUtil.java @@ -0,0 +1,163 @@ +package apelet.common.core.util; + +import apelet.common.core.object.TokenData; + +/** + * Redis 键生成工具类。 + * + * @author guifc + * @date 2023-08-04 + */ +public class RedisKeyUtil { + + private static final String SESSIONID_PREFIX = "SESSIONID:"; + + /** + * 获取通用的session缓存的键前缀。 + * + * @return session缓存的键前缀。 + */ + public static String getSessionIdPrefix() { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData.getTenantId() == null) { + return SESSIONID_PREFIX; + } + return SESSIONID_PREFIX + tokenData.getTenantId() + "_"; + } + + /** + * 获取指定用户Id的session缓存的键前缀。 + * + * @param loginName 指定的用户登录名。 + * @return session缓存的键前缀。 + */ + public static String getSessionIdPrefix(String loginName) { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData.getTenantId() == null) { + return SESSIONID_PREFIX + loginName + "_"; + } + return SESSIONID_PREFIX + tokenData.getTenantId() + "_" + loginName + "_"; + } + + /** + * 获取指定用户Id和登录设备类型的session缓存的键前缀。 + * + * @param loginName 指定的用户登录名。 + * @param deviceType 设备类型。 + * @return session缓存的键前缀。 + */ + public static String getSessionIdPrefix(String loginName, int deviceType) { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData.getTenantId() == null) { + return SESSIONID_PREFIX + loginName + "_" + deviceType + "_"; + } + return SESSIONID_PREFIX + tokenData.getTenantId() + "_" + loginName + "_" + deviceType + "_"; + } + + /** + * 计算SessionId返回存储于Redis中的键。 + * + * @param sessionId 会话Id。 + * @return 会话存储于Redis中的键值。 + */ + public static String makeSessionIdKey(String sessionId) { + return SESSIONID_PREFIX + sessionId; + } + + /** + * 计算SessionId关联的权限数据存储于Redis中的键。 + * + * @param sessionId 会话Id。 + * @return 会话关联的权限数据存储于Redis中的键值。 + */ + public static String makeSessionPermIdKey(String sessionId) { + return "PERM:" + sessionId; + } + + /** + * 计算SessionId关联的数据权限数据存储于Redis中的键。 + * + * @param sessionId 会话Id。 + * @return 会话关联的数据权限数据存储于Redis中的键值。 + */ + public static String makeSessionDataPermIdKey(String sessionId) { + return "DATA_PERM:" + sessionId; + } + + /** + * 计算包含全局字典及其数据项的缓存键。 + * + * @param dictCode 全局字典编码。 + * @return 全局字典指定编码的缓存键。 + */ + public static String makeGlobalDictKey(String dictCode) { + return "GLOBAL_DICT:" + dictCode; + } + + /** + * 计算仅仅包含全局字典对象数据的缓存键。 + * + * @param dictCode 全局字典编码。 + * @return 全局字典指定编码的缓存键。 + */ + public static String makeGlobalDictOnlyKey(String dictCode) { + return "GLOBAL_DICT_ONLY:" + dictCode; + } + + /** + * 计算会话的菜单Id关联权限资源URL的缓存键。 + * + * @param sessionId 会话Id。 + * @param menuId 菜单Id。 + * @return 计算后的缓存键。 + */ + public static String makeSessionMenuPermKey(String sessionId, Object menuId) { + return "SESSION_MENU_ID:" + sessionId + "-" + menuId.toString(); + } + + /** + * 计算会话的菜单Id关联权限资源URL的缓存键的前缀。 + * + * @param sessionId 会话Id。 + * @return 计算后的缓存键前缀。 + */ + public static String getSessionMenuPermPrefix(String sessionId) { + return "SESSION_MENU_ID:" + sessionId + "-"; + } + + /** + * 计算会话关联的白名单URL的缓存键。 + * + * @param sessionId 会话Id。 + * @return 计算后的缓存键。 + */ + public static String makeSessionWhiteListPermKey(String sessionId) { + return "SESSION_WHITE_LIST:" + sessionId; + } + + /** + * 计算会话关联指定部门Ids的子部门Ids的缓存键。 + * + * @param sessionId 会话Id。 + * @param deptIds 部门Id,多个部门Id之间逗号分割。 + * @return 计算后的缓存键。 + */ + public static String makeSessionChildrenDeptIdKey(String sessionId, String deptIds) { + return "SESSION_CHILDREN_DEPT_ID:" + sessionId + "-" + deptIds; + } + + /** + * 计算租户编码的缓存键。 + * + * @param tenantCode 租户编码。 + */ + public static String makeTenantCodeKey(String tenantCode) { + return "TENANT_CODE:" + tenantCode; + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private RedisKeyUtil() { + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/RsaUtil.java b/common/common-core/src/main/java/apelet/common/core/util/RsaUtil.java new file mode 100644 index 0000000..0c9e248 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/RsaUtil.java @@ -0,0 +1,102 @@ +package apelet.common.core.util; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.asymmetric.RSA; +import lombok.extern.slf4j.Slf4j; + +import java.security.*; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; +import java.util.Map; + +/** + * Java RSA 加密工具类。 + * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +public class RsaUtil { + + /** + * 密钥长度 于原文长度对应 以及越长速度越慢 + */ + private static final int KEY_SIZE = 1024; + /** + * 用于封装随机产生的公钥与私钥 + */ + private static final Map KEY_MAP = MapUtil.newHashMap(); + + /** + * 随机生成密钥对。 + */ + public static void genKeyPair() throws NoSuchAlgorithmException { + // KeyPairGenerator类用于生成公钥和私钥对,基于RSA算法生成对象 + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + // 初始化密钥对生成器 + keyPairGen.initialize(KEY_SIZE, new SecureRandom()); + // 生成一个密钥对,保存在keyPair中 + KeyPair keyPair = keyPairGen.generateKeyPair(); + // 得到私钥 + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // 得到公钥 + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + String publicKeyString = Base64.getEncoder().encodeToString(publicKey.getEncoded()); + // 得到私钥字符串 + String privateKeyString = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + // 将公钥和私钥保存到Map + // 0表示公钥 + KEY_MAP.put(0, publicKeyString); + // 1表示私钥 + KEY_MAP.put(1, privateKeyString); + } + + /** + * RSA公钥加密。 + * + * @param str 加密字符串 + * @param publicKey 公钥 + * @return 密文 + */ + public static String encrypt(String str, String publicKey) { + RSA rsa = new RSA(null, publicKey); + return Base64.getEncoder().encodeToString(rsa.encrypt(str, KeyType.PublicKey)); + } + + /** + * RSA私钥解密。 + * + * @param str 加密字符串 + * @param privateKey 私钥 + * @return 明文 + */ + public static String decrypt(String str, String privateKey) { + RSA rsa = new RSA(privateKey, null); + // 64位解码加密后的字符串 + return new String(rsa.decrypt(Base64.getDecoder().decode(str), KeyType.PrivateKey)); + } + + public static void main(String[] args) throws Exception { + long temp = System.currentTimeMillis(); + // 生成公钥和私钥 + genKeyPair(); + // 加密字符串 + log.info("公钥:" + KEY_MAP.get(0)); + log.info("私钥:" + KEY_MAP.get(1)); + log.info("生成密钥消耗时间:" + (System.currentTimeMillis() - temp) / 1000.0 + "秒"); + log.info("生成后的公钥前端使用!"); + log.info("生成后的私钥后台使用!"); + String message = "RSA测试ABCD~!@#$"; + log.info("原文:" + message); + temp = System.currentTimeMillis(); + String messageEn = encrypt(message, KEY_MAP.get(0)); + log.info("密文:" + messageEn); + log.info("加密消耗时间:" + (System.currentTimeMillis() - temp) / 1000.0 + "秒"); + temp = System.currentTimeMillis(); + String messageDe = decrypt(messageEn, KEY_MAP.get(1)); + log.info("解密:" + messageDe); + log.info("解密消耗时间:" + (System.currentTimeMillis() - temp) / 1000.0 + "秒"); + } +} \ No newline at end of file diff --git a/common/common-core/src/main/java/apelet/common/core/util/SQLUtils.java b/common/common-core/src/main/java/apelet/common/core/util/SQLUtils.java new file mode 100644 index 0000000..c1c8824 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/SQLUtils.java @@ -0,0 +1,96 @@ +package apelet.common.core.util; + +import lombok.extern.slf4j.Slf4j; + +import javax.sql.RowSet; +import javax.sql.rowset.JdbcRowSet; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * @author Soon + * @version 1.0 + * Create by 2024/4/13 20:45 + */ +@Slf4j +public class SQLUtils { + public static final void cleanup(Connection cn) + { + cleanup(null, null, cn); + } + + public static final void cleanup(Statement stmt) + { + cleanup(null, stmt, null); + } + + public static final void cleanup(Statement stmt, Connection cn) + { + cleanup(null, stmt, cn); + } + + public static final void cleanup(ResultSet rs) + { + cleanup(rs, null, null); + } + + public static final void cleanup(ResultSet rs, Statement stmt) + { + cleanup(rs, stmt, null); + } + + public static void cleanup(ResultSet rs, Statement stmt, Connection cn) + { + if (rs != null) + { + try { + rs.close(); + } catch (Exception _) { + log.error("cleanup error", _); + } + rs = null; + } + + if (stmt != null) + { + try { + stmt.close(); + } catch (Exception _) { + log.error("cleanup error", _); + } + stmt = null; + } + + if (cn == null) + return; + try { + cn.close(); + } + catch (Exception _) { + log.error("cleanup error", _); + } + cn = null; + } + + public static void execute(Connection conn, String sql) + throws SQLException + { + Statement stmt = null; + try + { + stmt = conn.createStatement(); + stmt.execute(sql); + } + catch (SQLException sqle) + { + } + finally + { + cleanup(stmt); + } + } + + +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/TreeNode.java b/common/common-core/src/main/java/apelet/common/core/util/TreeNode.java new file mode 100644 index 0000000..091db95 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/TreeNode.java @@ -0,0 +1,92 @@ +package apelet.common.core.util; + +import cn.hutool.core.util.ObjectUtil; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 将列表结构组建为树结构的工具类。 + * + * @param 对象类型。 + * @param 节点之间关联键的类型。 + * + * @author guifc + * @date 2023-08-04 + */ +@Data +public class TreeNode { + + private K id; + private K parentId; + private T data; + private List> childList = new ArrayList<>(); + + /** + * 将列表结构组建为树结构的工具方法。 + * + * @param dataList 数据列表结构。 + * @param idFunc 获取关联id的函数对象。 + * @param parentIdFunc 获取关联ParentId的函数对象。 + * @param root 根节点。 + * @param 数据对象类型。 + * @param 节点之间关联键的类型。 + * @return 源数据对象的树结构存储。 + */ + public static List> build( + List dataList, Function idFunc, Function parentIdFunc, K root) { + List> treeNodeList = new ArrayList<>(); + for (T data : dataList) { + if (ObjectUtil.equals(parentIdFunc.apply(data), idFunc.apply(data))) { + continue; + } + TreeNode dataNode = new TreeNode<>(); + dataNode.setId(idFunc.apply(data)); + dataNode.setParentId(parentIdFunc.apply(data)); + dataNode.setData(data); + treeNodeList.add(dataNode); + } + return root == null ? toBuildTreeWithoutRoot(treeNodeList) : toBuildTree(treeNodeList, root); + } + + private static List> toBuildTreeWithoutRoot(List> treeNodes) { + Map> treeNodeMap = + treeNodes.stream().collect(Collectors.toMap(TreeNode::getId, n -> n)); + List> treeNodeList = new ArrayList<>(); + for (TreeNode treeNode : treeNodes) { + TreeNode parentNode = treeNodeMap.get(treeNode.getParentId()); + if (parentNode == null) { + treeNodeList.add(treeNode); + } else { + parentNode.add(treeNode); + } + } + return treeNodeList; + } + + private static List> toBuildTree(List> treeNodes, K root) { + List> treeNodeList = new ArrayList<>(); + for (TreeNode treeNode : treeNodes) { + if (root.equals(treeNode.getParentId())) { + treeNodeList.add(treeNode); + } + for (TreeNode it : treeNodes) { + if (it.getParentId() == treeNode.getId()) { + if (treeNode.getChildList() == null) { + treeNode.setChildList(new ArrayList<>()); + } + treeNode.add(it); + } + } + } + return treeNodeList; + } + + private void add(TreeNode node) { + childList.add(node); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/util/TypeConversionUtils.java b/common/common-core/src/main/java/apelet/common/core/util/TypeConversionUtils.java new file mode 100644 index 0000000..6e682fa --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/util/TypeConversionUtils.java @@ -0,0 +1,744 @@ +package apelet.common.core.util; + +/** + * @author Soon + * @version 1.0 + * Create by 2024/4/10 16:09 + */ + + +import apelet.common.core.exception.IllegalConversionException; +import apelet.common.core.object.ObjectValue; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.sql.Time; +import java.sql.Timestamp; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; + +public class TypeConversionUtils +{ + private static Boolean useJdk15Method = null; + + public static String objToString(Object obj) + { + if (obj == null) + return null; + if (obj instanceof String) { + return ((String)obj); + } + + + return obj.toString(); + } + + public static BigDecimal objToBigDecimal(Object obj) + throws IllegalConversionException + { + if (obj == null) + return null; + if (obj instanceof BigDecimal) + return ((BigDecimal)obj); + if ((obj instanceof String) && + (StringUtils.isEmpty((String)obj))) { + return null; + } + + try + { + if (obj instanceof Double) { + return BigDecimal.valueOf(((Double)obj).doubleValue()); + } + if (obj instanceof JSONObject) { + return null; + } + return new BigDecimal(obj.toString()); + } + catch (NumberFormatException ex) + { + throw new IllegalConversionException("NumberFormatException"); + } + } + + public static BigDecimal objToBigDecimal(Object obj, int scale) + throws IllegalConversionException + { + if (obj == null) + return null; + if ((obj instanceof String) && + (StringUtils.isEmpty((String)obj))) { + return null; + } + + if (obj instanceof BigDecimal) + { + BigDecimal bigdecimal = (BigDecimal)obj; + obj = bigdecimal.setScale(scale, 6); + return ((BigDecimal)obj); + } + try + { + if (obj instanceof Integer) + { + BigDecimal bigdecimal = new BigDecimal(((Integer)obj).intValue()); + return bigdecimal.setScale(scale, 6); + } + if (obj instanceof Float) + { + BigDecimal bigdecimal = new BigDecimal(((Float)obj).doubleValue()); + return bigdecimal.setScale(scale, 6); + } + if (obj instanceof Double) + { + BigDecimal bigdecimal = new BigDecimal(((Double)obj).doubleValue()); + return bigdecimal.setScale(scale, 6); + } + if (obj instanceof String) + { + BigDecimal bigdecimal = new BigDecimal((String)obj); + return bigdecimal.setScale(scale, 6); + } + + BigDecimal bigdecimal = new BigDecimal(obj.toString()); + return bigdecimal.setScale(scale, 6); + } + catch (NumberFormatException _ex) + { + throw new IllegalConversionException("NumberFormatException"); + } + } + + public static boolean objToBoolean(Object obj) + throws IllegalConversionException + { + if (obj == null) return false; + if (obj instanceof Boolean) return ((Boolean)obj).booleanValue(); + String s = obj.toString(); + if (s.equalsIgnoreCase("true")) + return true; + if (s.equalsIgnoreCase("yes")) + return true; + if (s.equals("1")) + return true; + if (s.equalsIgnoreCase("false")) + return false; + if (s.equalsIgnoreCase("no")) + return false; + if (s.equals("0")) + return false; + try + { + return Boolean.valueOf(obj.toString()).booleanValue(); + } + catch (Exception _ex) { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Boolean objToBBoolean(Object obj) throws IllegalConversionException { + if (obj == null) + return Boolean.FALSE; + if (obj instanceof Boolean) + return ((Boolean)obj); + String s = obj.toString(); + if (s.equalsIgnoreCase("true")) + return Boolean.TRUE; + if (s.equalsIgnoreCase("yes")) + return Boolean.TRUE; + if (s.equals("1")) + return Boolean.TRUE; + if (s.equalsIgnoreCase("false")) + return Boolean.FALSE; + if (s.equalsIgnoreCase("no")) + return Boolean.FALSE; + if (s.equals("0")) + return Boolean.FALSE; + try + { + return Boolean.valueOf(obj.toString()); + } + catch (Exception _ex) { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static byte objToByte(Object obj) + throws IllegalConversionException + { + if (obj instanceof Byte) + return ((Byte)obj).byteValue(); + return (byte)objToInt(obj); + } + + public static short objToShort(Object obj) + throws IllegalConversionException + { + if (obj instanceof Short) + return ((Short)obj).shortValue(); + return (short)objToInt(obj); + } + + public static int objToInt(Object obj) + throws IllegalConversionException + { + if (obj == null) + return 0; + if (obj instanceof Integer) + return ((Integer)obj).intValue(); + if (obj instanceof Long) + return ((Long)obj).intValue(); + if (obj instanceof Float) + return ((Float)obj).intValue(); + if (obj instanceof Double) + return ((Double)obj).intValue(); + if (obj instanceof BigDecimal) + return ((BigDecimal)obj).intValue(); + if (obj instanceof Boolean) + return ((((Boolean)obj).booleanValue()) ? 1 : 0); + + try + { + return Integer.valueOf(obj.toString()).intValue(); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion" + _ex.getMessage()); + } + } + + public static Integer objToInteger(Object obj) throws IllegalConversionException + { + if (obj == null) + return Integer.valueOf(0); + if (obj instanceof Integer) + return ((Integer)obj); + if (obj instanceof Long) + return Integer.valueOf(((Long)obj).intValue()); + if (obj instanceof Float) + return Integer.valueOf(((Float)obj).intValue()); + if (obj instanceof Double) + return Integer.valueOf(((Double)obj).intValue()); + if (obj instanceof BigDecimal) + return Integer.valueOf(((BigDecimal)obj).intValue()); + if (obj instanceof Boolean) + return Integer.valueOf((((Boolean)obj).booleanValue()) ? 1 : 0); + + try + { + return Integer.valueOf(obj.toString()); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static long objToLong(Object obj) throws IllegalConversionException + { + if (obj == null) + return 0L; + if (obj instanceof Integer) + return ((Integer)obj).longValue(); + if (obj instanceof Long) + return ((Long)obj).longValue(); + if (obj instanceof Float) + return ((Float)obj).longValue(); + if (obj instanceof Double) + return ((Double)obj).longValue(); + if (obj instanceof BigDecimal) + return ((BigDecimal)obj).longValue(); + if(obj instanceof ObjectValue){ + return ((ObjectValue)obj).getLong("id"); + } + if(obj instanceof JSONObject) { + JSONObject values = ((JSONObject) obj).getJSONObject("values"); + if(values!=null && !values.isEmpty()){ + if(values.get("id") instanceof JSONObject){ + JSONObject o = values.getJSONObject("id"); + return o.isEmpty() ? 0L: o.getLong("id"); + }else{ + return values.getLong("id"); + } + } + } + try + { + return Long.valueOf(obj.toString()).longValue(); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Long objToLLong(Object obj) throws IllegalConversionException + { + if (obj == null) + return Long.valueOf(0L); + if (obj instanceof Integer) + return Long.valueOf(((Integer)obj).longValue()); + if (obj instanceof Long) + return ((Long)obj); + if (obj instanceof Float) + return Long.valueOf(((Float)obj).longValue()); + if (obj instanceof Double) + return Long.valueOf(((Double)obj).longValue()); + if (obj instanceof BigDecimal) + return Long.valueOf(((BigDecimal)obj).longValue()); + try + { + return Long.valueOf(obj.toString()); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static float objToFloat(Object obj) + throws IllegalConversionException + { + if (obj == null) + return 0.0F; + if (obj instanceof Integer) + return ((Integer)obj).floatValue(); + if (obj instanceof Long) + return ((Long)obj).floatValue(); + if (obj instanceof Float) + return ((Float)obj).floatValue(); + if (obj instanceof Double) + return ((Double)obj).floatValue(); + if (obj instanceof BigDecimal) + return ((BigDecimal)obj).floatValue(); + try + { + return Float.valueOf(obj.toString()).floatValue(); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Float objToFFloat(Object obj) throws IllegalConversionException + { + if (obj == null) + return Float.valueOf(0.0F); + if (obj instanceof Float) + return ((Float)obj); + if (obj instanceof Integer) + return Float.valueOf(((Integer)obj).floatValue()); + if (obj instanceof Long) + return Float.valueOf(((Long)obj).floatValue()); + if (obj instanceof Double) + return Float.valueOf(((Double)obj).floatValue()); + if (obj instanceof BigDecimal) + return Float.valueOf(((BigDecimal)obj).floatValue()); + try + { + return Float.valueOf(obj.toString()); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static double objToDouble(Object obj) + throws IllegalConversionException + { + if (obj == null) + return 0.0D; + if (obj instanceof Integer) + return ((Integer)obj).doubleValue(); + if (obj instanceof Long) + return ((Long)obj).doubleValue(); + if (obj instanceof Float) + return ((Float)obj).doubleValue(); + if (obj instanceof Double) + return ((Double)obj).doubleValue(); + if (obj instanceof BigDecimal) + return ((BigDecimal)obj).doubleValue(); + try + { + return Double.valueOf(obj.toString()).doubleValue(); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Double objToDDouble(Object obj) throws IllegalConversionException { + if (obj == null) + return Double.valueOf(0.0D); + if (obj instanceof Double) + return ((Double)obj); + if (obj instanceof Integer) + return Double.valueOf(((Integer)obj).doubleValue()); + if (obj instanceof Long) + return Double.valueOf(((Long)obj).doubleValue()); + if (obj instanceof Float) + return Double.valueOf(((Float)obj).doubleValue()); + if (obj instanceof BigDecimal) + return Double.valueOf(((BigDecimal)obj).doubleValue()); + try + { + return Double.valueOf(obj.toString()); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Time objToTime(Object obj) + throws IllegalConversionException + { + if (obj == null) + return null; + if (obj instanceof Time) + return ((Time)obj); + try + { + if (obj instanceof Timestamp) + { + Timestamp timestamp = (Timestamp)obj; + return new Time(timestamp.getTime()); + } + if (obj instanceof java.util.Date) + { + java.util.Date date = (java.util.Date)obj; + return new Time(date.getTime()); + } + + DateFormat df = DateFormat.getTimeInstance(); + String strObj = obj.toString(); + return new Time(df.parse(strObj).getTime()); + } + catch (IllegalArgumentException _ex) + { + throw new IllegalConversionException("Illegal date value - Java Time Objects cannot be earlier than 1/1/70"); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Time string2Time(String time) throws IllegalConversionException { + java.sql.Date date = string2Date(time); + if (date == null) + return null; + return new Time(date.getTime()); + } + + public static java.sql.Date string2Date(String str) throws IllegalConversionException + { + if (StringUtils.isEmpty(str)) + return null; + str = str.trim().toLowerCase(); + if (str.startsWith("{ts")) { + str = str.substring(3).trim(); + } + if (str.startsWith("'")) + str = str.substring(1).trim(); + if (str.endsWith("}")) { + str = str.substring(0, str.length() - 1).trim(); + } + if (str.endsWith("'")) + str = str.substring(0, str.length() - 1).trim(); + String dfs = "yyyy-MM-dd"; + if (str.length() > dfs.length()) + dfs = "yyyy-MM-dd HH:mm:ss"; + DateFormat df = new SimpleDateFormat(dfs); + java.util.Date date; + try { + date = df.parse(str); + } catch (ParseException e) { + throw new IllegalConversionException("parse date error"); + } + return new java.sql.Date(date.getTime()); + } + + public static Timestamp string2Timestamp(String str) throws IllegalConversionException { + java.sql.Date date = string2Date(str); + if (date == null) + return null; + return new Timestamp(date.getTime()); + } + + public static void main(String[] args) + { + System.out.println(string2Date("{ts'1979-01-01'}")); + } + + public static java.sql.Date objToDate(Object obj) + throws IllegalConversionException + { + if (obj == null) + return null; + + if (obj instanceof java.sql.Date) + return ((java.sql.Date)obj); + if (obj instanceof JSONObject) + return null; + try + { + if (obj instanceof Timestamp) + { + Timestamp timestamp = (Timestamp)obj; + return new java.sql.Date(timestamp.getTime()); + } + if (obj instanceof java.util.Date) + { + java.util.Date date = (java.util.Date)obj; + return new java.sql.Date(date.getTime()); + } + + return new java.sql.Date(DateTimeUtils.parseDate(obj.toString()).getTime()); + } + catch (IllegalArgumentException _ex) + { + throw new IllegalConversionException("Illegal date value - Java Date Objects cannot be earlier than 1/1/70"); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static java.util.Date objToUtilDate(Object obj) + throws IllegalConversionException + { + if (obj == null) + return null; + try + { + if (obj instanceof Time) + { + Time time = (Time)obj; + return new java.util.Date(time.getTime()); + } + if (obj instanceof java.sql.Date) + { + java.sql.Date date = (java.sql.Date)obj; + return new java.util.Date(date.getTime()); + } + if (obj instanceof Timestamp) + { + Timestamp timestamp = (Timestamp)obj; + return new java.util.Date(timestamp.getTime()); + } + if (obj instanceof java.util.Date) + return ((java.util.Date)obj); + String strObj = obj.toString(); + return DateTimeUtils.parseDate(strObj); + } + catch (IllegalArgumentException _ex) + { + throw new IllegalConversionException("Illegal java.util.Date value - java.util.Date Objects cannot be earlier than 1/1/70"); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static Timestamp objToTimestamp(Object obj) + throws IllegalConversionException + { + if (obj == null) + return null; + if (obj instanceof Timestamp) + return ((Timestamp)obj); + try + { + if (obj instanceof Time) + { + Time time = (Time)obj; + return new Timestamp(time.getTime()); + } + if (obj instanceof java.sql.Date) + { + java.sql.Date date = (java.sql.Date)obj; + return new Timestamp(date.getTime()); + } + if (obj instanceof java.util.Date) + { + java.util.Date dt = (java.util.Date)obj; + return new Timestamp(dt.getTime()); + } + String strObj = obj.toString(); + return new Timestamp(DateTimeUtils.parseDate(strObj).getTime()); + } + catch (IllegalArgumentException _ex) + { + throw new IllegalConversionException("Illegal Timestamp value - Java Timestamp Objects cannot be earlier than 1/1/70"); + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + } + + public static byte[] objToBytes(Object obj) + throws IllegalConversionException + { + if (obj == null) + return null; + if (obj instanceof byte[]) + return ((byte[])(byte[])obj); + if (obj instanceof Long) { + long val = ((Long)obj).longValue(); + byte[] b = new byte[8]; + b[7] = (byte)(int)(val >>> 0); + b[6] = (byte)(int)(val >>> 8); + b[5] = (byte)(int)(val >>> 16); + b[4] = (byte)(int)(val >>> 24); + b[3] = (byte)(int)(val >>> 32); + b[2] = (byte)(int)(val >>> 40); + b[1] = (byte)(int)(val >>> 48); + b[0] = (byte)(int)(val >>> 56); + return b; + } + + if (obj instanceof Integer) { + long val = ((Integer)obj).longValue(); + byte[] b = new byte[8]; + b[7] = (byte)(int)(val >>> 0); + b[6] = (byte)(int)(val >>> 8); + b[5] = (byte)(int)(val >>> 16); + b[4] = (byte)(int)(val >>> 24); + b[3] = (byte)(int)(val >>> 32); + b[2] = (byte)(int)(val >>> 40); + b[1] = (byte)(int)(val >>> 48); + b[0] = (byte)(int)(val >>> 56); + return b; + } + + try + { + if (obj instanceof String) + { + String s = (String)obj; + return s.getBytes(); + } + } + catch (Exception _ex) + { + throw new IllegalConversionException("Illegal conversion"); + } + throw new IllegalConversionException("Illegal conversion"); + } + + + + + + public static int getLongHi(long l) + { + return (int)(l >> 32); + } + + public static int getLongLo(long l) + { + return (int)(l & 0xFFFFFFFF); + } + + public static long intToLong(int hiWord, int loWord) + { + long l = hiWord & 0xFFFFFFFF; + l <<= 32; + return (l | loWord & 0xFFFFFFFF); + } + + public static long intToLong(int i) + { + return (i & 0xFFFFFFFF); + } + + public static int longToInt(long l) + { + return (int)(l & 0xFFFFFFFF); + } + + public static Object stringToObject(int sqlType, String str) + throws IllegalConversionException + { + if (str == null) return null; + switch (sqlType) + { + case -7: + return Boolean.valueOf(objToBoolean(str)); + case -6: + case 4: + case 5: + return new Integer(objToInt(str)); + case -5: + return new Long(objToLong(str)); + case 6: + case 7: + case 8: + return new Double(objToDouble(str)); + case 2: + case 3: + return objToBigDecimal(str); + case -1: + case 1: + case 12: + return str; + case -3: + case -2: + case 2004: + return objToBytes(str); + case 91: + return string2Date(str); + case 92: + return string2Time(str); + case 93: + return string2Timestamp(str); + case 2005: + return str; + } + throw new IllegalConversionException("sqlType " + sqlType + " not supported."); + } + + public static String numberToPlainString(Object obj) + { + if (obj == null) { + return null; + } + if (!(obj instanceof Number)) { + throw new IllegalConversionException(); + } + + BigDecimal dec = objToBigDecimal(obj); + + synchronized (TypeConversionUtils.class) { + if (useJdk15Method == null) { + useJdk15Method = Boolean.TRUE; + try { + BigDecimal.class.getMethod("toPlainString", null); + } catch (Exception e) { + useJdk15Method = Boolean.FALSE; + } + } + } + + if (useJdk15Method.booleanValue()) + try { + Method toPlainString = BigDecimal.class.getMethod("toPlainString", null); + return ((String)toPlainString.invoke(dec, null)); + } + catch (Exception e) { + } + return dec.toString(); + } + + +} diff --git a/common/common-core/src/main/java/apelet/common/core/validator/AddGroup.java b/common/common-core/src/main/java/apelet/common/core/validator/AddGroup.java new file mode 100644 index 0000000..c57d4b6 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/validator/AddGroup.java @@ -0,0 +1,10 @@ +package apelet.common.core.validator; + +/** + * 数据增加的验证分组。通常用于数据新增场景。 + * + * @author guifc + * @date 2023-08-04 + */ +public interface AddGroup { +} diff --git a/common/common-core/src/main/java/apelet/common/core/validator/ConstDictRef.java b/common/common-core/src/main/java/apelet/common/core/validator/ConstDictRef.java new file mode 100644 index 0000000..837b381 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/validator/ConstDictRef.java @@ -0,0 +1,45 @@ +package apelet.common.core.validator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +/** + * 定义在Model对象中,标注字段值引用自指定的常量字典,和ConstDictRefValidator对象配合完成数据验证。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ConstDictValidator.class) +public @interface ConstDictRef { + + /** + * 引用的常量字典对象,该对象必须包含isValid的静态方法。 + * + * @return 最大长度。 + */ + Class constDictClass(); + + /** + * 超过边界后的错误消息提示。 + * + * @return 错误提示。 + */ + String message() default "无效的字典引用值!"; + + /** + * 验证分组。 + * + * @return 验证分组。 + */ + Class[] groups() default {}; + + /** + * 载荷对象类型。 + * + * @return 载荷对象。 + */ + Class[] payload() default {}; +} diff --git a/common/common-core/src/main/java/apelet/common/core/validator/ConstDictValidator.java b/common/common-core/src/main/java/apelet/common/core/validator/ConstDictValidator.java new file mode 100644 index 0000000..b7e5dff --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/validator/ConstDictValidator.java @@ -0,0 +1,34 @@ +package apelet.common.core.validator; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.lang.reflect.Method; + +/** + * 数据字段自定义验证,用于验证Model中字符串字段的最大长度和最小长度。 + * + * @author guifc + * @date 2023-08-04 + */ +public class ConstDictValidator implements ConstraintValidator { + + private ConstDictRef constDictRef; + + @Override + public void initialize(ConstDictRef constDictRef) { + this.constDictRef = constDictRef; + } + + @Override + public boolean isValid(Object s, ConstraintValidatorContext constraintValidatorContext) { + if (ObjectUtil.isEmpty(s)) { + return true; + } + Method method = + ReflectUtil.getMethodByName(constDictRef.constDictClass(), "isValid"); + return ReflectUtil.invokeStatic(method, s); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/validator/TextLength.java b/common/common-core/src/main/java/apelet/common/core/validator/TextLength.java new file mode 100644 index 0000000..104b5cc --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/validator/TextLength.java @@ -0,0 +1,52 @@ +package apelet.common.core.validator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +/** + * 定义在Model或Dto对象中,UTF-8编码的字符串字段长度的上限和下限,和TextLengthValidator对象配合完成数据验证。 + * + * @author guifc + * @date 2023-08-04 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = TextLengthValidator.class) +public @interface TextLength { + + /** + * 字符串字段的最小长度。 + * + * @return 最小长度。 + */ + int min() default 0; + + /** + * 字符串字段的最大长度。 + * + * @return 最大长度。 + */ + int max() default Integer.MAX_VALUE; + + /** + * 超过边界后的错误消息提示。 + * + * @return 错误提示。 + */ + String message() default "字段长度超过最大字节数!"; + + /** + * 验证分组。 + * + * @return 验证分组。 + */ + Class[] groups() default { }; + + /** + * 载荷对象类型。 + * + * @return 载荷对象。 + */ + Class[] payload() default { }; +} diff --git a/common/common-core/src/main/java/apelet/common/core/validator/TextLengthValidator.java b/common/common-core/src/main/java/apelet/common/core/validator/TextLengthValidator.java new file mode 100644 index 0000000..fbf26be --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/validator/TextLengthValidator.java @@ -0,0 +1,39 @@ +package apelet.common.core.validator; + +import org.apache.commons.lang3.CharUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +/** + * 数据字段自定义验证,用于验证Model中UTF-8编码的字符串字段的最大长度和最小长度。 + * + * @author guifc + * @date 2023-08-04 + */ +public class TextLengthValidator implements ConstraintValidator { + + private TextLength textLength; + + @Override + public void initialize(TextLength textLength) { + this.textLength = textLength; + } + + @Override + public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { + if (s == null) { + return true; + } + int length = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (CharUtils.isAscii(c)) { + ++length; + } else { + length += 2; + } + } + return length >= textLength.min() && length <= textLength.max(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/validator/UpdateGroup.java b/common/common-core/src/main/java/apelet/common/core/validator/UpdateGroup.java new file mode 100644 index 0000000..79041d0 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/validator/UpdateGroup.java @@ -0,0 +1,11 @@ +package apelet.common.core.validator; + +/** + * 数据修改的验证分组。通常用于数据更新的场景。 + * + * @author guifc + * @date 2023-08-04 + */ +public interface UpdateGroup { + +} diff --git a/common/common-flow-online/pom.xml b/common/common-flow-online/pom.xml index c89858f..9700ed9 100644 --- a/common/common-flow-online/pom.xml +++ b/common/common-flow-online/pom.xml @@ -28,7 +28,7 @@ apelet common-orm - 1.0.2 + 1.0.3 \ No newline at end of file diff --git a/common/common-generator/pom.xml b/common/common-generator/pom.xml index 0133288..008dab7 100644 --- a/common/common-generator/pom.xml +++ b/common/common-generator/pom.xml @@ -11,13 +11,13 @@ common-generator jar - 1.0.4 + 1.0.5 apelet common-core - 1.0.0 + 1.0.1 @@ -59,7 +59,7 @@ apelet common-orm - 1.0.2 + 1.0.3 compile diff --git a/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java b/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java index d3806be..dbf518b 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java +++ b/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java @@ -2,7 +2,10 @@ package apelet.common.generator.controller; import apelet.common.core.annotation.MyRequestBody; import apelet.common.core.constant.ErrorCodeEnum; -import apelet.common.core.object.*; +import apelet.common.core.object.MyOrderParam; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; import apelet.common.core.util.MyPageUtil; import apelet.common.generator.dto.OnlCgreportHeadDto; import apelet.common.generator.dto.SQLParseDto; @@ -171,11 +174,7 @@ public class OnlCgreportHeadController { */ @GetMapping(value = "/getParamsById") public ResponseResult getParamsById(@RequestParam("id") String id) { - List result = onlCgreportHeadService.getParamsById(id); - if (ObjectUtil.isAllEmpty(result)){ - return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); - } - return ResponseResult.success(result); + return ResponseResult.success(onlCgreportHeadService.getParamsById(id)); } /** * 保存报表配置+字段+参数 diff --git a/common/common-online/pom.xml b/common/common-online/pom.xml index 7d3b28b..d28f2a9 100644 --- a/common/common-online/pom.xml +++ b/common/common-online/pom.xml @@ -10,7 +10,7 @@ 4.0.0 common-online - 1.0.4 + 1.0.5 common-online jar @@ -65,7 +65,7 @@ apelet common-generator - 1.0.4 + 1.0.5 compile @@ -76,7 +76,7 @@ apelet common-orm - 1.0.2 + 1.0.3 diff --git a/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataOrmService.java b/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataOrmService.java index c6a2f5d..f97a3c0 100644 --- a/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataOrmService.java +++ b/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataOrmService.java @@ -13,6 +13,7 @@ import apelet.common.online.model.constant.FieldFilterType; import apelet.common.online.model.constant.RelationType; import apelet.common.orm.impl.*; import apelet.common.orm.parser.FilterParser; +import apelet.common.online.util.WidgetJsonUtil; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ReUtil; @@ -20,8 +21,12 @@ import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import java.math.BigDecimal; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -31,6 +36,7 @@ import java.util.stream.Collectors; * @version 1.0 * Create by 2024/10/24 10:46 */ +@Slf4j public class ListDataOrmService extends ListDataService { public ListDataOrmService(OnlineEventPluginExecuteDto dto) { super(dto); @@ -71,47 +77,38 @@ public class ListDataOrmService extends ListDataService { List> resList = new ArrayList<>(); OnlineTable masterTable = datasourceResult.getData().getMasterTable(); OnlineForm onlineForm = onlineFormService.getOnlineFormFromCache(dto.getFormId()); - // 根据当前用户角色 + 当前表单匹配权限规则:非空→基础数据权限,为空→列表权限 - List ruleSchemaList = this.getRuleSchemaListByCurrentRole(); - String conditionExpression = null; - if (CollUtil.isNotEmpty(ruleSchemaList)) { - List filtered = new ArrayList<>(); - if (StrUtil.isNotBlank(metaCode)) { - filtered = ruleSchemaList.stream().filter(s -> DataRuleType.BASIC_DATA == s.getRuleType() && metaCode.equals(s.getMetaCode())).collect(Collectors.toList()); - } - if (CollUtil.isEmpty(filtered)) { - filtered = ruleSchemaList.stream().filter(s -> DataRuleType.LIST == s.getRuleType()).collect(Collectors.toList()); - } - if (CollUtil.isNotEmpty(filtered)) { - conditionExpression = filtered.stream().map(PermissionDataRuleSchema::getConditionExpression).collect(Collectors.joining(") AND (", "(", ")")); - } - } - JSONObject widgetJson = JSON.parseObject(onlineForm.getWidgetJson()); JSONObject widgetList = widgetJson.getJSONObject("pc").getJSONObject("tableWidget"); Set selectorSet = new TreeSet<>(); getOrmSelector(widgetList, selectorSet, masterTable, new HashMap<>()); - Filter filter = getFilter(); - if (StrUtil.isNotBlank(conditionExpression)) { - TokenData tokenData = TokenData.takeFromRequest(); - // 解析 conditionExpression 中的 ${字段名} 占位符,从 tokenData 中取值填充 - conditionExpression = resolvePlaceholders(conditionExpression, tokenData); - Filter parsed = FilterParser.parse(conditionExpression); - filter = filter.and(parsed); - } + Filter filter = buildFilter(onlineForm); Sorter sorter = new Sorter(); - if (orderParam != null) { + // 排序:前端传了排序 → 按前端排(保留 create_time 兜底);没传且表单配了 sortField → 用它(值带方向则解析,默认 desc);否则 create_time DESC 兜底。 + if (CollUtil.isNotEmpty(orderParam)) { for (MyOrderParam.OrderInfo orderInfo : orderParam) { SorterItem sorterItem = new SorterItem(orderInfo.getFieldName()); sorterItem.setSortType(orderInfo.getAsc() ? SortType._ASC : SortType._DESC); sorter.add(sorterItem); } - } - if (!sorter.startsWithKey("create_time")) { - SorterItem sorterItem = new SorterItem("create_time"); - sorterItem.setSortType(SortType._DESC); - sorter.add(sorterItem); + if (!sorter.startsWithKey("create_time")) { + SorterItem sorterItem = new SorterItem("create_time"); + sorterItem.setSortType(SortType._DESC); + sorter.add(sorterItem); + } + } else { + // 排序优先级:关联组件(402).sortField > tableWidget.sortField > create_time DESC 兜底。 + String sortField = this.getRelativeFormSortFilter().get("sortField"); + if (StrUtil.isBlank(sortField)) { + sortField = this.getTableWidgetSortField(onlineForm); + } + if (StrUtil.isNotBlank(sortField)) { + sorter.add(this.buildSorterItemFromSortField(sortField)); + } else { + SorterItem sorterItem = new SorterItem("create_time"); + sorterItem.setSortType(SortType._DESC); + sorter.add(sorterItem); + } } Selector selector = new Selector(); selectorSet.forEach(f -> selector.getList().add(new SelectorItem(f))); @@ -124,6 +121,8 @@ public class ListDataOrmService extends ListDataService { .filter(r -> r.getRelationType().equals(RelationType.ONE_TO_ONE)).collect(Collectors.toList()); onlineOperationService.buildDataListWithDict(masterTable, oneToOneRelationList, resList); onlineOperationHelper.maskFieldDataList(resList, masterTable, null, null); + // 附件上传/图片上传字段的值不落业务表,这里从 zz_online_file 回填成文件JSON数组。 + onlineOperationService.fillFileColumn(resList, masterTable); GridData gridData = new GridData(); gridData.setDataList(resList); gridData.setTotalCount(ormDataSourceUtil.count(masterTable.getTableName(), filter)); @@ -133,6 +132,81 @@ public class ListDataOrmService extends ListDataService { } /** + * 构建列表查询过滤条件:用户过滤条件 + 数据权限规则(与 loadGridData 共用)。 + */ + private Filter buildFilter(OnlineForm onlineForm) { + // 根据当前用户角色 + 当前表单匹配权限规则:非空→基础数据权限,为空→列表权限 + List ruleSchemaList = this.getRuleSchemaListByCurrentRole(); + String conditionExpression = null; + if (CollUtil.isNotEmpty(ruleSchemaList)) { + List filtered = new ArrayList<>(); + if (StrUtil.isNotBlank(metaCode)) { + filtered = ruleSchemaList.stream().filter(s -> DataRuleType.BASIC_DATA == s.getRuleType() + && metaCode.equals(s.getMetaCode()) + && this.matchBaseFormId(s.getFormIdList())).collect(Collectors.toList()); + } + if (CollUtil.isEmpty(filtered)) { + filtered = ruleSchemaList.stream().filter(s -> DataRuleType.LIST == s.getRuleType()).collect(Collectors.toList()); + } + if (CollUtil.isNotEmpty(filtered)) { + conditionExpression = filtered.stream().map(PermissionDataRuleSchema::getConditionExpression).collect(Collectors.joining(") AND (", "(", ")")); + } + } + Filter filter = getFilter(); + if (StrUtil.isNotBlank(conditionExpression)) { + TokenData tokenData = TokenData.takeFromRequest(); + // 解析 conditionExpression 中的 ${字段名} 占位符,从 tokenData 中取值填充 + conditionExpression = resolvePlaceholders(conditionExpression, tokenData); + Filter parsed = FilterParser.parse(conditionExpression); + filter = filter.and(parsed); + } + // 表单 tableWidget 配置的默认过滤条件(如 name = ${userName}),占位符取 tokenData,拼接到查询条件后面。 + String filterField = this.getTableWidgetFilterField(onlineForm); + if (StrUtil.isNotBlank(filterField)) { + try { + String resolved = this.resolveFilterFieldPlaceholders(filterField, TokenData.takeFromRequest()); + Filter parsed = FilterParser.parse(resolved); + filter = filter.and(parsed); + } catch (Exception e) { + log.warn("tableWidget 默认过滤条件解析失败,跳过。filterField={}", filterField, e); + } + } + // 关联组件(402)配置的默认过滤条件,从 baseFormId 表单里匹配 onlineFormId == formId 的组件读取。 + String relativeFilterField = this.getRelativeFormSortFilter().get("filterField"); + if (StrUtil.isNotBlank(relativeFilterField)) { + try { + String resolved = this.resolveFilterFieldPlaceholders(relativeFilterField, TokenData.takeFromRequest()); + Filter parsed = FilterParser.parse(resolved); + filter = filter.and(parsed); + } catch (Exception e) { + log.warn("关联组件默认过滤条件解析失败,跳过。filterField={}", relativeFilterField, e); + } + } + return filter; + } + + /** + * 按列表查询条件统计主表记录数。 + * + * @return 符合条件的记录总数。 + */ + public int countGridData() { + OnlineTable masterTable = datasourceResult.getData().getMasterTable(); + return ormDataSourceUtil.count(masterTable.getTableName(), buildFilter(this.getCurrentOnlineForm())); + } + + /** + * 按列表查询条件对指定字段求和。 + * + * @param sumColumn 要求和的字段(支持 表名.字段名 或直接字段名)。 + * @return 求和结果。 + */ + public BigDecimal sumGridData(String sumColumn) { + OnlineTable masterTable = datasourceResult.getData().getMasterTable(); + return ormDataSourceUtil.sum(masterTable.getTableName(), sumColumn, buildFilter(this.getCurrentOnlineForm())); + } + + /** * 解析 conditionExpression 中的 ${字段名} 占位符,从 tokenData 中取值填充。 * 如 ${loginName} → 当前登录用户名,${deptId} → 当前用户部门ID。 */ @@ -148,6 +222,79 @@ public class ListDataOrmService extends ListDataService { } /** + * 读取表单 tableWidget 组件配置的默认排序字段(props.sortField),读取失败返回 null。 + */ + private String getTableWidgetSortField(OnlineForm onlineForm) { + return this.getTableWidgetConfig(onlineForm, "sortField"); + } + + /** + * 读取表单 tableWidget 组件配置的默认过滤条件(props.filterField),读取失败返回 null。 + */ + private String getTableWidgetFilterField(OnlineForm onlineForm) { + return this.getTableWidgetConfig(onlineForm, "filterField"); + } + + /** + * 从 zz_online_form.widgetJson 的 tableWidget.props 读取指定配置项。 + */ + private String getTableWidgetConfig(OnlineForm onlineForm, String key) { + if (onlineForm == null || StrUtil.isBlank(onlineForm.getWidgetJson())) { + return null; + } + return WidgetJsonUtil.getTableWidgetConfig(onlineForm.getWidgetJson(), key); + } + + /** + * 取当前请求对应的在线表单(count/sum 等无 widgetJson 入口时使用)。 + */ + private OnlineForm getCurrentOnlineForm() { + return onlineFormService.getOnlineFormFromCache(dto.getFormId()); + } + + /** + * 读取关联组件(402)配置的默认排序/过滤:从 baseFormId 表单的 widgetJson 里匹配 onlineFormId == formId 的组件。 + */ + private Map getRelativeFormSortFilter() { + if (baseFormId == null) { + return Collections.emptyMap(); + } + OnlineForm baseForm = onlineFormService.getOnlineFormFromCache(baseFormId); + if (baseForm == null || StrUtil.isBlank(baseForm.getWidgetJson())) { + return Collections.emptyMap(); + } + return WidgetJsonUtil.getRelativeOnlineFormSortFilter(baseForm.getWidgetJson(), dto.getFormId()); + } + + /** + * 解析 sortField 成 SorterItem:值带方向("field desc"/"field asc")则用之,没带默认 desc。 + */ + private SorterItem buildSorterItemFromSortField(String sortField) { + String[] parts = sortField.trim().split("\\s+"); + String fieldName = parts[0]; + boolean asc = parts.length > 1 && "asc".equalsIgnoreCase(parts[1]); + SorterItem sorterItem = new SorterItem(fieldName); + sorterItem.setSortType(asc ? SortType._ASC : SortType._DESC); + return sorterItem; + } + + /** + * 解析 filterField 表达式中的 ${字段名} 占位符,取值转成带引号的字符串(满足 FilterParser 值只接受字符串/null)。 + * 兼容已带引号('${xxx}')与未带引号(${xxx})两种存法。 + */ + private String resolveFilterFieldPlaceholders(String expression, TokenData tokenData) { + Matcher matcher = Pattern.compile("'?\\$\\{(\\w+)\\}'?").matcher(expression); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + Object value = BeanUtil.getProperty(tokenData, matcher.group(1)); + String replacement = value == null ? "null" : "'" + value.toString().replace("'", "''") + "'"; + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + /** * 根据当前登录用户角色查询启用的数据规则,并按当前表单匹配。 * 兜底:角色未绑定任何数据规则时返回空列表,不做数据权限过滤。 */ @@ -183,7 +330,11 @@ public class ListDataOrmService extends ListDataService { if (!Objects.equals(rule.getFormHeadId(), onlFormHead.getId())) { return false; } - // 2. 再按 form_id_list 筛选 + // 2. 基础数据权限:form_id_list 存的是「适用基础在线表单id」,由 buildFilter 按 baseFormId 匹配,这里不拦 + if (DataRuleType.BASIC_DATA == rule.getRuleType()) { + return true; + } + // 3. 列表权限:再按 form_id_list 筛选目标表单 if (StrUtil.isBlank(rule.getFormIdList())) { return true; } @@ -194,6 +345,17 @@ public class ListDataOrmService extends ListDataService { return StrUtil.split(rule.getFormIdList(), ',').stream().map(Long::valueOf).collect(Collectors.toSet()).contains(currentFormId); } + /** + * 判断基础数据权限规则的 form_id_list(逗号分隔集合,存「适用基础在线表单id」)是否匹配请求的 baseFormId。 + * form_id_list 未配置或请求未传 baseFormId 时视为匹配(兼容旧规则)。 + */ + private boolean matchBaseFormId(String ruleFormIdList) { + if (StrUtil.isBlank(ruleFormIdList) || baseFormId == null) { + return true; + } + return StrUtil.split(ruleFormIdList, ',').stream().map(Long::valueOf).collect(Collectors.toSet()).contains(baseFormId); + } + public void getOrmSelector(JSONObject jsonObject, Set selectorSet, OnlineTable masterTable, Map entryIdMap) { Map columnMap = masterTable.getColumnMap(); JSONArray array = jsonObject.getJSONArray("childWidgetList"); diff --git a/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataService.java b/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataService.java index f9cde9e..ce38939 100644 --- a/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataService.java +++ b/common/common-online/src/main/java/apelet/common/online/abstractplugin/ListDataService.java @@ -38,6 +38,7 @@ public abstract class ListDataService { protected TraceParamDto traceParamDto; protected OnlFormHead onlFormHead; protected String metaCode; + protected Long baseFormId; protected OrmDataSourceUtil ormDataSourceUtil; @@ -64,6 +65,7 @@ public abstract class ListDataService { datasourceResult = (ResponseResult) listParams.get("datasourceResult"); metaCode = (String) listParams.get("metaCode"); + baseFormId = (Long) listParams.get("baseFormId"); onlineFormService = ApplicationContextHolder.getBean("onlineFormService"); onlFormFieldService = ApplicationContextHolder.getBean("onlFormFieldServiceImpl"); diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineColumnController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineColumnController.java index 42ade43..3ca5353 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlineColumnController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineColumnController.java @@ -3,6 +3,9 @@ package apelet.common.online.controller; import apelet.common.core.annotation.MyRequestBody; import apelet.common.core.constant.ErrorCodeEnum; import apelet.common.core.constant.MaskFieldTypeEnum; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.object.*; import apelet.common.core.util.MyCommonUtil; import apelet.common.core.util.MyModelUtil; @@ -176,9 +179,10 @@ public class OnlineColumnController { * @return 应答结果对象,包含查询结果集。 */ @PostMapping("/list") + @McpToolClass(name = "online_column_list", description = "分页查询在线表字段列表,pageParam 传 pageNum/pageSize") public ResponseResult> list( - @MyRequestBody OnlineColumnDto onlineColumnDtoFilter, - @MyRequestBody MyPageParam pageParam) { + @MyRequestBody @McpToolParam(fieldName = "onlineColumnDtoFilter", type = McpToolParamType.OBJECT, description = "字段过滤条件", schemaClass = OnlineColumnDto.class) OnlineColumnDto onlineColumnDtoFilter, + @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { if (pageParam != null) { PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); } diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineDatasourceRelationController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineDatasourceRelationController.java index ee2c454..a405279 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlineDatasourceRelationController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineDatasourceRelationController.java @@ -2,6 +2,9 @@ package apelet.common.online.controller; import apelet.common.core.annotation.MyRequestBody; import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.object.*; import apelet.common.core.util.MyCommonUtil; import apelet.common.core.util.MyModelUtil; @@ -203,10 +206,11 @@ public class OnlineDatasourceRelationController { * @return 应答结果对象,包含查询结果集。 */ @PostMapping("/list") + @McpToolClass(name = "online_datasource_relation_list", description = "分页查询数据源关联列表,pageParam 传 pageNum/pageSize") public ResponseResult> list( - @MyRequestBody OnlineDatasourceRelationDto onlineDatasourceRelationDtoFilter, + @MyRequestBody @McpToolParam(fieldName = "onlineDatasourceRelationDtoFilter", type = McpToolParamType.OBJECT, description = "关联过滤条件", schemaClass = OnlineDatasourceRelationDto.class) OnlineDatasourceRelationDto onlineDatasourceRelationDtoFilter, @MyRequestBody MyOrderParam orderParam, - @MyRequestBody MyPageParam pageParam) { + @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { if (pageParam != null) { PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); } diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java index fac7040..cbe1474 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java @@ -7,10 +7,12 @@ import apelet.common.core.cache.CacheKey; import apelet.common.core.constant.AppDeviceType; import apelet.common.core.constant.ErrorCodeEnum; import apelet.common.core.constant.GlobalDeletedFlag; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.object.*; import apelet.common.core.util.MyCommonUtil; import apelet.common.core.util.MyModelUtil; -import apelet.common.core.util.MyPageUtil; import apelet.common.core.validator.UpdateGroup; import apelet.common.dbutil.object.SqlTable; import apelet.common.dbutil.object.SqlTableColumn; @@ -26,12 +28,16 @@ import apelet.common.online.config.OnlineProperties; import apelet.common.online.dto.OnlineEventPluginExecuteDto; import apelet.common.online.dto.OnlineFormDto; import apelet.common.online.model.*; +import apelet.common.online.model.constant.FieldFilterType; +import apelet.common.online.model.constant.FormKind; import apelet.common.online.model.constant.FormType; import apelet.common.online.service.*; import apelet.common.online.util.OnlineRedisKeyUtil; +import apelet.common.online.util.QuickFormWidgetUtil; import apelet.common.online.util.WidgetFieldTypeMapping; import apelet.common.online.util.WidgetJsonUtil; import apelet.common.online.vo.OnlineFormVo; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.ObjectUtil; @@ -40,7 +46,6 @@ import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.github.pagehelper.page.PageMethod; import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; import com.google.common.collect.Lists; import io.swagger.v3.oas.annotations.tags.Tag; @@ -51,6 +56,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.*; @@ -109,6 +115,13 @@ public class OnlineFormController { @Autowired(required = false) private FormButtonPermissionChecker buttonPermissionChecker; + @Autowired + private OnlineQuickFormService onlineQuickFormService; + @Autowired + private OnlineQuickFormFieldService onlineQuickFormFieldService; + @Autowired + private IdGeneratorWrapper idGenerator; + /** * 自动同步字段到历史数据:给所有缺该字段的 onl_form_head 表补字段(可按表类型过滤)。 * 已同步的表补字段+同步数据库+回写 zz_online_column;未同步的表只补字段。 @@ -138,7 +151,13 @@ public class OnlineFormController { @ApiOperationSupport(ignoreParameters = {"onlineFormDto.formId"}) @OperationLog(type = SysOperationLogType.ADD) @PostMapping("/add") - public ResponseResult add(@MyRequestBody OnlineFormDto onlineFormDto) { + @Transactional(rollbackFor = Exception.class) + @McpToolClass(name = "online_form_add", description = "新增在线表单") + public ResponseResult add(@MyRequestBody @McpToolParam(fieldName = "onlineFormDto", type = McpToolParamType.OBJECT, description = "表单信息", schemaClass = OnlineFormDto.class, required = true) OnlineFormDto onlineFormDto) { + // 快捷表单:走快捷表单保存逻辑 + if (onlineFormDto.getFormType() != null && onlineFormDto.getFormType() == FormType.QUICK_FORM) { + return ResponseResult.success(this.quickFormSave(onlineFormDto, null)); + } String errorMessage = MyCommonUtil.getModelValidationError(onlineFormDto); if (errorMessage != null) { return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage); @@ -177,7 +196,14 @@ public class OnlineFormController { */ @OperationLog(type = SysOperationLogType.UPDATE) @PostMapping("/update") - public ResponseResult update(@MyRequestBody OnlineFormDto onlineFormDto) { + @Transactional(rollbackFor = Exception.class) + @McpToolClass(name = "online_form_update", description = "修改在线表单") + public ResponseResult update(@MyRequestBody @McpToolParam(fieldName = "onlineFormDto", type = McpToolParamType.OBJECT, description = "表单信息", schemaClass = OnlineFormDto.class, required = true) OnlineFormDto onlineFormDto) { + // 快捷表单:formType=QUICK_FORM 才是快捷表单,formId 即快捷表单id,走快捷表单保存逻辑 + if (onlineFormDto.getFormType() != null && onlineFormDto.getFormType() == FormType.QUICK_FORM) { + this.quickFormSave(onlineFormDto, onlineFormDto.getFormId()); + return ResponseResult.success(); + } String errorMessage = MyCommonUtil.getModelValidationError(onlineFormDto, Default.class, UpdateGroup.class); if (errorMessage != null) { return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage); @@ -264,7 +290,6 @@ public class OnlineFormController { onlineFormDto.setWidgetJson(updatedJson.toJSONString()); } } - if (!onlineFormService.update(onlineForm, originalOnlineForm, datasourceIdSet)) { redissonClient.getBucket(OnlineRedisKeyUtil.makeOnlineFormKey(onlineForm.getFormId())).delete(); return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); @@ -282,11 +307,28 @@ public class OnlineFormController { */ @OperationLog(type = SysOperationLogType.DELETE) @PostMapping("/delete") - public ResponseResult delete(@MyRequestBody Long formId) { + @McpToolClass(name = "online_form_delete", description = "删除在线表单") + public ResponseResult delete(@MyRequestBody @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "表单主键Id", required = true) Long formId) { String errorMessage; ResponseResult verifyResult = this.doVerifyAndGet(formId); if (!verifyResult.isSuccess()) { - return ResponseResult.errorFrom(verifyResult); + // formId 不是在线表单:可能是快捷表单,先去快捷表单表确认 + OnlineQuickForm quickForm = onlineQuickFormService.getById(formId); + if (quickForm == null) { + return ResponseResult.errorFrom(verifyResult); + } + // 1. 先删除快捷表单及其字段 + onlineQuickFormFieldService.remove(new LambdaQueryWrapper().eq(OnlineQuickFormField::getQuickFormId, formId)); + if (!onlineQuickFormService.removeById(formId)) { + errorMessage = "数据操作失败,删除的对象不存在,请刷新后重试!"; + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage); + } + // 2. 再删除该快捷表单下的生成表单(复用现有删除逻辑,会同步清理数据源关联与缓存) + List generatedFormList = onlineFormService.list(new LambdaQueryWrapper().eq(OnlineForm::getQuickFormId, formId).eq(OnlineForm::getIsQuick, 1)); + for (OnlineForm generatedForm : generatedFormList) { + onlineFormService.remove(generatedForm.getFormId()); + } + return ResponseResult.success(); } if (!onlineFormService.remove(formId)) { errorMessage = "数据操作失败,删除的对象不存在,请刷新后重试!"; @@ -303,11 +345,55 @@ public class OnlineFormController { */ @OperationLog(type = SysOperationLogType.ADD) @PostMapping("/clone") - public ResponseResult clone(@MyRequestBody Long formId) { + @McpToolClass(name = "online_form_clone", description = "复制在线表单") + public ResponseResult clone(@MyRequestBody @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "源表单主键Id", required = true) Long formId) { ResponseResult verifyResult = this.doVerifyAndGet(formId); if (!verifyResult.isSuccess()) { - return ResponseResult.errorFrom(verifyResult); + // formId 不是在线表单:可能是快捷表单,按快捷表单复制 + OnlineQuickForm sourceQuickForm = onlineQuickFormService.getById(formId); + if (sourceQuickForm == null) { + return ResponseResult.errorFrom(verifyResult); + } + TokenData tokenData = TokenData.takeFromRequest(); + if (!StrUtil.equals(sourceQuickForm.getAppCode(), tokenData.getAppCode())) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "数据验证失败,当前应用不包含该表单!"); + } + if (ObjectUtil.notEqual(sourceQuickForm.getTenantId(), tokenData.getTenantId())) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "数据验证失败,当前租户不包含该表单!"); + } + // 查询快捷表单字段 + List sourceFieldList = onlineQuickFormFieldService.list(new LambdaQueryWrapper() + .eq(OnlineQuickFormField::getQuickFormId, formId)); + OnlineFormDto cloneDto = new OnlineFormDto(); + cloneDto.setPageId(sourceQuickForm.getPageId()); + cloneDto.setFormCode(sourceQuickForm.getQuickFormCode() + "_copy_" + System.currentTimeMillis()); + cloneDto.setFormName(sourceQuickForm.getQuickFormName() + "_copy"); + cloneDto.setLayoutType(sourceQuickForm.getLayoutType()); + cloneDto.setMasterTableId(sourceQuickForm.getMasterTableId()); + cloneDto.setOnlineQuickFormFields(sourceFieldList); + if (CollUtil.isNotEmpty(sourceFieldList)) { + // 有字段:与保存逻辑一致,生成编辑页/列表页 + return ResponseResult.success(this.quickFormSave(cloneDto, null)); + } + // 无字段:只复制快捷表单,不生成编辑页/列表页 + OnlineQuickForm cloneQuickForm = new OnlineQuickForm(); + cloneQuickForm.setId(idGenerator.nextLongId()); + cloneQuickForm.setAppCode(tokenData.getAppCode()); + cloneQuickForm.setTenantId(tokenData.getTenantId()); + cloneQuickForm.setPageId(cloneDto.getPageId()); + cloneQuickForm.setQuickFormCode(cloneDto.getFormCode()); + cloneQuickForm.setQuickFormName(cloneDto.getFormName()); + cloneQuickForm.setLayoutType(cloneDto.getLayoutType()); + cloneQuickForm.setMasterTableId(cloneDto.getMasterTableId()); + Date now = new Date(); + cloneQuickForm.setCreateTime(now); + cloneQuickForm.setCreateUserId(tokenData.getUserId()); + cloneQuickForm.setUpdateTime(now); + cloneQuickForm.setUpdateUserId(tokenData.getUserId()); + onlineQuickFormService.save(cloneQuickForm); + return ResponseResult.success(cloneQuickForm.getId()); } + // 原先逻辑不动 OnlineForm form = verifyResult.getData(); form.setFormName(form.getFormName() + "_copy"); form.setFormCode(form.getFormCode() + "_copy_" + System.currentTimeMillis()); @@ -327,18 +413,24 @@ public class OnlineFormController { * @return 应答结果对象,包含查询结果集。 */ @PostMapping("/list") - public ResponseResult> list( - @MyRequestBody OnlineFormDto onlineFormDtoFilter, - @MyRequestBody MyOrderParam orderParam, - @MyRequestBody MyPageParam pageParam) { - if (pageParam != null) { - PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); - } + @McpToolClass(name = "online_form_list", description = "分页查询在线表单,pageParam 传 pageNum/pageSize") + public ResponseResult> list(@MyRequestBody @McpToolParam(fieldName = "onlineFormDtoFilter", type = McpToolParamType.OBJECT, description = "过滤条件", schemaClass = OnlineFormDto.class) OnlineFormDto onlineFormDtoFilter, @MyRequestBody MyOrderParam orderParam, @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { + // 不做分页:非快捷表单 + 快捷表单合并,再按前端排序字段排序 + List voList = new ArrayList<>(); OnlineForm onlineFormFilter = MyModelUtil.copyTo(onlineFormDtoFilter, OnlineForm.class); + onlineFormFilter.setIsQuick(0); String orderBy = MyOrderParam.buildOrderBy(orderParam, OnlineForm.class); - List onlineFormList = - onlineFormService.getOnlineFormListWithRelation(onlineFormFilter, orderBy); - return ResponseResult.success(MyPageUtil.makeResponseData(onlineFormList, OnlineForm.INSTANCE)); + List onlineFormList = onlineFormService.getOnlineFormListWithRelation(onlineFormFilter, orderBy); + onlineFormList.forEach(form -> voList.add(OnlineForm.INSTANCE.fromModel(form))); + OnlineQuickForm quickFormFilter = new OnlineQuickForm(); + if (onlineFormDtoFilter != null) { + quickFormFilter.setPageId(onlineFormDtoFilter.getPageId()); + quickFormFilter.setQuickFormCode(onlineFormDtoFilter.getFormCode()); + quickFormFilter.setQuickFormName(onlineFormDtoFilter.getFormName()); + } + List quickFormList = onlineQuickFormService.getOnlineFormListWithRelation(quickFormFilter, MyOrderParam.buildOrderBy(orderParam, OnlineQuickForm.class)); + quickFormList.forEach(qf -> voList.add(OnlineQuickForm.INSTANCE.fromModel(qf))); + return ResponseResult.success(new MyPageData<>(voList, (long) voList.size())); } /** @@ -348,7 +440,8 @@ public class OnlineFormController { * @return 应答结果对象,包含对象详情。 */ @GetMapping("/view") - public ResponseResult view(@RequestParam Long formId) { + @McpToolClass(name = "online_form_view", description = "查看在线表单详情") + public ResponseResult view(@RequestParam @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "表单主键Id", required = true) Long formId) { ResponseResult verifyResult = this.doVerifyAndGet(formId); if (!verifyResult.isSuccess()) { return ResponseResult.errorFrom(verifyResult); @@ -575,6 +668,313 @@ public class OnlineFormController { return ResponseResult.success(datasourceIdSet); } + // ============ 快捷表单保存逻辑 ============ + + /** + * 快捷表单保存逻辑(文档技术方案第 1~4 步 + 字段同步): + * 1. 基础信息保存进 zz_online_quick_form; + * 2. 字段信息存入 zz_online_quick_form_field; + * 3. 生成编辑页 zz_online_form(code+Edit / name+编辑,pc+小程序 组件json); + * 4. 生成列表页 zz_online_form(code+List / name+列表),编辑页id关联到新建/编辑按钮; + * 之后调用 syncNewFieldsAndSubTables 完成 onl_form_field 字段同步。 + * + * @param dto 快捷表单配置(formCode/formName 即快捷表单编码/名称) + * @param quickFormId 已有快捷表单id(null 表示新增) + * @return 编辑页 formId + */ + private Long quickFormSave(OnlineFormDto dto, Long quickFormId) { + if (dto.getMasterTableId() == null) { + throw new IllegalArgumentException("数据验证失败,表单主表id不能为空!"); + } + if (StringUtils.isBlank(dto.getFormCode())) { + throw new IllegalArgumentException("数据验证失败,快捷表单编码不能为空!"); + } + if (StringUtils.isBlank(dto.getFormName())) { + throw new IllegalArgumentException("数据验证失败,快捷表单名称不能为空!"); + } + TokenData tokenData = TokenData.takeFromRequest(); + // 1. 基础信息存 zz_online_quick_form + OnlineQuickForm quickForm; + if (quickFormId == null) { + boolean codeExists = CollUtil.isNotEmpty(onlineQuickFormService.list(new LambdaQueryWrapper() + .eq(OnlineQuickForm::getPageId, dto.getPageId()) + .eq(OnlineQuickForm::getQuickFormCode, dto.getFormCode()))); + if (codeExists) { + throw new IllegalArgumentException("数据验证失败,快捷表单编码已经存在!"); + } + quickForm = new OnlineQuickForm(); + quickForm.setId(idGenerator.nextLongId()); + quickForm.setAppCode(tokenData.getAppCode()); + quickForm.setTenantId(tokenData.getTenantId()); + quickForm.setCreateTime(new Date()); + quickForm.setCreateUserId(tokenData.getUserId()); + } else { + quickForm = onlineQuickFormService.getById(quickFormId); + if (quickForm == null) { + throw new IllegalArgumentException("数据验证失败,快捷表单不存在!"); + } + } + quickForm.setPageId(dto.getPageId()); + quickForm.setQuickFormCode(dto.getFormCode()); + quickForm.setQuickFormName(dto.getFormName()); + quickForm.setLayoutType(dto.getLayoutType()); + quickForm.setMasterTableId(dto.getMasterTableId()); + quickForm.setUpdateTime(new Date()); + quickForm.setUpdateUserId(tokenData.getUserId()); + onlineQuickFormService.saveOrUpdate(quickForm); + Long savedQuickFormId = quickForm.getId(); + // 2. 字段信息存 zz_online_quick_form_field + onlineQuickFormFieldService.remove(new LambdaQueryWrapper().eq(OnlineQuickFormField::getQuickFormId, savedQuickFormId)); + List fieldList = dto.getOnlineQuickFormFields(); + if (CollUtil.isNotEmpty(fieldList)) { + Date now = new Date(); + for (OnlineQuickFormField field : fieldList) { + field.setId(idGenerator.nextLongId()); + field.setQuickFormId(savedQuickFormId); + field.setTenantId(tokenData.getTenantId()); + field.setCreateTime(now); + field.setCreateUserId(tokenData.getUserId()); + field.setUpdateTime(now); + field.setUpdateUserId(tokenData.getUserId()); + onlineQuickFormFieldService.save(field); + } + + } + // 3. 解析数据源与主表 + OnlineDatasource datasource = onlineDatasourceService.getOnlineDatasourceByMasterTableId(dto.getMasterTableId()); + if (datasource == null) { + throw new IllegalArgumentException("数据验证失败,关联的数据源不存在!"); + } + OnlineTable masterTable = onlineTableService.getById(dto.getMasterTableId()); + if (masterTable == null) { + throw new IllegalArgumentException("数据验证失败,关联的主表不存在!"); + } + Set datasourceIdSet = Collections.singleton(datasource.getDatasourceId()); + String masterTableName = masterTable.getTableName(); + // 4. 复用已生成的列表页/编辑页:保留 formId,避免菜单等配置引用的 formId 因删除重建而失效 + List generatedFormList = onlineFormService.list(new LambdaQueryWrapper() + .eq(OnlineForm::getQuickFormId, savedQuickFormId) + .eq(OnlineForm::getIsQuick, 1)); + OnlineForm existingEdit = generatedFormList.stream().filter(f -> f.getFormType() != null && f.getFormType() == FormType.EDIT_FORM).findFirst().orElse(null); + OnlineForm existingList = generatedFormList.stream().filter(f -> f.getFormType() != null && f.getFormType() == FormType.QUERY).findFirst().orElse(null); + // 5. 编辑页 zz_online_form:存在则原地更新(formId 不变),不存在才新建 + OnlineForm editForm = existingEdit; + if (editForm == null) { + editForm = new OnlineForm(); + } + editForm.setPageId(dto.getPageId()); + editForm.setFormCode(dto.getFormCode() + "Edit"); + editForm.setFormName(dto.getFormName() + "编辑"); + editForm.setFormKind(FormKind.DIALOG); + editForm.setFormType(FormType.EDIT_FORM); + editForm.setMasterTableId(dto.getMasterTableId()); + editForm.setIsQuick(1); + editForm.setQuickFormId(savedQuickFormId); + if (editForm.getFormId() == null) { + // 新生成的编辑页才插入;此时 pageId/formName 等必填字段已就绪 + editForm = onlineFormService.saveNew(editForm, datasourceIdSet); + } + // 6. 生成编辑页组件json,并同步字段到 onl_form_head/onl_form_field + this.resolveQuickFormFieldRefs(fieldList); + JSONObject editWidget = QuickFormWidgetUtil.buildEditWidgetJson(fieldList, savedQuickFormId, dto.getMasterTableId(), masterTableName); + OnlineFormDto editSyncDto = new OnlineFormDto(); + editSyncDto.setDatasourceIdList(Collections.singletonList(datasource.getDatasourceId())); + editSyncDto.setFormType(FormType.EDIT_FORM); + JSONObject updatedEdit = syncNewFieldsAndSubTables(editForm, editSyncDto, editWidget); + if (updatedEdit != null) { + editWidget = updatedEdit; + } + this.backfillColumnIds(editWidget, dto.getMasterTableId()); + editForm.setWidgetJson(editWidget.toJSONString()); + onlineFormService.update(editForm, editForm, datasourceIdSet); + // 7. 列表页 zz_online_form:存在则原地更新(formId 不变),不存在才新建 + OnlineForm listForm = existingList; + if (listForm == null) { + listForm = new OnlineForm(); + } + listForm.setPageId(dto.getPageId()); + listForm.setFormCode(dto.getFormCode() + "List"); + listForm.setFormName(dto.getFormName() + "列表"); + listForm.setFormKind(FormKind.NEW_PAGE); + listForm.setFormType(FormType.QUERY); + listForm.setMasterTableId(dto.getMasterTableId()); + listForm.setIsQuick(1); + listForm.setQuickFormId(savedQuickFormId); + if (listForm.getFormId() == null) { + // 新生成的列表页才插入;此时 pageId/formName/masterTableId 等必填字段已就绪 + listForm = onlineFormService.saveNew(listForm, datasourceIdSet); + } + // 8. 生成列表页组件json,新建/编辑按钮关联编辑页id;tableColumnList 需要列id,先构建 字段名→columnId 映射 + Map columnIdMap = this.buildColumnIdMap(dto.getMasterTableId()); + // 写回字段 column_id:新字段在编辑页同步时已建列,回填后前端再次编辑能区分新旧字段 + this.writeBackFieldColumnId(fieldList, columnIdMap); + JSONObject listWidget = QuickFormWidgetUtil.buildListWidgetJson(fieldList, listForm.getFormId(), editForm.getFormId(), savedQuickFormId, dto.getMasterTableId(), masterTableName, columnIdMap); + this.backfillColumnIds(listWidget, dto.getMasterTableId()); + listForm.setWidgetJson(listWidget.toJSONString()); + onlineFormService.update(listForm, listForm, datasourceIdSet); + // 8.5 清理历史残留的多余生成页(正常情况下仅编辑页+列表页各一个) + for (OnlineForm generatedForm : generatedFormList) { + if (generatedForm.getFormId().equals(editForm.getFormId()) || generatedForm.getFormId().equals(listForm.getFormId())) { + continue; + } + onlineFormService.remove(generatedForm.getFormId()); + } + // 9. 过滤规则回写 zz_online_column(文档第4步:上面所有逻辑走完再处理) + this.writeColumnFilterType(fieldList, dto.getMasterTableId(), tokenData); + return editForm.getFormId(); + } + + /** + * 构建 字段名(小写)→columnId 映射,供列表页 tableColumnList 回填列id。 + */ + private Map buildColumnIdMap(Long tableId) { + Map columnIdMap = new HashMap<>(); + OnlineTable table = onlineTableService.getOnlineTableFromCache(tableId); + if (table == null || table.getColumnMap() == null) { + return columnIdMap; + } + table.getColumnMap().values().forEach(column -> { + if (column != null && StringUtils.isNotBlank(column.getColumnName())) { + columnIdMap.put(column.getColumnName().toLowerCase(), column.getColumnId()); + } + }); + return columnIdMap; + } + + /** + * 将解析出的 columnId 写回快捷表单字段记录:前端新字段不传 columnId(表示待新增), + * 编辑页同步建列后回填,保证再次编辑时前端能区分新旧字段。 + */ + private void writeBackFieldColumnId(List fieldList, Map columnIdMap) { + if (CollUtil.isEmpty(fieldList) || columnIdMap == null || columnIdMap.isEmpty()) { + return; + } + for (OnlineQuickFormField field : fieldList) { + Long columnId = columnIdMap.get(field.getFieldName().toLowerCase()); + if (columnId != null && !Objects.equals(columnId, field.getColumnId())) { + field.setColumnId(columnId); + onlineQuickFormFieldService.updateById(field); + } + } + } + + /** + * 过滤字段(是过滤条件且有过滤方案)按 表id+字段名 查 zz_online_column,把过滤规则写进去。 + */ + private void writeColumnFilterType(List fieldList, Long masterTableId, TokenData tokenData) { + if (CollUtil.isEmpty(fieldList)) { + return; + } + for (OnlineQuickFormField field : fieldList) { + if (field.getIsFilter() == null || field.getIsFilter() != 1 || field.getFilterType() == null || field.getFilterType() == FieldFilterType.NO_FILTER) { + continue; + } + OnlineColumn column = onlineColumnService.getOnlineColumnByTableIdAndColumnName(masterTableId, field.getFieldName()); + if (column != null && !Objects.equals(column.getFilterType(), field.getFilterType())) { + column.setFilterType(field.getFilterType()); + column.setUpdateTime(new Date()); + column.setUpdateUserId(tokenData.getUserId()); + onlineColumnService.updateById(column); + redissonClient.getBucket(OnlineRedisKeyUtil.makeOnlineTableKey(masterTableId)).delete(); + } + } + } + + // ============ 组件json列id回填 ============ + + /** + * F7 关联字段解析关联表信息:前端 refProperty 传的是字段所在列id,F7 真正关联的是在线表单对应主表的表头(onl_form_head)。 + * 按 formId(兜底 refDict)→ 在线表单 → 主表 → 表头 解析,结果放入临时字段供控件生成与 onl_form_field.ref_propert 回写。 + */ + private void resolveQuickFormFieldRefs(List fieldList) { + if (CollUtil.isEmpty(fieldList)) { + return; + } + for (OnlineQuickFormField field : fieldList) { + if (field.getFieldType() == null || field.getFieldType() != 402) { + continue; + } + Long relatedFormId = field.getFormId() != null ? field.getFormId() : field.getRefDict(); + if (relatedFormId == null) { + continue; + } + OnlineForm relatedForm = onlineFormService.getById(relatedFormId); + if (relatedForm == null || relatedForm.getMasterTableId() == null) { + continue; + } + OnlineTable relatedTable = onlineTableService.getById(relatedForm.getMasterTableId()); + if (relatedTable == null) { + continue; + } + OnlFormHead relatedHead = onlFormHeadService.getOne(new LambdaQueryWrapper().eq(OnlFormHead::getTableName, relatedTable.getTableName())); + if (relatedHead == null) { + continue; + } + field.setRefTableId(relatedHead.getId()); + field.setRefTableName(relatedTable.getTableName()); + field.setRefFormCode(relatedForm.getFormCode()); + } + } + + /** + * 从主表 OnlineColumn 按列名回填控件 bindData.columnId/tableId(覆盖 pc/mobile 全部控件)。 + */ + private void backfillColumnIds(JSONObject widgetJson, Long tableId) { + OnlineTable table = onlineTableService.getOnlineTableFromCache(tableId); + if (table == null || table.getColumnMap() == null || table.getColumnMap().isEmpty()) { + return; + } + Map nameToId = new HashMap<>(); + table.getColumnMap().values().forEach(column -> { + if (column != null && StringUtils.isNotBlank(column.getColumnName())) { + nameToId.put(column.getColumnName().toLowerCase(), column.getColumnId()); + } + }); + for (String device : new String[]{"pc", "mobile"}) { + JSONObject deviceObj = widgetJson.getJSONObject(device); + if (deviceObj == null) { + continue; + } + backfillColumnIdsInWidgets(deviceObj.getJSONArray("widgetList"), nameToId, tableId); + JSONObject tableWidget = deviceObj.getJSONObject("tableWidget"); + if (tableWidget != null) { + backfillColumnIdsInWidgets(tableWidget.getJSONArray("childWidgetList"), nameToId, tableId); + } + } + } + + private void backfillColumnIdsInWidgets(JSONArray widgetList, Map nameToId, Long tableId) { + if (widgetList == null) { + return; + } + for (int i = 0; i < widgetList.size(); i++) { + JSONObject widget = widgetList.getJSONObject(i); + if (widget == null) { + continue; + } + JSONArray children = widget.getJSONArray("childWidgetList"); + if (children != null && !children.isEmpty()) { + backfillColumnIdsInWidgets(children, nameToId, tableId); + } + JSONObject bindData = widget.getJSONObject("bindData"); + if (bindData == null) { + continue; + } + String columnName = bindData.getString("columnFieldName"); + if (StrUtil.isEmpty(columnName)) { + columnName = bindData.getString("columnName"); + } + if (StrUtil.isEmpty(columnName)) { + continue; + } + Long columnId = nameToId.get(columnName.toLowerCase()); + if (columnId != null) { + bindData.put("columnId", columnId); + bindData.put("tableId", tableId); + } + } + } + /** * 获取流程对应的在线表单 * @param bosType @@ -657,13 +1057,19 @@ public class OnlineFormController { JSONObject widget = columnEntry.getValue(); OnlFormField existingField = fieldByColumnName.get(columnName); if (existingField != null) { - // 已有字段:带 slaveTableId 则更新 refPropert(子表列仅关联列 comType=402 生效) + // 已有字段:字段控件带 slaveTableId(关联组件)则更新 refPropert;不带(如字典下拉)则清空残留,避免实体表误按关联表强转 Map 报错 boolean isTableColumn = !widget.containsKey("bindData"); Integer comType = widget.getInteger("comType"); boolean isAssociation = !isTableColumn || (comType != null && comType == 402); String slaveTableId = extractSlaveTableId(widget); if (isAssociation && StrUtil.isNotEmpty(slaveTableId)) { - existingField.setRefPropert(Long.valueOf(slaveTableId)); + if (!Objects.equals(existingField.getRefPropert(), Long.valueOf(slaveTableId))) { + existingField.setRefPropert(Long.valueOf(slaveTableId)); + onlFormFieldService.updateById(existingField); + changed = true; + } + } else if (existingField.getRefPropert() != null) { + existingField.setRefPropert(null); onlFormFieldService.updateById(existingField); changed = true; } @@ -742,9 +1148,11 @@ public class OnlineFormController { if (newSubTableNames.isEmpty()) { return false; } + List subTableNameList = new ArrayList<>(newSubTableNames.size()); for (String subTableName : newSubTableNames) { // 因为是附表,所以要加上 subTableName = "t_" + subTableName + "_entry"; + subTableNameList.add(subTableName); OnlFormHead subHead = onlFormHeadService.createMetaAndSync(subTableName, onlFormHead.getTableTxt() + "附表", CustomUtil.MetaType_Entry, onlFormHead.getTableName()); // 获取子表的 OnlineTable OnlineTable subOnlineTable = onlineTableService.getOne(new LambdaQueryWrapper().eq(OnlineTable::getTableName, subTableName.toLowerCase())); @@ -764,7 +1172,7 @@ public class OnlineFormController { } //这里要将 所有的附表名称 加到 apelet.common.generator.model.OnlFormHead.childName 后面 - String newSubTableName = String.join(",", newSubTableNames); + String newSubTableName = String.join(",", subTableNameList); if (StringUtils.isNotBlank(onlFormHead.getChildName())) { onlFormHead.setChildName(onlFormHead.getChildName() + "," + newSubTableName); } else { @@ -1025,4 +1433,22 @@ public class OnlineFormController { return ResponseResult.success(formList); } + @PostMapping("/queryListFormByFormName") + public ResponseResult> queryListFormByFormName(@MyRequestBody String formName) { + List formList = onlineFormService.queryByFormName(formName); + if (CollUtil.isEmpty(formList)) { + return ResponseResult.success(new ArrayList<>()); + } + formList.forEach(item -> { + item.setWidgetJson(null); + item.setParamsJson(null); + item.setCreateTime(null); + item.setUpdateTime(null); + item.setCreateUserId(null); + item.setUpdateUserId(null); + }); + return ResponseResult.success(formList); + } + + } diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineHomepageConfigController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineHomepageConfigController.java new file mode 100644 index 0000000..1e95be0 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineHomepageConfigController.java @@ -0,0 +1,58 @@ +package apelet.common.online.controller; + +import apelet.common.core.annotation.MyRequestBody; +import apelet.common.core.object.ResponseResult; +import apelet.common.log.annotation.OperationLog; +import apelet.common.log.model.constant.SysOperationLogType; +import apelet.common.online.model.OnlineHomepageConfig; +import apelet.common.online.service.OnlineHomepageConfigService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 功能: 在线首页配置 + * + * @author {chenchuchuan} + * @version 1.0 + */ +@Tag(name = "在线首页配置接口") +@Slf4j +@RestController +@RequestMapping("${common-online.urlPrefix}/onlineHomepageConfig") +@ConditionalOnProperty(name = "common-online.operationEnabled", havingValue = "true") +public class OnlineHomepageConfigController { + + @Autowired + private OnlineHomepageConfigService onlineHomepageConfigService; + + + /** + * 保存或更新在线表单数据。 + * + * @param onlineHomepageConfig 更新对象。 + * @return 应答结果对象。 + */ + @OperationLog(type = SysOperationLogType.UPDATE) + @PostMapping("/saveOrUpdate") + public ResponseResult saveOrUpdate(@MyRequestBody String onlineHomepageConfig) { + onlineHomepageConfigService.updateOrSave(onlineHomepageConfig); + return ResponseResult.success(); + } + + /** + * 根据用户信息查询在线表单数据。 + * + * @return 应答结果对象。 + */ + @PostMapping("/queryByUserInfo") + public ResponseResult queryByUserInfo() { + return ResponseResult.success(onlineHomepageConfigService.queryByUser()); + + } + +} diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java index 536f7ce..349249e 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java @@ -21,9 +21,7 @@ import apelet.common.generator.service.IOnlFormHeadService; import apelet.common.generator.utils.OrmGenDataSourceUtil; import apelet.common.log.annotation.OperationLog; import apelet.common.log.model.constant.SysOperationLogType; -import apelet.common.online.abstractplugin.ListDataEasService; import apelet.common.online.abstractplugin.ListDataOrmService; -import apelet.common.online.abstractplugin.ListDataService; import apelet.common.online.abstractplugin.model.ExportData; import apelet.common.online.abstractplugin.model.GridData; import apelet.common.online.config.OnlineProperties; @@ -39,10 +37,13 @@ import apelet.common.online.model.constant.RelationType; import apelet.common.online.service.*; import apelet.common.online.util.OnlineConstant; import apelet.common.online.util.OnlineOperationHelper; +import apelet.common.online.util.WidgetJsonUtil; import apelet.common.online.vo.OnlineEventPluginExecuteVo; import apelet.common.orm.impl.*; +import apelet.common.orm.parser.FilterParser; import apelet.common.redis.cache.SessionCacheHelper; import apelet.common.redis.util.CommonRedisUtil; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.text.StrFormatter; @@ -69,6 +70,8 @@ import java.io.IOException; import java.io.Serializable; import java.math.BigDecimal; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -818,7 +821,8 @@ public class OnlineOperationController { @MyRequestBody String ignoreMaskFields, @MyRequestBody TraceParamDto traceParamDto, @MyRequestBody OnlineEventPluginExecuteDto onlineEventPluginExecuteDto, - @MyRequestBody String metaCode + @MyRequestBody String metaCode, + @MyRequestBody Long baseFormId ) { // 1. 验证数据源及其关联 ResponseResult datasourceResult = @@ -830,7 +834,7 @@ public class OnlineOperationController { if (!filterDtoListResult.isSuccess()) { return ResponseResult.errorFrom(filterDtoListResult); } - // 透传 metaCode 到 ListDataOrmService,用于权限规则类型过滤 + // 透传 metaCode、baseFormId 到 ListDataOrmService,用于权限规则类型过滤及关联组件过滤排序 if (onlineEventPluginExecuteDto == null) { onlineEventPluginExecuteDto = new OnlineEventPluginExecuteDto(); } @@ -840,8 +844,9 @@ public class OnlineOperationController { onlineEventPluginExecuteDto.setListParams(listParams); } listParams.put("metaCode", metaCode); + listParams.put("baseFormId", baseFormId); OnlFormHead onlFormHead = onlFormHeadService.getFormHeadByDatasource(datasourceId); - GridData pluginData = this.exeListPlugin(formId,filterDtoList,orderParam,pageParam, + GridData pluginData = onlineOperationService.exeListPlugin(formId,filterDtoList,orderParam,pageParam, traceParamDto,onlFormHead,datasourceResult,onlineEventPluginExecuteDto );//执行插件查询数据 if(pluginData != null){ @@ -858,55 +863,99 @@ public class OnlineOperationController { // } } - private GridData exeListPlugin( - Long formId, - List filterDtoList, - MyOrderParam orderParam, - MyPageParam pageParam, - TraceParamDto traceParamDto, - OnlFormHead onlFormHead, - ResponseResult datasourceResult, - OnlineEventPluginExecuteDto onlineEventPluginExecuteDto - ){ - if(onlineEventPluginExecuteDto == null){ - onlineEventPluginExecuteDto = new OnlineEventPluginExecuteDto(); + /** + * 根据表头Id,以及接口参数,统计动态表单数据记录数(过滤逻辑与 ormListByDatasourceId 一致)。 + * 前端不再传 datasourceId/datasourceVariableName,后端直接按表头Id(onl_form_head.id)取 zz_online_table 查询,不依赖数据源。 + * + * @param formId 表头Id,即 onl_form_head.id。 + * @param filterDtoList 多虑数据对象列表。 + * @param metaCode 透传给数据权限规则的元编码。 + * @return 符合过滤条件的记录总数。 + */ + @PostMapping("/ormCountByDatasourceId") + public ResponseResult ormCountByDatasourceId( + @MyRequestBody(required = true) Long formId, + @MyRequestBody List filterDtoList, + @MyRequestBody String metaCode + ) { + // 前端不再传 datasourceId/datasourceVariableName,直接按表头Id(onl_form_head.id)取 zz_online_table,无需查数据源。 + OnlineTable masterTable = this.getMasterTableByFormId(formId); + if (masterTable == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "数据查询失败, 请检查表单Id"); } - onlineEventPluginExecuteDto.setFormId(formId); - onlineEventPluginExecuteDto.setEventCode(EventEnum.LOADLISTDATA.getCode()); - Map listParams = onlineEventPluginExecuteDto.getListParams(); - if (listParams == null) { - listParams = new HashMap<>(); + ResponseResult filterDtoListResult = this.verifyFilterDtoList(filterDtoList); + if (!filterDtoListResult.isSuccess()) { + return ResponseResult.errorFrom(filterDtoListResult); } - listParams.put("filterDtoList",filterDtoList); - listParams.put("pageParam",pageParam); - listParams.put("orderParam",orderParam); - listParams.put("traceParamDto",traceParamDto); - listParams.put("onlFormHead",onlFormHead); - listParams.put("datasourceResult",datasourceResult); - onlineEventPluginExecuteDto.setListParams(listParams); - onlineEventPluginExecuteDto.setModel(new Model()); - List result = onlineFormService.eventPluginExecute(onlineEventPluginExecuteDto); - GridData gridData = null; - if(result != null){ - for(Object obj : result){ - if(obj instanceof OnlineEventPluginExecuteVo){ - OnlineEventPluginExecuteVo vo = (OnlineEventPluginExecuteVo)obj; - if(vo.getWidgetVariableName().equals("gridData")){ - gridData = (GridData)vo.getWidgetOperateContent(); - } - } - } + ListDataOrmService listDataService = this.buildListDataOrmService(formId, filterDtoList, metaCode, masterTable); + return ResponseResult.success(listDataService.countGridData()); + } + + /** + * 根据表头Id,以及接口参数,统计动态表单数据某字段的合计值(过滤逻辑与 ormListByDatasourceId 一致)。 + * 前端不再传 datasourceId/datasourceVariableName,后端直接按表头Id(onl_form_head.id)取 zz_online_table 查询,不依赖数据源。 + * + * @param formId 表头Id,即 onl_form_head.id。 + * @param sumColumn 要求和的字段名,支持 表名.字段名 或直接字段名。 + * @param filterDtoList 多虑数据对象列表。 + * @param metaCode 透传给数据权限规则的元编码。 + * @return 符合条件的字段合计值。 + */ + @PostMapping("/ormSumByDatasourceId") + public ResponseResult ormSumByDatasourceId( + @MyRequestBody(required = true) Long formId, + @MyRequestBody(required = true) String sumColumn, + @MyRequestBody List filterDtoList, + @MyRequestBody String metaCode + ) { + // 求和字段支持 表名.字段名 或直接字段名,逐段校验防注入。 + boolean sumColumnValid = StrUtil.isNotBlank(sumColumn) && Arrays.stream(sumColumn.split("\\.")).allMatch(this::checkTableAndColumnName); + if (!sumColumnValid) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "数据验证失败,求和字段名非法!"); } - if(gridData == null){ - ListDataService listDataService = null; - if (onlFormHead.getIsomerismMark() != null) { - listDataService = new ListDataEasService(onlineEventPluginExecuteDto); - }else{ - listDataService = new ListDataOrmService(onlineEventPluginExecuteDto); - } - gridData = listDataService.loadGridData(); + // 前端不再传 datasourceId/datasourceVariableName,直接按表头Id(onl_form_head.id)取 zz_online_table,无需查数据源。 + OnlineTable masterTable = this.getMasterTableByFormId(formId); + if (masterTable == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "数据查询失败, 请检查表单Id"); } - return gridData; + ResponseResult filterDtoListResult = this.verifyFilterDtoList(filterDtoList); + if (!filterDtoListResult.isSuccess()) { + return ResponseResult.errorFrom(filterDtoListResult); + } + ListDataOrmService listDataService = this.buildListDataOrmService(formId, filterDtoList, metaCode, masterTable); + return ResponseResult.success(listDataService.sumGridData(sumColumn)); + } + + /** + * 按 ormListByDatasourceId 的规则组装 ListDataOrmService:透传 metaCode、onlFormHead、主表信息,供过滤与数据权限使用。 + * 求和/统计不依赖数据源,直接用 zz_online_table 主表组装轻量数据源即可。 + */ + private ListDataOrmService buildListDataOrmService(Long formId, List filterDtoList, String metaCode, OnlineTable masterTable) { + OnlineEventPluginExecuteDto onlineEventPluginExecuteDto = new OnlineEventPluginExecuteDto(); + Map listParams = new HashMap<>(); + listParams.put("metaCode", metaCode); + listParams.put("filterDtoList", filterDtoList); + listParams.put("onlFormHead", onlFormHeadService.getOnlFormHeadFromCache(formId)); + OnlineDatasource datasource = new OnlineDatasource(); + datasource.setMasterTable(masterTable); + listParams.put("datasourceResult", ResponseResult.success(datasource)); + onlineEventPluginExecuteDto.setFormId(formId); + onlineEventPluginExecuteDto.setListParams(listParams); + return new ListDataOrmService(onlineEventPluginExecuteDto); + } + + /** + * 前端不再传 datasourceId/datasourceVariableName 时,根据表头Id(onl_form_head.id)直接取主表 zz_online_table。 + * + * @param formId 表头Id,即 onl_form_head.id。 + * @return 主表对象,表头或表不存在时返回null。 + */ + private OnlineTable getMasterTableByFormId(Long formId) { + OnlFormHead onlFormHead = onlFormHeadService.getOnlFormHeadFromCache(formId); + if (onlFormHead == null) { + return null; + } + return onlineTableService.getOne(new LambdaQueryWrapper().eq(OnlineTable::getTableName, onlFormHead.getTableName())); } private void normalizeFilterList(OnlineTable table @@ -928,9 +977,6 @@ public class OnlineOperationController { } } } - - - /** * 获取表单详情数据 * @param formId @@ -967,10 +1013,39 @@ public class OnlineOperationController { JSONObject jsonObject = (JSONObject) l; getOrmSelector(jsonObject, selectorSet, masterTable, entryIdMap); }); + // 收集各分录组件配置的默认排序/过滤条件(pc/mobile 控件树,按 bindData.tableId 区分主表与分录表)。 + Map entryFilterFieldMap = new HashMap<>(); + Map entrySortFieldMap = new HashMap<>(); + WidgetJsonUtil.collectSubTableSortFilter(widgetJson, masterTable.getTableId(), + tableId -> { + OnlineTable t = onlineTableService.getOnlineTableFromCache(tableId); + return t == null ? null : t.getTableName(); + }, + entryFilterFieldMap, entrySortFieldMap); + // 附表过滤(SQL 层):列名限定为 分录表名.列名,解析后拼接到查询条件。 + for (Map.Entry entry : entryFilterFieldMap.entrySet()) { + try { + String resolved = this.resolveFilterFieldPlaceholders(entry.getValue(), TokenData.takeFromRequest()); + String qualified = this.qualifyProperties(resolved, entry.getKey()); + filter = filter.and(FilterParser.parse(qualified)); + } catch (Exception e) { + log.warn("附表过滤条件解析失败,跳过。tableName={}, filterField={}", entry.getKey(), entry.getValue(), e); + } + } Selector selector = getSelector(selectorSet, masterTable,true); + // 附表排序(SQL 层):配置了 sortField 的分录表按 分录表名.字段 排序。 + Sorter sorter = null; + if (!entrySortFieldMap.isEmpty()) { + sorter = new Sorter(); + for (Map.Entry entry : entrySortFieldMap.entrySet()) { + sorter.add(this.buildSorterItemFromSortField(entry.getValue(), entry.getKey())); + } + } HashMap rowData = new HashMap<>(); HashMap listData = new HashMap<>(); - ObjectCollection collection = ormGenDataSourceUtil.query(masterTable.getTableName(), filter, selector); + ObjectCollection collection = (sorter != null && sorter.size() > 0) + ? ormGenDataSourceUtil.query(masterTable.getTableName(), filter, selector, sorter) + : ormGenDataSourceUtil.query(masterTable.getTableName(), filter, selector); if (collection.isEmpty()) { JSONObject jsonObject = new JSONObject(); rowData.put("id", id); @@ -984,6 +1059,8 @@ public class OnlineOperationController { .filter(r -> r.getRelationType().equals(RelationType.ONE_TO_ONE)).collect(Collectors.toList()); onlineOperationService.buildDataListWithDict(masterTable, oneToOneRelationList, resultList); onlineOperationHelper.maskFieldDataList(resultList, masterTable, null, null); + // 表头的附件上传/图片上传字段从 zz_online_file 回填成文件JSON数组。 + onlineOperationService.fillFileColumn(resultList, masterTable); Map map = resultList.get(0); map.keySet().forEach(k -> { @@ -991,12 +1068,23 @@ public class OnlineOperationController { ObjectCollection entryCollection = (ObjectCollection) map.get(k); List> entryList = new ArrayList<>(); onlineOperationService.collectionToJson(entryCollection, entryList); - String sort = "seq"; - entryList = entryList.stream().sorted( - Comparator.comparing(m -> m.containsKey(sort) && m.get(sort) != null && StringUtils.isNotBlank(m.get(sort).toString()) - ? Double.valueOf(m.get(sort).toString()) : null, - Comparator.nullsLast(Double::compareTo))) - .collect(Collectors.toList()); + // 附表配置了 sortField 时已在 SQL 层排序,这里不再按 seq 内存排序。 + if (!entrySortFieldMap.containsKey(k)) { + String sort = "seq"; + entryList = entryList.stream().sorted( + Comparator.comparing(m -> m.containsKey(sort) && m.get(sort) != null && StringUtils.isNotBlank(m.get(sort).toString()) + ? Double.valueOf(m.get(sort).toString()) : null, + Comparator.nullsLast(Double::compareTo))) + .collect(Collectors.toList()); + } + // 分录的附件上传/图片上传字段同样回填,online_table_id 用分录表自己的tableId。 + Object entryTableId = entryIdMap.get(k); + if (entryTableId != null) { + OnlineTable entryTable = onlineTableService.getOnlineTableFromCache(Long.valueOf(entryTableId.toString())); + if (entryTable != null) { + onlineOperationService.fillFileColumn(entryList, entryTable); + } + } listData.put(String.valueOf(entryIdMap.get(k)), entryList); } else { rowData.put(k, map.get(k)); @@ -1067,6 +1155,49 @@ public class OnlineOperationController { } + /** + * 解析过滤表达式中的 ${字段名} 占位符,取值转成带引号的字符串(满足 FilterParser 值只接受字符串/null)。 + * 兼容已带引号('${xxx}')与未带引号(${xxx})两种存法。 + */ + public String resolveFilterFieldPlaceholders(String expression, TokenData tokenData) { + Matcher matcher = Pattern.compile("'?\\$\\{(\\w+)\\}'?").matcher(expression); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + Object value = BeanUtil.getProperty(tokenData, matcher.group(1)); + String replacement = value == null ? "null" : "'" + value.toString().replace("'", "''") + "'"; + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } + + /** + * 把过滤表达式中的列名限定为 表名.列名(已带点限定的列跳过),供附表 SQL 过滤使用。 + */ + public String qualifyProperties(String expression, String tableName) { + Pattern p = Pattern.compile("(^|[(]|\\b(?:and|or)\\s+)([A-Za-z_]\\w*)(?=\\s*(?:=|!=|<=|>=|<|>|like|not\\s+like|in|not\\s+in|exists|not\\s+exists|is\\s+null|is\\s+not\\s+null)\\b)"); + Matcher m = p.matcher(expression); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + m.appendReplacement(sb, Matcher.quoteReplacement(m.group(1) + tableName + "." + m.group(2))); + } + m.appendTail(sb); + return sb.toString(); + } + + /** + * 解析 sortField 成 SorterItem:值带方向("field desc"/"field asc")则用之,没带默认 desc;可选加表名前缀。 + */ + public SorterItem buildSorterItemFromSortField(String sortField, String tablePrefix) { + String[] parts = sortField.trim().split("\\s+"); + String fieldName = parts[0]; + boolean asc = parts.length > 1 && "asc".equalsIgnoreCase(parts[1]); + String fullName = StrUtil.isBlank(tablePrefix) ? fieldName : tablePrefix + "." + fieldName; + SorterItem sorterItem = new SorterItem(fullName); + sorterItem.setSortType(asc ? SortType._ASC : SortType._DESC); + return sorterItem; + } + public Selector getSelector(Set selectorSet, OnlineTable masterTable,boolean b) { Selector selector = new Selector(); EntityTable entityTable = ormGenDataSourceUtil.getEntityTable(masterTable.getTableName()); diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlinePageController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlinePageController.java index 8d137a7..47b6812 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlinePageController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlinePageController.java @@ -2,6 +2,9 @@ package apelet.common.online.controller; import apelet.common.core.annotation.MyRequestBody; import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.object.*; import apelet.common.core.util.MyCommonUtil; import apelet.common.core.util.MyModelUtil; @@ -23,6 +26,7 @@ import apelet.common.online.dto.OnlinePageDto; import apelet.common.online.model.*; import apelet.common.online.model.constant.PageStatus; import apelet.common.online.service.*; +import apelet.common.online.upms.service.OnlinePermCodeService; import apelet.common.online.vo.OnlineDatasourceVo; import apelet.common.online.vo.OnlinePageDatasourceVo; import apelet.common.online.vo.OnlinePageVo; @@ -77,6 +81,8 @@ public class OnlinePageController { private OnlineColumnService onlineColumnService; @Autowired private IOnlFormFieldService onlFormFieldService; + @Autowired + private OnlinePermCodeService onlinePermCodeService; //new=新建元数据,exist=现有元数据 private static final String META_SOURCE_NEW = "new"; @@ -92,7 +98,8 @@ public class OnlinePageController { @OperationLog(type = SysOperationLogType.ADD) @Transactional(rollbackFor = Exception.class) @PostMapping("/add") - public ResponseResult add(@MyRequestBody OnlinePageDto onlinePageDto) { + @McpToolClass(name = "online_page_add", description = "新增在线表单页面数据,metaSource=new 时新建元数据,metaSource=existing 时使用已有表单元数据") + public ResponseResult add(@MyRequestBody @McpToolParam(fieldName = "onlinePageDto", type = McpToolParamType.OBJECT, description = "页面信息", schemaClass = OnlinePageDto.class, required = true) OnlinePageDto onlinePageDto) { String errorMessage = MyCommonUtil.getModelValidationError(onlinePageDto); if (errorMessage != null) { return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage); @@ -401,7 +408,8 @@ public class OnlinePageController { */ @OperationLog(type = SysOperationLogType.UPDATE) @PostMapping("/update") - public ResponseResult update(@MyRequestBody OnlinePageDto onlinePageDto) { + @McpToolClass(name = "online_page_update", description = "修改在线表单页面") + public ResponseResult update(@MyRequestBody @McpToolParam(fieldName = "onlinePageDto", type = McpToolParamType.OBJECT, description = "页面信息", schemaClass = OnlinePageDto.class, required = true) OnlinePageDto onlinePageDto) { String errorMessage = MyCommonUtil.getModelValidationError(onlinePageDto, Default.class, UpdateGroup.class); try { if (errorMessage != null) { @@ -478,9 +486,10 @@ public class OnlinePageController { */ @OperationLog(type = SysOperationLogType.UPDATE) @PostMapping("/updatePublished") + @McpToolClass(name = "online_page_update_status", description = "发布/取消发布在线表单页面") public ResponseResult updateStatus( - @MyRequestBody(required = true) Long pageId, - @MyRequestBody(required = true) Boolean published) { + @MyRequestBody(required = true) @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "页面主键Id", required = true) Long pageId, + @MyRequestBody(required = true) @McpToolParam(fieldName = "published", type = McpToolParamType.BOOLEAN, description = "发布状态:true=发布,false=取消发布", required = true) Boolean published) { String errorMessage; ResponseResult verifyResult = this.doVerifyAndGet(pageId); if (!verifyResult.isSuccess()) { @@ -494,6 +503,11 @@ public class OnlinePageController { } onlinePageService.updatePublished(pageId, published); } + //异步发布权限字 + if (BooleanUtil.isTrue(published)) { + onlinePermCodeService.genPermCodeByPageId(pageId, TokenData.takeFromRequest().getUserId()); + } + return ResponseResult.success(); } @@ -505,16 +519,20 @@ public class OnlinePageController { */ @OperationLog(type = SysOperationLogType.DELETE) @PostMapping("/delete") - public ResponseResult delete(@MyRequestBody Long pageId) { + @McpToolClass(name = "online_page_delete", description = "删除在线表单页面") + public ResponseResult delete(@MyRequestBody @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "页面主键Id", required = true) Long pageId) { String errorMessage; ResponseResult verifyResult = this.doVerifyAndGet(pageId); if (!verifyResult.isSuccess()) { return ResponseResult.errorFrom(verifyResult); } + OnlinePage originalOnlinePage = verifyResult.getData(); if (!onlinePageService.remove(pageId)) { errorMessage = "数据操作失败,删除的对象不存在,请刷新后重试!"; return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage); } + // 异步清理发布时自动生成的权限字。 + onlinePermCodeService.removePermCodeByPageCode(originalOnlinePage.getPageCode()); return ResponseResult.success(); } @@ -527,10 +545,11 @@ public class OnlinePageController { * @return 应答结果对象,包含查询结果集。 */ @PostMapping("/list") + @McpToolClass(name = "online_page_list", description = "分页查询在线表单页面,pageParam 传 pageNum/pageSize") public ResponseResult> list( - @MyRequestBody OnlinePageDto onlinePageDtoFilter, + @MyRequestBody @McpToolParam(fieldName = "onlinePageDtoFilter", type = McpToolParamType.OBJECT, description = "过滤条件", schemaClass = OnlinePageDto.class) OnlinePageDto onlinePageDtoFilter, @MyRequestBody MyOrderParam orderParam, - @MyRequestBody MyPageParam pageParam) { + @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { if (pageParam != null) { PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); } @@ -563,7 +582,8 @@ public class OnlinePageController { * @return 应答结果对象,包含对象详情。 */ @GetMapping("/view") - public ResponseResult view(@RequestParam Long pageId) { + @McpToolClass(name = "online_page_view", description = "查看在线表单页面详情") + public ResponseResult view(@RequestParam @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "页面主键Id", required = true) Long pageId) { ResponseResult verifyResult = this.doVerifyAndGet(pageId); if (!verifyResult.isSuccess()) { return ResponseResult.errorFrom(verifyResult); @@ -586,11 +606,12 @@ public class OnlinePageController { * @return 应答结果对象,返回符合条件的数据列表。 */ @PostMapping("/listOnlinePageDatasource") + @McpToolClass(name = "online_page_list_datasource", description = "分页查询在线表单页面关联的数据源") public ResponseResult> listOnlinePageDatasource( - @MyRequestBody Long pageId, - @MyRequestBody OnlineDatasourceDto onlineDatasourceDtoFilter, + @MyRequestBody @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "页面主键Id", required = true) Long pageId, + @MyRequestBody @McpToolParam(fieldName = "onlineDatasourceDtoFilter", type = McpToolParamType.OBJECT, description = "数据源过滤条件", schemaClass = OnlineDatasourceDto.class) OnlineDatasourceDto onlineDatasourceDtoFilter, @MyRequestBody MyOrderParam orderParam, - @MyRequestBody MyPageParam pageParam) { + @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { ResponseResult verifyResult = this.doVerifyAndGet(pageId); if (!verifyResult.isSuccess()) { return ResponseResult.errorFrom(verifyResult); diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineFileMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineFileMapper.java new file mode 100644 index 0000000..a670294 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineFileMapper.java @@ -0,0 +1,13 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineFile; + +/** + * 在线表单文件数据访问操作接口。 + * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlineFileMapper extends BaseDaoMapper { +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineHomepageConfigMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineHomepageConfigMapper.java new file mode 100644 index 0000000..ba21810 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineHomepageConfigMapper.java @@ -0,0 +1,13 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineHomepageConfig; + +/** + * 功能: 在线首页配置数据访问层 + * + * @author {chenchuchuan} + * @version 1.0 + */ +public interface OnlineHomepageConfigMapper extends BaseDaoMapper { +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineQuickFormFieldMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineQuickFormFieldMapper.java new file mode 100644 index 0000000..7608135 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineQuickFormFieldMapper.java @@ -0,0 +1,13 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineQuickFormField; + +/** + * 在线快捷表单字段数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +public interface OnlineQuickFormFieldMapper extends BaseDaoMapper { +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineQuickFormMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineQuickFormMapper.java new file mode 100644 index 0000000..d493ee2 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineQuickFormMapper.java @@ -0,0 +1,26 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineQuickForm; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 在线快捷表单数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +public interface OnlineQuickFormMapper extends BaseDaoMapper { + + /** + * 获取过滤后的对象列表。 + * + * @param onlineFormFilter 主表过滤对象。 + * @param orderBy 排序字符串,order by从句的参数。 + * @return 对象列表。 + */ + List getOnlineFormList( + @Param("onlineFormFilter") OnlineQuickForm onlineFormFilter, @Param("orderBy") String orderBy); +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineFormMapper.xml b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineFormMapper.xml index 2f097ce..b0d0e62 100644 --- a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineFormMapper.xml +++ b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineFormMapper.xml @@ -13,6 +13,8 @@ + + @@ -68,6 +70,16 @@ #{item} + + + + AND (zz_online_form.is_quick = 0 OR zz_online_form.is_quick IS NULL) + + + AND zz_online_form.is_quick = #{onlineFormFilter.isQuick} + + + diff --git a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineQuickFormMapper.xml b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineQuickFormMapper.xml new file mode 100644 index 0000000..5140c85 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineQuickFormMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + AND zz_online_quick_form.tenant_id IS NULL + + + AND zz_online_quick_form.tenant_id = #{onlineFormFilter.tenantId} + + + AND zz_online_quick_form.app_code IS NULL + + + AND zz_online_quick_form.app_code = #{onlineFormFilter.appCode} + + + AND zz_online_quick_form.page_id = #{onlineFormFilter.pageId} + + + AND zz_online_quick_form.quick_form_code = #{onlineFormFilter.quickFormCode} + + + + AND zz_online_quick_form.quick_form_name LIKE #{safeQuickFormName} + + + AND zz_online_quick_form.master_table_id = #{onlineFormFilter.masterTableId} + + + + + + diff --git a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermCodeMapper.xml b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermCodeMapper.xml new file mode 100644 index 0000000..bb8ac60 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermCodeMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + DELETE FROM xy_sys_perm_code WHERE perm_code_id IN + + #{permCodeId} + + + diff --git a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermMapper.xml b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermMapper.xml new file mode 100644 index 0000000..b9d9a0e --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + DELETE FROM xy_sys_perm WHERE perm_id IN + + #{permId} + + + diff --git a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermModuleMapper.xml b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermModuleMapper.xml new file mode 100644 index 0000000..a128706 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineSysPermModuleMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + DELETE FROM xy_sys_perm_module WHERE module_id IN + + #{moduleId} + + + diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineColumnDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineColumnDto.java index 295644f..6fed546 100644 --- a/common/common-online/src/main/java/apelet/common/online/dto/OnlineColumnDto.java +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineColumnDto.java @@ -1,5 +1,7 @@ package apelet.common.online.dto; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.validator.ConstDictRef; import apelet.common.core.validator.UpdateGroup; import apelet.common.online.model.constant.FieldFilterType; @@ -25,6 +27,7 @@ public class OnlineColumnDto { */ @Schema(description = "主键Id") @NotNull(message = "数据验证失败,主键Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "columnId", type = McpToolParamType.INTEGER, description = "字段主键Id") private Long columnId; /** @@ -32,6 +35,7 @@ public class OnlineColumnDto { */ @Schema(description = "字段名") @NotBlank(message = "数据验证失败,字段名不能为空!") + @McpToolParam(fieldName = "columnName", type = McpToolParamType.STRING, description = "字段名") private String columnName; /** @@ -39,6 +43,7 @@ public class OnlineColumnDto { */ @Schema(description = "数据表Id") @NotNull(message = "数据验证失败,数据表Id不能为空!") + @McpToolParam(fieldName = "tableId", type = McpToolParamType.INTEGER, description = "数据表Id") private Long tableId; /** @@ -46,6 +51,7 @@ public class OnlineColumnDto { */ @Schema(description = "数据表中的字段类型") @NotBlank(message = "数据验证失败,数据表中的字段类型不能为空!") + @McpToolParam(fieldName = "columnType", type = McpToolParamType.STRING, description = "数据表中的字段类型") private String columnType; /** @@ -53,6 +59,7 @@ public class OnlineColumnDto { */ @Schema(description = "数据表中的完整字段类型") @NotBlank(message = "数据验证失败,数据表中的完整字段类型(包括了精度和刻度)不能为空!") + @McpToolParam(fieldName = "fullColumnType", type = McpToolParamType.STRING, description = "完整字段类型(含精度刻度)") private String fullColumnType; /** @@ -60,6 +67,7 @@ public class OnlineColumnDto { */ @Schema(description = "是否为主键") @NotNull(message = "数据验证失败,是否为主键不能为空!") + @McpToolParam(fieldName = "primaryKey", type = McpToolParamType.BOOLEAN, description = "是否为主键") private Boolean primaryKey; /** @@ -67,6 +75,7 @@ public class OnlineColumnDto { */ @Schema(description = "是否是自增主键") @NotNull(message = "数据验证失败,是否是自增主键(0: 不是 1: 是)不能为空!") + @McpToolParam(fieldName = "autoIncrement", type = McpToolParamType.BOOLEAN, description = "是否自增主键") private Boolean autoIncrement; /** @@ -74,12 +83,14 @@ public class OnlineColumnDto { */ @Schema(description = "是否可以为空") @NotNull(message = "数据验证失败,是否可以为空 (0: 不可以为空 1: 可以为空)不能为空!") + @McpToolParam(fieldName = "nullable", type = McpToolParamType.BOOLEAN, description = "是否可以为空") private Boolean nullable; /** * 缺省值。 */ @Schema(description = "缺省值") + @McpToolParam(fieldName = "columnDefault", type = McpToolParamType.STRING, description = "缺省值") private String columnDefault; /** @@ -87,12 +98,14 @@ public class OnlineColumnDto { */ @Schema(description = "字段在数据表中的显示位置") @NotNull(message = "数据验证失败,字段在数据表中的显示位置不能为空!") + @McpToolParam(fieldName = "columnShowOrder", type = McpToolParamType.INTEGER, description = "字段显示位置") private Integer columnShowOrder; /** * 数据表中的字段注释。 */ @Schema(description = "数据表中的字段注释") + @McpToolParam(fieldName = "columnComment", type = McpToolParamType.STRING, description = "字段注释") private String columnComment; /** @@ -100,6 +113,7 @@ public class OnlineColumnDto { */ @Schema(description = "对象映射字段名称") @NotBlank(message = "数据验证失败,对象映射字段名称不能为空!") + @McpToolParam(fieldName = "objectFieldName", type = McpToolParamType.STRING, description = "对象映射字段名称") private String objectFieldName; /** @@ -107,18 +121,21 @@ public class OnlineColumnDto { */ @Schema(description = "对象映射字段类型") @NotBlank(message = "数据验证失败,对象映射字段类型不能为空!") + @McpToolParam(fieldName = "objectFieldType", type = McpToolParamType.STRING, description = "对象映射字段类型") private String objectFieldType; /** * 数值型字段的精度(目前仅Oracle使用)。 */ @Schema(description = "数值型字段的精度") + @McpToolParam(fieldName = "numericPrecision", type = McpToolParamType.INTEGER, description = "数值精度") private Integer numericPrecision; /** * 数值型字段的刻度(小数点后位数,目前仅Oracle使用)。 */ @Schema(description = "数值型字段的刻度") + @McpToolParam(fieldName = "numericScale", type = McpToolParamType.INTEGER, description = "数值刻度") private Integer numericScale; /** @@ -127,6 +144,7 @@ public class OnlineColumnDto { @Schema(description = "过滤类型字段") @NotNull(message = "数据验证失败,过滤类型字段不能为空!", groups = {UpdateGroup.class}) @ConstDictRef(constDictClass = FieldFilterType.class, message = "数据验证失败,过滤类型字段为无效值!") + @McpToolParam(fieldName = "filterType", type = McpToolParamType.INTEGER, description = "过滤类型") private Integer filterType; /** @@ -134,6 +152,7 @@ public class OnlineColumnDto { */ @Schema(description = "是否是主键的父Id") @NotNull(message = "数据验证失败,是否是主键的父Id不能为空!") + @McpToolParam(fieldName = "parentKey", type = McpToolParamType.BOOLEAN, description = "是否主键的父Id") private Boolean parentKey; /** @@ -141,6 +160,7 @@ public class OnlineColumnDto { */ @Schema(description = "是否部门过滤字段") @NotNull(message = "数据验证失败,是否部门过滤字段标记不能为空!") + @McpToolParam(fieldName = "deptFilter", type = McpToolParamType.BOOLEAN, description = "是否部门过滤字段") private Boolean deptFilter; /** @@ -148,6 +168,7 @@ public class OnlineColumnDto { */ @Schema(description = "是否用户过滤字段") @NotNull(message = "数据验证失败,是否用户过滤字段标记不能为空!") + @McpToolParam(fieldName = "userFilter", type = McpToolParamType.BOOLEAN, description = "是否用户过滤字段") private Boolean userFilter; /** @@ -155,35 +176,41 @@ public class OnlineColumnDto { */ @Schema(description = "字段类别") @ConstDictRef(constDictClass = FieldKind.class, message = "数据验证失败,字段类别为无效值!") + @McpToolParam(fieldName = "fieldKind", type = McpToolParamType.INTEGER, description = "字段类别") private Integer fieldKind; /** * 包含的文件文件数量,0表示无限制。 */ @Schema(description = "包含的文件文件数量,0表示无限制") + @McpToolParam(fieldName = "maxFileCount", type = McpToolParamType.INTEGER, description = "包含的文件数量,0表示无限制") private Integer maxFileCount; /** * 上传文件系统类型。 */ @Schema(description = "上传文件系统类型") + @McpToolParam(fieldName = "uploadFileSystemType", type = McpToolParamType.INTEGER, description = "上传文件系统类型") private Integer uploadFileSystemType; /** * 脱敏字段类型,具体值可参考MaskFieldTypeEnum枚举。 */ @Schema(description = "脱敏字段类型") + @McpToolParam(fieldName = "maskFieldType", type = McpToolParamType.STRING, description = "脱敏字段类型") private String maskFieldType; /** * 编码规则的JSON格式数据。 */ @Schema(description = "编码规则的JSON格式数据") + @McpToolParam(fieldName = "encodedRule", type = McpToolParamType.STRING, description = "编码规则JSON") private String encodedRule; /** * 字典Id。 */ @Schema(description = "字典Id") + @McpToolParam(fieldName = "dictId", type = McpToolParamType.INTEGER, description = "字典Id") private Long dictId; } diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceDto.java index 0df6347..1f4cdcc 100644 --- a/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceDto.java +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceDto.java @@ -1,5 +1,7 @@ package apelet.common.online.dto; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.validator.AddGroup; import apelet.common.core.validator.UpdateGroup; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,6 +25,7 @@ public class OnlineDatasourceDto { */ @Schema(description = "主键Id") @NotNull(message = "数据验证失败,主键Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "datasourceId", type = McpToolParamType.INTEGER, description = "数据源主键Id") private Long datasourceId; /** @@ -30,6 +33,7 @@ public class OnlineDatasourceDto { */ @Schema(description = "数据源名称") @NotBlank(message = "数据验证失败,数据源名称不能为空!") + @McpToolParam(fieldName = "datasourceName", type = McpToolParamType.STRING, description = "数据源名称") private String datasourceName; /** @@ -37,6 +41,7 @@ public class OnlineDatasourceDto { */ @Schema(description = "数据源变量名,会成为数据访问url的一部分") @NotBlank(message = "数据验证失败,数据源变量名不能为空!") + @McpToolParam(fieldName = "variableName", type = McpToolParamType.STRING, description = "数据源变量名") private String variableName; /** @@ -44,6 +49,7 @@ public class OnlineDatasourceDto { */ @Schema(description = "主表所在的数据库链接Id") @NotNull(message = "数据验证失败,数据库链接Id不能为空!") + @McpToolParam(fieldName = "dblinkId", type = McpToolParamType.INTEGER, description = "数据库链接Id") private Long dblinkId; /** @@ -51,6 +57,7 @@ public class OnlineDatasourceDto { */ @Schema(description = "主表Id") @NotNull(message = "数据验证失败,主表Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "masterTableId", type = McpToolParamType.INTEGER, description = "主表Id") private Long masterTableId; /** @@ -58,5 +65,6 @@ public class OnlineDatasourceDto { */ @Schema(description = "主表表名") @NotBlank(message = "数据验证失败,主表名不能为空!", groups = {AddGroup.class}) + @McpToolParam(fieldName = "masterTableName", type = McpToolParamType.STRING, description = "主表表名") private String masterTableName; } diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceRelationDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceRelationDto.java index 5c49c7f..c98f146 100644 --- a/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceRelationDto.java +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineDatasourceRelationDto.java @@ -1,5 +1,7 @@ package apelet.common.online.dto; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.validator.AddGroup; import apelet.common.core.validator.ConstDictRef; import apelet.common.core.validator.UpdateGroup; @@ -25,6 +27,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "主键Id") @NotNull(message = "数据验证失败,主键Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "relationId", type = McpToolParamType.INTEGER, description = "关联主键Id") private Long relationId; /** @@ -32,6 +35,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "关联名称") @NotBlank(message = "数据验证失败,关联名称不能为空!") + @McpToolParam(fieldName = "relationName", type = McpToolParamType.STRING, description = "关联名称") private String relationName; /** @@ -39,6 +43,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "变量名") @NotBlank(message = "数据验证失败,变量名不能为空!") + @McpToolParam(fieldName = "variableName", type = McpToolParamType.STRING, description = "变量名") private String variableName; /** @@ -46,6 +51,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "主数据源Id") @NotNull(message = "数据验证失败,主数据源Id不能为空!") + @McpToolParam(fieldName = "datasourceId", type = McpToolParamType.INTEGER, description = "主数据源Id") private Long datasourceId; /** @@ -54,6 +60,7 @@ public class OnlineDatasourceRelationDto { @Schema(description = "关联类型") @NotNull(message = "数据验证失败,关联类型不能为空!") @ConstDictRef(constDictClass = RelationType.class, message = "数据验证失败,关联类型为无效值!") + @McpToolParam(fieldName = "relationType", type = McpToolParamType.INTEGER, description = "关联类型(0=一对一,1=一对多)") private Integer relationType; /** @@ -61,6 +68,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "主表关联字段Id") @NotNull(message = "数据验证失败,主表关联字段Id不能为空!") + @McpToolParam(fieldName = "masterColumnId", type = McpToolParamType.INTEGER, description = "主表关联字段Id") private Long masterColumnId; /** @@ -68,6 +76,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "从表Id") @NotNull(message = "数据验证失败,从表Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "slaveTableId", type = McpToolParamType.INTEGER, description = "从表Id") private Long slaveTableId; /** @@ -75,6 +84,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "从表名") @NotBlank(message = "数据验证失败,从表名不能为空!", groups = {AddGroup.class}) + @McpToolParam(fieldName = "slaveTableName", type = McpToolParamType.STRING, description = "从表名") private String slaveTableName; /** @@ -82,6 +92,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "从表关联字段Id") @NotNull(message = "数据验证失败,从表关联字段Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "slaveColumnId", type = McpToolParamType.INTEGER, description = "从表关联字段Id") private Long slaveColumnId; /** @@ -89,6 +100,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "从表字段名") @NotBlank(message = "数据验证失败,从表字段名不能为空!", groups = {AddGroup.class}) + @McpToolParam(fieldName = "slaveColumnName", type = McpToolParamType.STRING, description = "从表字段名") private String slaveColumnName; /** @@ -96,6 +108,7 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "是否级联删除标记") @NotNull(message = "数据验证失败,是否级联删除标记不能为空!") + @McpToolParam(fieldName = "cascadeDelete", type = McpToolParamType.BOOLEAN, description = "是否级联删除") private Boolean cascadeDelete; /** @@ -103,5 +116,6 @@ public class OnlineDatasourceRelationDto { */ @Schema(description = "是否左连接标记") @NotNull(message = "数据验证失败,是否左连接标记不能为空!") + @McpToolParam(fieldName = "leftJoin", type = McpToolParamType.BOOLEAN, description = "是否左连接") private Boolean leftJoin; } diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineFormDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineFormDto.java index c1960f7..84feb7e 100644 --- a/common/common-online/src/main/java/apelet/common/online/dto/OnlineFormDto.java +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineFormDto.java @@ -1,7 +1,10 @@ package apelet.common.online.dto; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.validator.ConstDictRef; import apelet.common.core.validator.UpdateGroup; +import apelet.common.online.model.OnlineQuickFormField; import apelet.common.online.model.constant.FormKind; import apelet.common.online.model.constant.FormType; import io.swagger.v3.oas.annotations.media.Schema; @@ -26,6 +29,7 @@ public class OnlineFormDto { */ @Schema(description = "主键Id") @NotNull(message = "数据验证失败,主键Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "主键Id(编辑时使用)") private Long formId; /** @@ -33,12 +37,14 @@ public class OnlineFormDto { */ @Schema(description = "页面id") @NotNull(message = "数据验证失败,页面id不能为空!") + @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "页面id", required = true) private Long pageId; /** * 表单编码。 */ @Schema(description = "表单编码") + @McpToolParam(fieldName = "formCode", type = McpToolParamType.STRING, description = "表单编码") private String formCode; /** @@ -46,6 +52,7 @@ public class OnlineFormDto { */ @Schema(description = "表单名称") @NotBlank(message = "数据验证失败,表单名称不能为空!") + @McpToolParam(fieldName = "formName", type = McpToolParamType.STRING, description = "表单名称", required = true) private String formName; /** @@ -54,6 +61,7 @@ public class OnlineFormDto { @Schema(description = "表单类别") @NotNull(message = "数据验证失败,表单类别不能为空!") @ConstDictRef(constDictClass = FormKind.class, message = "数据验证失败,表单类别为无效值!") + @McpToolParam(fieldName = "formKind", type = McpToolParamType.INTEGER, description = "表单类别:1=弹框列表,5=跳页类别") private Integer formKind; /** @@ -62,6 +70,7 @@ public class OnlineFormDto { @Schema(description = "表单类型") @NotNull(message = "数据验证失败,表单类型不能为空!") @ConstDictRef(constDictClass = FormType.class, message = "数据验证失败,表单类型为无效值!") + @McpToolParam(fieldName = "formType", type = McpToolParamType.INTEGER, description = "表单类型:1=查询表单,2=左树右表表单,3=一对一关联数据查询,5=编辑表单,10=流程表单,11=流程工单表单,15=快捷表单", required = true) private Integer formType; /** @@ -69,23 +78,48 @@ public class OnlineFormDto { */ @Schema(description = "表单主表id") @NotNull(message = "数据验证失败,表单主表id不能为空!") + @McpToolParam(fieldName = "masterTableId", type = McpToolParamType.INTEGER, description = "表单主表id", required = true) private Long masterTableId; /** * 当前表单关联的数据源Id集合。 */ @Schema(description = "当前表单关联的数据源Id集合") + @McpToolParam(fieldName = "datasourceIdList", type = McpToolParamType.ARRAY, description = "当前表单关联的数据源Id集合") private List datasourceIdList; /** * 表单组件JSON。 */ @Schema(description = "表单组件JSON") + @McpToolParam(fieldName = "widgetJson", type = McpToolParamType.STRING, description = "表单组件JSON") private String widgetJson; /** * 表单参数JSON。 */ @Schema(description = "表单参数JSON") + @McpToolParam(fieldName = "paramsJson", type = McpToolParamType.STRING, description = "表单参数JSON") private String paramsJson; + + /** + * 快捷表单ID(编辑快捷表单配置时回传)。 + */ + @Schema(description = "快捷表单ID") + @McpToolParam(fieldName = "quickFormId", type = McpToolParamType.INTEGER, description = "快捷表单ID") + private Long quickFormId; + + /** + * 排版类型(快捷表单)。 + */ + @Schema(description = "排版类型") + @McpToolParam(fieldName = "layoutType", type = McpToolParamType.INTEGER, description = "排版类型(快捷表单):0=一行一列,1=一行两列,2=一行三列") + private Integer layoutType; + + /** + * 快捷表单字段列表。 + */ + @Schema(description = "快捷表单字段列表") + @McpToolParam(fieldName = "onlineQuickFormFields", type = McpToolParamType.ARRAY, description = "快捷表单字段列表(每项见 OnlineQuickFormField 字段)") + private List onlineQuickFormFields; } diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlinePageDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlinePageDto.java index afa6f86..2e29f8e 100644 --- a/common/common-online/src/main/java/apelet/common/online/dto/OnlinePageDto.java +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlinePageDto.java @@ -1,5 +1,7 @@ package apelet.common.online.dto; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; import apelet.common.core.validator.ConstDictRef; import apelet.common.core.validator.UpdateGroup; import apelet.common.online.model.constant.PageStatus; @@ -25,12 +27,14 @@ public class OnlinePageDto { */ @Schema(description = "主键Id") @NotNull(message = "数据验证失败,主键Id不能为空!", groups = {UpdateGroup.class}) + @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "主键Id(编辑时使用)") private Long pageId; /** * 页面编码。 */ @Schema(description = "页面编码") + @McpToolParam(fieldName = "pageCode", type = McpToolParamType.STRING, description = "页面编码(metaSource=new 时必填)") private String pageCode; /** @@ -38,6 +42,7 @@ public class OnlinePageDto { */ @Schema(description = "页面名称") @NotBlank(message = "数据验证失败,页面名称不能为空!") + @McpToolParam(fieldName = "pageName", type = McpToolParamType.STRING, description = "页面名称", required = true) private String pageName; /** @@ -46,6 +51,7 @@ public class OnlinePageDto { @Schema(description = "页面类型") @NotNull(message = "数据验证失败,页面类型不能为空!") @ConstDictRef(constDictClass = PageType.class, message = "数据验证失败,页面类型为无效值!") + @McpToolParam(fieldName = "pageType", type = McpToolParamType.INTEGER, description = "页面类型", required = true) private Integer pageType; /** @@ -54,29 +60,34 @@ public class OnlinePageDto { @Schema(description = "页面编辑状态") @NotNull(message = "数据验证失败,状态不能为空!") @ConstDictRef(constDictClass = PageStatus.class, message = "数据验证失败,状态为无效值!") + @McpToolParam(fieldName = "status", type = McpToolParamType.INTEGER, description = "页面编辑状态", required = true) private Integer status; /** * 表单元数据。 */ @Schema(description = "表单元数据") + @McpToolParam(fieldName = "masterTableId", type = McpToolParamType.STRING, description = "表单元数据Id(metaSource=existing 时必填)") private String masterTableId; /** * 表单元数据。 */ @Schema(description = "表单元数据表名") + @McpToolParam(fieldName = "masterTableName", type = McpToolParamType.STRING, description = "表单元数据表名") private String masterTableName; /** * 数据来源: "new"=新建元数据, "existing"=使用已有元数据。 */ @Schema(description = "数据来源: new=新建元数据, existing=使用已有元数据") + @McpToolParam(fieldName = "metaSource", type = McpToolParamType.STRING, description = "数据来源:new=新建元数据,existing=使用已有元数据", required = true) private String metaSource; /** * 表类型: "2"=基础资料(单表), "1"=业务单据(主表+附表)。仅在metaSource="new"时有效。 */ @Schema(description = "表类型: 2=基础资料(单表), 1=业务单据(主表+附表)。仅在metaSource=new时有效") + @McpToolParam(fieldName = "metaType", type = McpToolParamType.STRING, description = "表类型:2=基础资料(单表),1=业务单据(主表+附表),仅在 metaSource=new 时有效") private String metaType; } diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineFile.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineFile.java new file mode 100644 index 0000000..e42a75b --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineFile.java @@ -0,0 +1,104 @@ +package apelet.common.online.model; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 在线表单文件实体对象。 + *

附件上传/图片上传字段(file_kind=1/2)的文件数据统一落在此表,业务表中该字段仅作查询回填载体。

+ * + * @author guifc + * @date 2023-08-04 + */ +@Data +@TableName(value = "zz_online_file") +public class OnlineFile { + + /** + * 主键Id。 + */ + @TableId(value = "id") + private Long id; + + /** + * 在线表Id(zz_online_table.table_id)。 + */ + @TableField(value = "online_table_id") + private Long onlineTableId; + + /** + * 字段名称。 + */ + @TableField(value = "field_name") + private String fieldName; + + /** + * 业务Id(所在表记录主键值)。 + */ + @TableField(value = "business_id") + private Long businessId; + + /** + * 基础URL。 + */ + @TableField(value = "base_url") + private String baseUrl; + + /** + * 下载URI。 + */ + @TableField(value = "download_uri") + private String downloadUri; + + /** + * 文件名称(带后缀)。 + */ + @TableField(value = "file_name") + private String fileName; + + /** + * 上传地址。 + */ + @TableField(value = "upload_path") + private String uploadPath; + + /** + * 名称(带后缀)。 + */ + @TableField(value = "name") + private String name; + + /** + * 文件全地址。 + */ + @TableField(value = "url") + private String url; + + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + + /** + * 创建者。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + + /** + * 修改时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + + /** + * 修改人。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineForm.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineForm.java index 2ca8658..54dd673 100644 --- a/common/common-online/src/main/java/apelet/common/online/model/OnlineForm.java +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineForm.java @@ -119,6 +119,18 @@ public class OnlineForm { @TableField(value = "update_user_id") private Long updateUserId; + /** + * 是否快捷表单:0-否,1-是。 + */ + @TableField(value = "is_quick") + private Integer isQuick; + + /** + * 快捷表单ID。 + */ + @TableField(value = "quick_form_id") + private Long quickFormId; + @RelationOneToOne( masterIdField = "masterTableId", slaveModelClass = OnlineTable.class, diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineHomepageConfig.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineHomepageConfig.java new file mode 100644 index 0000000..93dbe7c --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineHomepageConfig.java @@ -0,0 +1,66 @@ +package apelet.common.online.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 在线自定义首页实体 + */ +@Data +@TableName("zz_online_homepage_config") +public class OnlineHomepageConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键Id + */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + private Long id; + + /** + * 租户id + */ + @TableField("tenant_id") + private Long tenantId; + + /** + * 应用编码 + */ + @TableField("app_code") + private String appCode; + + /** + * 首页组件JSON + */ + @TableField("widget_json") + private String widgetJson; + + /** + * 创建时间 + */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** + * 创建者 + */ + @TableField("create_user_id") + private Long createUserId; + + /** + * 更新时间 + */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** + * 更新者 + */ + @TableField("update_user_id") + private Long updateUserId; + +} \ No newline at end of file diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineQuickForm.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineQuickForm.java new file mode 100644 index 0000000..52450e6 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineQuickForm.java @@ -0,0 +1,143 @@ +package apelet.common.online.model; + +import apelet.common.core.annotation.RelationConstDict; +import apelet.common.core.annotation.RelationDict; +import apelet.common.core.annotation.RelationOneToMany; +import apelet.common.core.annotation.RelationOneToOne; +import apelet.common.core.base.mapper.BaseModelMapper; +import apelet.common.online.model.constant.FormType; +import apelet.common.online.vo.OnlineFormVo; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 在线快捷表单实体对象。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +@Data +@TableName(value = "zz_online_quick_form") +public class OnlineQuickForm { + + public static final OnlineQuickForm.OnlineFormModelMapper INSTANCE = Mappers.getMapper(OnlineQuickForm.OnlineFormModelMapper.class); + /** + * 主键Id。 + */ + @TableId(value = "id") + private Long id; + /** + * 租户Id。 + */ + @TableField(value = "tenant_id") + private Long tenantId; + /** + * 应用编码。 + */ + @TableField(value = "app_code") + private String appCode; + /** + * 页面id。 + */ + @TableField(value = "page_id") + private Long pageId; + /** + * 快捷表单编码。 + */ + @TableField(value = "quick_form_code") + private String quickFormCode; + /** + * 快捷表单名称。 + */ + @TableField(value = "quick_form_name") + private String quickFormName; + /** + * 排版类型。 0 一行一列,1一行两列,2 一行三列 + * @see apelet.common.online.model.constant.LayoutType + */ + @TableField(value = "layout_type") + private Integer layoutType; + /** + * 表单主表id。 + */ + @TableField(value = "master_table_id") + private Long masterTableId; + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + /** + * 创建者。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + /** + * 更新者。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; + /** + * 表单类型(快捷表单固定为 FormType.QUICK_FORM,非数据库列)。 + */ + @TableField(exist = false) + private Integer formType; + @RelationOneToOne(masterIdField = "masterTableId", slaveModelClass = OnlineTable.class, slaveIdField = "tableId") + @TableField(exist = false) + private OnlineTable onlineTable; + @RelationDict(masterIdField = "masterTableId", equalOneToOneRelationField = "onlineTable", slaveModelClass = OnlineTable.class, slaveIdField = "tableId", slaveNameField = "modelName") + @TableField(exist = false) + private Map masterTableIdDictMap; + @RelationConstDict( + masterIdField = "formType", + constantDictClass = FormType.class) + @TableField(exist = false) + private Map formTypeDictMap; + @RelationOneToMany(masterIdField = "id", slaveModelClass = OnlineQuickFormField.class, slaveIdField = "quickFormId") + @TableField(exist = false) + private List onlineQuickFormFields; + + @Mapper + public interface OnlineFormModelMapper extends BaseModelMapper { + /** + * 转换Vo对象到实体对象。 + * + * @param onlineFormVo 域对象。 + * @return 实体对象。 + */ + @Mapping(target = "onlineTable", expression = "java(mapToBean(onlineFormVo.getOnlineTable(), apelet.common.online.model.OnlineTable.class))") + @Override + OnlineQuickForm toModel(OnlineFormVo onlineFormVo); + + /** + * 转换实体对象到VO对象。 + * + * @param onlineForm 实体对象。 + * @return 域对象。 + */ + @Mapping(target = "formKind", expression = "java(apelet.common.online.model.constant.FormKind.NEW_PAGE)") + @Mapping(target = "formType", expression = "java(apelet.common.online.model.constant.FormType.QUICK_FORM)") + @Mapping(target = "formName", expression = "java(onlineForm.getQuickFormName())") + @Mapping(target = "formCode", expression = "java(onlineForm.getQuickFormCode())") + @Mapping(target = "formId", expression = "java(onlineForm.getId())") + @Mapping(target = "quickFormId", expression = "java(onlineForm.getId())") + @Mapping(target = "onlineTable", expression = "java(beanToMap(onlineForm.getOnlineTable(), false))") + @Mapping(target = "onlineQuickFormFields", expression = "java(onlineForm.getOnlineQuickFormFields() == null ? java.util.Collections.emptyList() : onlineForm.getOnlineQuickFormFields())") + @Override + OnlineFormVo fromModel(OnlineQuickForm onlineForm); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineQuickFormField.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineQuickFormField.java new file mode 100644 index 0000000..73f2fa4 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineQuickFormField.java @@ -0,0 +1,210 @@ +package apelet.common.online.model; + +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 在线快捷表单字段实体对象。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +@Data +@TableName(value = "zz_online_quick_form_field") +public class OnlineQuickFormField { + + /** + * 主键Id。 + */ + @TableId(value = "id") + private Long id; + + /** + * 快捷表单id。 + */ + @TableField(value = "quick_form_id") + private Long quickFormId; + + /** + * 字段名称。 + */ + @TableField(value = "field_name") + @McpToolParam(fieldName = "fieldName", type = McpToolParamType.STRING, description = "字段名称", required = true) + private String fieldName; + + /** + * 字段备注。 + */ + @TableField(value = "field_remark") + @McpToolParam(fieldName = "fieldRemark", type = McpToolParamType.STRING, description = "字段备注") + private String fieldRemark; + + /** + * 字段长度。 + */ + @TableField(value = "field_length") + @McpToolParam(fieldName = "fieldLength", type = McpToolParamType.INTEGER, description = "字段长度") + private Integer fieldLength; + + /** + * 小数点位数。 + */ + @TableField(value = "field_point") + @McpToolParam(fieldName = "fieldPoint", type = McpToolParamType.INTEGER, description = "小数点位数") + private Integer fieldPoint; + + /** + * 字段类型(前端展示类型,即组件widgetType)。 + */ + @TableField(value = "field_type") + @McpToolParam(fieldName = "fieldType", type = McpToolParamType.INTEGER, description = "字段类型(前端展示类型,即组件widgetType)") + private Integer fieldType; + + /** + * 关联 zz_online_column 列ID(前端传入;为空=新增字段待建列,非空=已有字段仅修改)。 + */ + @TableField(value = "column_id") + @McpToolParam(fieldName = "columnId", type = McpToolParamType.INTEGER, description = "关联在线列Id(空=新增字段,非空=已有字段修改)") + private Long columnId; + + /** + * 是否为主键:0-否,1-是。 + */ + @TableField(value = "is_key") + @McpToolParam(fieldName = "isKey", type = McpToolParamType.INTEGER, description = "是否主键:0-否,1-是") + private Integer isKey; + + /** + * 默认值。 + */ + @TableField(value = "default_value") + @McpToolParam(fieldName = "defaultValue", type = McpToolParamType.STRING, description = "默认值") + private String defaultValue; + + /** + * 是否允许为空:0-否,1-是。 + */ + @TableField(value = "is_empty") + @McpToolParam(fieldName = "isEmpty", type = McpToolParamType.INTEGER, description = "是否允许为空:0-否,1-是") + private Integer isEmpty; + + /** + * 是否为默认字段:0-否,1-是。 + */ + @TableField(value = "is_default") + @McpToolParam(fieldName = "isDefault", type = McpToolParamType.INTEGER, description = "是否默认字段:0-否,1-是") + private Integer isDefault; + + /** + * 是否列表展示:0-否,1-是。 + */ + @TableField(value = "is_list") + @McpToolParam(fieldName = "isList", type = McpToolParamType.INTEGER, description = "是否列表展示:0-否,1-是") + private Integer isList; + + /** + * 是否过滤条件:0-否,1-是。 + */ + @TableField(value = "is_filter") + @McpToolParam(fieldName = "isFilter", type = McpToolParamType.INTEGER, description = "是否过滤条件:0-否,1-是") + private Integer isFilter; + + /** + * 过滤方案,参考FieldFilterType常量。 + */ + @TableField(value = "filter_type") + @McpToolParam(fieldName = "filterType", type = McpToolParamType.INTEGER, description = "过滤方案(参考FieldFilterType常量)") + private Integer filterType; + + /** + * 关联元数据ID。 + */ + @TableField(value = "ref_property") + @McpToolParam(fieldName = "refProperty", type = McpToolParamType.INTEGER, description = "关联元数据Id") + private Long refProperty; + + /** + * 关联数据字典ID。 + */ + @TableField(value = "ref_dict") + @McpToolParam(fieldName = "refDict", type = McpToolParamType.INTEGER, description = "关联数据字典Id") + private Long refDict; + + /** + * 关联数据字典名称。 + */ + @TableField(value = "ref_dict_name") + @McpToolParam(fieldName = "refDictName", type = McpToolParamType.STRING, description = "关联数据字典名称") + private String refDictName; + + /** + * 关联在线表单id(F7关联字段,前端传入,用于解析关联表,不落库)。 + */ + @TableField(exist = false) + @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "关联在线表单Id(F7关联字段)") + private Long formId; + + /** + * 关联在线表单显示字段(F7关联字段,前端传入,不落库)。 + */ + @TableField(exist = false) + @McpToolParam(fieldName = "displayField", type = McpToolParamType.STRING, description = "关联在线表单显示字段(F7关联字段)") + private String displayField; + + /** + * 关联表头id(F7关联字段,由关联在线表单解析出 onl_form_head.id,用于 F7 控件 slaveTableId 与 onl_form_field.ref_propert 回写,不落库)。 + */ + @TableField(exist = false) + @McpToolParam(fieldName = "refTableId", type = McpToolParamType.INTEGER, description = "关联表头Id(F7关联字段)") + private Long refTableId; + + /** + * 关联表名(F7关联字段,由关联在线表单解析,不落库)。 + */ + @TableField(exist = false) + @McpToolParam(fieldName = "refTableName", type = McpToolParamType.STRING, description = "关联表名(F7关联字段)") + private String refTableName; + + /** + * 关联在线表单编码(F7关联字段,由关联在线表单解析,用于 relativeFormCode,不落库)。 + */ + @TableField(exist = false) + @McpToolParam(fieldName = "refFormCode", type = McpToolParamType.STRING, description = "关联在线表单编码(F7关联字段)") + private String refFormCode; + + /** + * 租户ID。 + */ + @TableField(value = "tenant_id") + private Long tenantId; + + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + + /** + * 创建人ID。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + + /** + * 更新人ID。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/FieldKind.java b/common/common-online/src/main/java/apelet/common/online/model/constant/FieldKind.java index 0470dcc..dcb8c42 100644 --- a/common/common-online/src/main/java/apelet/common/online/model/constant/FieldKind.java +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/FieldKind.java @@ -92,6 +92,16 @@ public final class FieldKind { } /** + * 判断字段类别是否为附件上传/图片上传字段。 + * + * @param fieldKind 字段类别值。 + * @return 是上传字段返回true,否则false。 + */ + public static boolean isFileKind(Integer fieldKind) { + return fieldKind != null && (fieldKind == UPLOAD || fieldKind == UPLOAD_IMAGE); + } + + /** * 判断参数是否为当前常量字典的合法值。 * * @param value 待验证的参数值。 diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/FormType.java b/common/common-online/src/main/java/apelet/common/online/model/constant/FormType.java index a9ea402..932bbb5 100644 --- a/common/common-online/src/main/java/apelet/common/online/model/constant/FormType.java +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/FormType.java @@ -35,6 +35,10 @@ public final class FormType { * 流程工单表单。 */ public static final int FLOW_WORK_ORDER = 11; + /** + * 快捷表单。 + */ + public static final int QUICK_FORM = 15; private static final Map DICT_MAP = new HashMap<>(2); static { @@ -44,6 +48,7 @@ public final class FormType { DICT_MAP.put(EDIT_FORM, "编辑表单"); DICT_MAP.put(FLOW, "流程表单"); DICT_MAP.put(FLOW_WORK_ORDER, "流程工单表单"); + DICT_MAP.put(QUICK_FORM, "快捷表单"); } /** diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/LayoutType.java b/common/common-online/src/main/java/apelet/common/online/model/constant/LayoutType.java new file mode 100644 index 0000000..90766a5 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/LayoutType.java @@ -0,0 +1,50 @@ +package apelet.common.online.model.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 快捷表单排版类型常量字典对象。 + * + * @author chenchuchuan + * @date 2026-09-08 + */ +public final class LayoutType { + + /** + * 一行一列。 + */ + public static final int SINGLE_COLUMN = 0; + /** + * 一行两列。 + */ + public static final int DOUBLE_COLUMN = 1; + /** + * 一行三列。 + */ + public static final int TRIPLE_COLUMN = 2; + + private static final Map DICT_MAP = new HashMap<>(3); + + static { + DICT_MAP.put(SINGLE_COLUMN, "一行一列"); + DICT_MAP.put(DOUBLE_COLUMN, "一行两列"); + DICT_MAP.put(TRIPLE_COLUMN, "一行三列"); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private LayoutType() { + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/rule/FormRuleEngine.java b/common/common-online/src/main/java/apelet/common/online/rule/FormRuleEngine.java index 5fcaed2..fa2a07f 100644 --- a/common/common-online/src/main/java/apelet/common/online/rule/FormRuleEngine.java +++ b/common/common-online/src/main/java/apelet/common/online/rule/FormRuleEngine.java @@ -80,14 +80,20 @@ public class FormRuleEngine { if (rule == null) { return null; } + return executeAction(rule, null, values); + } + + public List executeAction(FormRule rule, OnlineEventPluginExecuteDto dto, ObjectValue values) { + if (dto == null) { + dto = new OnlineEventPluginExecuteDto(); + } List result = new ArrayList<>(); - executeRule(rule, null, values, null, new OnlineEventPluginExecuteDto(), result); + executeRule(rule, null, values, null, dto, result); return result; } - private void executeRule(FormRule rule, OnlineForm form, ObjectValue ov, String eventCode, - OnlineEventPluginExecuteDto dto, List result) { + public void executeRule(FormRule rule, OnlineForm form, ObjectValue ov, String eventCode, OnlineEventPluginExecuteDto dto, List result) { if (!rule.isEnable()) { return; } diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineFileService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineFileService.java new file mode 100644 index 0000000..e37c2db --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineFileService.java @@ -0,0 +1,54 @@ +package apelet.common.online.service; + +import apelet.common.online.model.OnlineFile; + +import java.util.List; +import java.util.Map; + +/** + * 在线表单文件数据操作服务接口。 + * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlineFileService { + + /** + * 全量替换指定业务记录、指定字段的文件数据:先删旧再插入。 + * + * @param onlineTableId 在线表Id(zz_online_table.table_id)。 + * @param fieldName 字段名称。 + * @param businessId 业务Id(所在表记录主键值)。 + * @param fileList 文件数据列表。 + */ + void replaceFileList(Long onlineTableId, String fieldName, Long businessId, List fileList); + + /** + * 删除指定业务记录的全部文件数据。 + * + * @param onlineTableId 在线表Id(zz_online_table.table_id)。 + * @param businessId 业务Id(所在表记录主键值)。 + */ + void removeByBusinessId(Long onlineTableId, Long businessId); + + /** + * 获取指定业务记录、指定字段的文件数据列表。 + * + * @param onlineTableId 在线表Id(zz_online_table.table_id)。 + * @param fieldName 字段名称。 + * @param businessId 业务Id(所在表记录主键值)。 + * @return 文件数据列表。 + */ + List getOnlineFileList(Long onlineTableId, String fieldName, Long businessId); + + /** + * 批量获取多个业务记录的文件数据,按业务Id分组返回。 + * + * @param onlineTableId 在线表Id(zz_online_table.table_id)。 + * @param fieldName 字段名称。 + * @param businessIdList 业务Id列表。 + * @return key为businessId,value为该业务的文件数据列表。 + */ + Map> getOnlineFileMapByBusinessIds( + Long onlineTableId, String fieldName, List businessIdList); +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineFormService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineFormService.java index 8a6cda5..1b8b205 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/OnlineFormService.java +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineFormService.java @@ -157,4 +157,13 @@ public interface OnlineFormService extends IBaseService { * @see apelet.common.online.model.constant.FormType */ List queryByTableName(String tableName, int formType); + + + /** + * 根据表单名称查询表单 + * + * @param formName 表单名称 + * @return 表单 + */ + List queryByFormName(String formName); } diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineHomepageConfigService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineHomepageConfigService.java new file mode 100644 index 0000000..6b96582 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineHomepageConfigService.java @@ -0,0 +1,30 @@ +package apelet.common.online.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.online.model.OnlineHomepageConfig; + +/** + * 功能: 在线首页配置 + * + * @author {chenchuchuan} + * @version 1.0 + */ +public interface OnlineHomepageConfigService extends IBaseService { + + + /** + * 修改在线首页配置 + * + * @param onlineHomepageConfig 在线首页配置 + * @return 是否修改成功 + */ + boolean updateOrSave(String onlineHomepageConfig); + + /** + * 根据用户查询在线首页配置 + * + * @return 在线首页配置 + */ + OnlineHomepageConfig queryByUser(); + +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java index 84239c6..6a9f057 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java @@ -4,8 +4,11 @@ import apelet.common.core.object.*; import apelet.common.core.object.MyPageData; import apelet.common.core.object.MyPageParam; import apelet.common.core.object.TraceParamDto; +import apelet.common.online.abstractplugin.model.GridData; +import apelet.common.online.dto.OnlineEventPluginExecuteDto; import apelet.common.online.dto.OnlineFilterDto; import apelet.common.online.dto.OnlinePluginExecuteDto; +import apelet.common.generator.model.OnlFormHead; import apelet.common.online.model.*; import apelet.common.online.object.TransactionalBusinessData; import apelet.common.online.plugin.OperationResult; @@ -13,8 +16,6 @@ import apelet.common.online.plugin.PlugInArgs; import apelet.common.orm.impl.Filter; import apelet.common.orm.impl.Selector; import com.alibaba.fastjson.JSONObject; -import apelet.common.online.dto.OnlineFilterDto; -import apelet.common.online.object.TransactionalBusinessData; import java.util.List; import java.util.Map; @@ -304,6 +305,29 @@ public interface OnlineOperationService { */ void executePluginTransaction(PlugInArgs plugInArgs); + /** + * 执行列表查询插件(LOADLISTDATA),插件未返回 gridData 时回退到 ORM/EAS 列表服务。 + *

由 OnlineOperationController 的私有方法提升为服务接口,供 OpenAPI 等场景复用。

+ * + * @param formId 表单Id。 + * @param filterDtoList 过滤条件列表。 + * @param orderParam 排序参数。 + * @param pageParam 分页参数。 + * @param traceParamDto 数据追踪参数。 + * @param onlFormHead 表单表头元数据。 + * @param datasourceResult 数据源校验结果。 + * @param onlineEventPluginExecuteDto 插件执行上下文(可空,listParams 会被填充)。 + * @return 列表查询结果。 + */ + GridData exeListPlugin( + Long formId, + List filterDtoList, + MyOrderParam orderParam, + MyPageParam pageParam, + TraceParamDto traceParamDto, + OnlFormHead onlFormHead, + ResponseResult datasourceResult, + OnlineEventPluginExecuteDto onlineEventPluginExecuteDto); Object ormSaveNew(OnlineTable masterTable, JSONObject masterData, ObjectValue objectValue); @@ -313,4 +337,13 @@ public interface OnlineOperationService { List collectionToJson(ObjectCollection collection, List> resultList); + /** + * 查询结果回填:将附件上传/图片上传字段(field_kind=1/2)对应的 zz_online_file 数据 + * 组装成文件JSON数组,回填到查询结果的对应字段上。 + * + * @param resultList 查询结果列表,会被原地修改。 + * @param table 结果列表所属的在线数据表(主表或分录表)。 + */ + void fillFileColumn(List> resultList, OnlineTable table); + } diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlinePageService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlinePageService.java index b15c644..0d174d7 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/OnlinePageService.java +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlinePageService.java @@ -14,7 +14,7 @@ import java.util.List; */ public interface OnlinePageService extends IBaseService { - /** + /**z * 保存新增对象。 * * @param onlinePage 新增对象。 diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineQuickFormFieldService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineQuickFormFieldService.java new file mode 100644 index 0000000..8ca4fa7 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineQuickFormFieldService.java @@ -0,0 +1,13 @@ +package apelet.common.online.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.online.model.OnlineQuickFormField; + +/** + * 在线快捷表单字段数据操作服务接口。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +public interface OnlineQuickFormFieldService extends IBaseService { +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineQuickFormService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineQuickFormService.java new file mode 100644 index 0000000..afa87f8 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineQuickFormService.java @@ -0,0 +1,36 @@ +package apelet.common.online.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.online.model.OnlineQuickForm; + +import java.util.List; + +/** + * 在线快捷表单数据操作服务接口。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +public interface OnlineQuickFormService extends IBaseService { + + /** + * 获取单表查询结果。由于没有关联数据查询,因此在仅仅获取单表数据的场景下,效率更高。 + * 如果需要同时获取关联数据,请移步(getOnlineFormListWithRelation)方法。 + * + * @param filter 过滤对象。 + * @param orderBy 排序参数。 + * @return 查询结果集。 + */ + List getOnlineFormList(OnlineQuickForm filter, String orderBy); + + /** + * 获取主表的查询结果,以及主表关联的字典数据和一对一从表数据,以及一对一从表的字典数据。 + * 该查询会涉及到一对一从表的关联过滤,或一对多从表的嵌套关联过滤,因此性能不如单表过滤。 + * 如果仅仅需要获取主表数据,请移步(getOnlineFormList),以便获取更好的查询性能。 + * + * @param filter 主表过滤对象。 + * @param orderBy 排序参数。 + * @return 查询结果集。 + */ + List getOnlineFormListWithRelation(OnlineQuickForm filter, String orderBy); +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineColumnServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineColumnServiceImpl.java index 2756598..7f7d871 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineColumnServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineColumnServiceImpl.java @@ -113,9 +113,25 @@ public class OnlineColumnServiceImpl extends BaseService imp @Override public long saveBySqlTable(SqlTableColumn sqlTableColumn, Long tableId, String columnComment) { + // 同表同名列已存在则更新,避免重复插入产生脏数据(导致后续按表名列名 selectOne 报多行) + OnlineColumn existing = this.getOnlineColumnByTableIdAndColumnName(tableId, sqlTableColumn.getColumnName()); + OnlineColumn onlineColumn = buildOnlineColumn(sqlTableColumn, tableId, columnComment, existing == null ? idGenerator.nextLongId() : existing.getColumnId()); + if (existing == null) { + if (super.save(onlineColumn)) { + sqlTableColumn.setColumnName(onlineColumn.getColumnName().toLowerCase()); + return onlineColumn.getColumnId(); + } + return 0; + } + super.updateById(onlineColumn); + sqlTableColumn.setColumnName(onlineColumn.getColumnName().toLowerCase()); + return existing.getColumnId(); + } + + private OnlineColumn buildOnlineColumn(SqlTableColumn sqlTableColumn, Long tableId, String columnComment, Long columnId) { OnlineColumn onlineColumn = new OnlineColumn(); BeanUtil.copyProperties(sqlTableColumn, onlineColumn, false); - onlineColumn.setColumnId(idGenerator.nextLongId()); + onlineColumn.setColumnId(columnId); onlineColumn.setTableId(tableId); // 传了描述,并且不等于原先的描述,并且原先的为空或者为"null",就赋值 if (StringUtil.isNotBlank(columnComment) && !StringUtil.equals(columnComment, onlineColumn.getColumnComment()) @@ -126,11 +142,7 @@ public class OnlineColumnServiceImpl extends BaseService imp onlineColumn.setColumnComment(onlineColumn.getColumnName()); } this.setDefault(sqlTableColumn, onlineColumn); - if (super.save(onlineColumn)) { - sqlTableColumn.setColumnName(onlineColumn.getColumnName().toLowerCase()); - return onlineColumn.getColumnId(); - } - return 0; + return onlineColumn; } /** @@ -264,7 +276,9 @@ public class OnlineColumnServiceImpl extends BaseService imp OnlineColumn filter = new OnlineColumn(); filter.setTableId(tableId); filter.setColumnName(columnName); - return onlineColumnMapper.selectOne(new QueryWrapper<>(filter)); + // 历史脏数据可能存在同表同名列的多条记录,selectOne 会抛 TooManyResultsException,这里取第一条即可 + List list = onlineColumnMapper.selectList(new QueryWrapper<>(filter)); + return CollUtil.isEmpty(list) ? null : list.get(0); } @Override diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFileServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFileServiceImpl.java new file mode 100644 index 0000000..9f19f8b --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFileServiceImpl.java @@ -0,0 +1,93 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.online.dao.OnlineFileMapper; +import apelet.common.online.model.OnlineFile; +import apelet.common.online.service.OnlineFileService; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; +import cn.hutool.core.collection.CollUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 在线表单文件数据操作服务实现类。 + * + * @author guifc + * @date 2023-08-04 + */ +@MyDataSourceResolver( + resolver = DefaultDataSourceResolver.class, + intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineFileService") +public class OnlineFileServiceImpl implements OnlineFileService { + + @Autowired + private OnlineFileMapper onlineFileMapper; + @Autowired + private IdGeneratorWrapper idGenerator; + + @Transactional(rollbackFor = Exception.class) + @Override + public void replaceFileList(Long onlineTableId, String fieldName, Long businessId, List fileList) { + OnlineFile filter = new OnlineFile(); + filter.setOnlineTableId(onlineTableId); + filter.setFieldName(fieldName); + filter.setBusinessId(businessId); + onlineFileMapper.delete(new QueryWrapper<>(filter)); + if (CollUtil.isEmpty(fileList)) { + return; + } + Date now = new Date(); + for (OnlineFile onlineFile : fileList) { + onlineFile.setId(idGenerator.nextLongId()); + onlineFile.setOnlineTableId(onlineTableId); + onlineFile.setFieldName(fieldName); + onlineFile.setBusinessId(businessId); + if (onlineFile.getCreateTime() == null) { + onlineFile.setCreateTime(now); + } + onlineFile.setUpdateTime(now); + onlineFileMapper.insert(onlineFile); + } + } + + @Transactional(rollbackFor = Exception.class) + @Override + public void removeByBusinessId(Long onlineTableId, Long businessId) { + OnlineFile filter = new OnlineFile(); + filter.setOnlineTableId(onlineTableId); + filter.setBusinessId(businessId); + onlineFileMapper.delete(new QueryWrapper<>(filter)); + } + + @Override + public List getOnlineFileList(Long onlineTableId, String fieldName, Long businessId) { + OnlineFile filter = new OnlineFile(); + filter.setOnlineTableId(onlineTableId); + filter.setFieldName(fieldName); + filter.setBusinessId(businessId); + return onlineFileMapper.selectList(new QueryWrapper<>(filter)); + } + + @Override + public Map> getOnlineFileMapByBusinessIds(Long onlineTableId, String fieldName, List businessIdList) { + if (CollUtil.isEmpty(businessIdList)) { + return new HashMap<>(); + } + List fileList = onlineFileMapper.selectList(new QueryWrapper() + .eq("online_table_id", onlineTableId) + .eq("field_name", fieldName) + .in("business_id", businessIdList)); + return fileList.stream().collect(Collectors.groupingBy(OnlineFile::getBusinessId)); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFormServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFormServiceImpl.java index 2a36b66..8b92056 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFormServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineFormServiceImpl.java @@ -518,6 +518,14 @@ public class OnlineFormServiceImpl extends BaseService impleme return Collections.emptyList(); } + + @Override + public List queryByFormName(String formName) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.likeRight(StringUtils.isNotBlank(formName), OnlineForm::getFormName, formName); + return onlineFormMapper.selectList(queryWrapper); + } + /** * 设置关联表字段转换为对象 * diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineHomepageConfigServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineHomepageConfigServiceImpl.java new file mode 100644 index 0000000..3d3a817 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineHomepageConfigServiceImpl.java @@ -0,0 +1,73 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.online.dao.OnlineHomepageConfigMapper; +import apelet.common.online.model.OnlineHomepageConfig; +import apelet.common.online.service.OnlineHomepageConfigService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Date; + +/** + * 功能: 在线首页配置服务实现类 + * + * @author {chenchuchuan} + * @version 1.0 + */ +@Slf4j +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("OnlineHomepageConfigService") +public class OnlineHomepageConfigServiceImpl extends BaseService implements OnlineHomepageConfigService { + + @Autowired + private OnlineHomepageConfigMapper onlineHomepageConfigMapper; + + @Override + public OnlineHomepageConfigMapper mapper() { + return onlineHomepageConfigMapper; + } + + @Override + public boolean updateOrSave(String onlineHomepageConfig) { + if (StringUtils.isBlank(onlineHomepageConfig)) { + return false; + } + OnlineHomepageConfig homepageConfig = this.queryByUser(); + homepageConfig.setWidgetJson(onlineHomepageConfig); + Date date = new Date(); + TokenData tokenData = TokenData.takeFromRequest(); + homepageConfig.setAppCode(tokenData.getAppCode()); + homepageConfig.setTenantId(tokenData.getTenantId()); + homepageConfig.setUpdateUserId(tokenData.getUserId()); + homepageConfig.setUpdateTime(date); + if (homepageConfig.getId() == null) { + homepageConfig.setCreateTime(date); + homepageConfig.setCreateUserId(tokenData.getUserId()); + return onlineHomepageConfigMapper.insert(homepageConfig) >= 0; + } + return onlineHomepageConfigMapper.updateById(homepageConfig) >= 0; + } + + @Override + public OnlineHomepageConfig queryByUser() { + TokenData tokenData = TokenData.takeFromRequest(); + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(StringUtils.isNotBlank(tokenData.getAppCode()), OnlineHomepageConfig::getAppCode, tokenData.getAppCode()); + lambdaQueryWrapper.eq(tokenData.getTenantId() != null, OnlineHomepageConfig::getTenantId, tokenData.getTenantId()); + lambdaQueryWrapper.eq(OnlineHomepageConfig::getCreateUserId, tokenData.getUserId()); + OnlineHomepageConfig homepageConfig = onlineHomepageConfigMapper.selectOne(lambdaQueryWrapper); + if (homepageConfig == null) { + homepageConfig = new OnlineHomepageConfig(); + } + return homepageConfig; + + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java index 0645aeb..c5a3dbc 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java @@ -1,63 +1,72 @@ package apelet.common.online.service.impl; +import apelet.common.core.annotation.MultiDatabaseWriteMethod; +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.cache.CacheConfig; +import apelet.common.core.constant.*; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.exception.NoDataPermException; +import apelet.common.core.object.*; import apelet.common.core.util.*; +import apelet.common.datafilter.config.DataFilterProperties; +import apelet.common.dbutil.constant.DblinkType; +import apelet.common.dbutil.provider.DataSourceProvider; +import apelet.common.dict.model.TenantGlobalDict; +import apelet.common.dict.service.GlobalDictService; +import apelet.common.dict.service.TenantGlobalDictService; import apelet.common.generator.model.OnlFormField; import apelet.common.generator.model.OnlFormHead; import apelet.common.generator.service.IOnlFormFieldService; import apelet.common.generator.service.IOnlFormHeadService; import apelet.common.generator.utils.OrmGenDataSourceUtil; +import apelet.common.online.config.OnlineProperties; +import apelet.common.online.abstractplugin.ListDataEasService; +import apelet.common.online.abstractplugin.ListDataOrmService; +import apelet.common.online.abstractplugin.ListDataService; +import apelet.common.online.abstractplugin.model.GridData; import apelet.common.online.dao.ConvertRelationFOnLineMapper; +import apelet.common.online.dao.OnlineOperationMapper; +import apelet.common.online.dto.OnlineEventPluginExecuteDto; +import apelet.common.online.dto.OnlineFilterDto; import apelet.common.online.dto.OnlinePluginExecuteDto; import apelet.common.online.dto.OnlinePluginExecuteSortDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.*; +import apelet.common.online.model.constant.EventEnum; +import apelet.common.online.model.constant.FieldFilterType; +import apelet.common.online.model.constant.FieldKind; +import apelet.common.online.model.constant.RelationType; +import apelet.common.online.model.constant.VirtualType; +import apelet.common.online.object.ColumnData; +import apelet.common.online.object.ConstDictInfo; +import apelet.common.online.object.JoinTableInfo; +import apelet.common.online.object.TransactionalBusinessData; import apelet.common.online.plugin.OperationResult; import apelet.common.online.plugin.OperationServicePlugIn; import apelet.common.online.plugin.PlugInArgs; +import apelet.common.online.plugin.pre.DeleteOperationServicePlugIn; +import apelet.common.online.plugin.pre.SaveOperationServicePlugIn; +import apelet.common.online.service.*; +import apelet.common.online.util.*; +import apelet.common.online.vo.OnlineEventPluginExecuteVo; import apelet.common.orm.impl.*; +import apelet.common.redis.util.CommonRedisUtil; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; import cn.hutool.core.text.StrFormatter; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.EnumUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.core.map.MapUtil; import cn.hutool.http.HttpUtil; import com.alibaba.fastjson.JSON; -import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.TypeReference; -import apelet.common.core.annotation.MultiDatabaseWriteMethod; -import apelet.common.core.annotation.MyDataSourceResolver; -import apelet.common.core.cache.CacheConfig; -import apelet.common.core.constant.*; -import apelet.common.core.exception.MyRuntimeException; -import apelet.common.core.exception.NoDataPermException; -import apelet.common.core.object.*; -import apelet.common.datafilter.config.DataFilterProperties; -import apelet.common.dbutil.constant.DblinkType; -import apelet.common.dbutil.provider.DataSourceProvider; -import apelet.common.dict.model.TenantGlobalDict; -import apelet.common.dict.service.GlobalDictService; -import apelet.common.dict.service.TenantGlobalDictService; -import apelet.common.online.config.OnlineProperties; -import apelet.common.online.dao.OnlineOperationMapper; -import apelet.common.online.dto.OnlineFilterDto; -import apelet.common.online.exception.OnlineRuntimeException; -import apelet.common.online.model.*; -import apelet.common.online.model.constant.FieldFilterType; -import apelet.common.online.model.constant.*; -import apelet.common.online.object.ConstDictInfo; -import apelet.common.online.object.*; -import apelet.common.online.service.*; -import apelet.common.online.util.*; -import apelet.common.orm.impl.EntityColumn; -import apelet.common.orm.impl.EntityTable; -import apelet.common.orm.impl.LinkEntityColumn; -import apelet.common.redis.util.CommonRedisUtil; -import apelet.common.sequence.wrapper.IdGeneratorWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.google.common.collect.LinkedHashMultimap; import com.google.common.collect.Multimap; import lombok.Data; @@ -73,12 +82,10 @@ import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.io.File; -import java.io.IOException; import java.io.Serializable; -import java.nio.file.Files; import java.sql.Connection; +import java.text.SimpleDateFormat; import java.time.LocalDateTime; -import java.time.temporal.ValueRange; import java.util.*; import java.util.stream.Collectors; @@ -135,6 +142,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { private OnlineOperationService onlineOperationService; @Autowired private IOnlFormFieldService onlFormFieldService; + @Autowired + private OnlineFileService onlineFileService; private static final String DICT_MAP_SUFFIX = "DictMap"; private static final String DICT_MAP_LIST_SUFFIX = "DictMapList"; @@ -197,6 +206,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } } } + this.saveFileColumn(table, id, data); + this.nullifyFileColumnValues(columnDataList, columnValueList); this.doInsert(table, columnNames, columnValueList); // List list = Arrays.asList(columnNames.split(",")); // ObjectValue objectValue = new ObjectValue(table.getTableName()); @@ -245,6 +256,8 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } } // this.doInsert(table, columnNames, columnValueList); + this.saveFileColumn(table, id, data); + this.nullifyFileColumnValues(columnDataList, columnValueList); List column = Arrays.asList(columnNames.split(",")); for (int i = 0; i < columnValueList.size(); i++) { if (columnValueList.get(i) != null) { @@ -371,6 +384,10 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } continue; } + if (FieldKind.isFileKind(columnData.getColumn().getFieldKind())) { + // 文件字段值已写入 zz_online_file,业务列统一置NULL。 + columnData.setColumnValue(null); + } if (!MyCommonUtil.equalsAny(columnData.getColumn().getFieldKind(), FieldKind.CREATE_TIME, FieldKind.CREATE_USER_ID, FieldKind.CREATE_DEPT_ID, FieldKind.TENANT_FILTER)) { updateColumnList.add(columnData); @@ -380,6 +397,7 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } } if (CollUtil.isEmpty(updateColumnList)) { + this.saveFileColumn(table, this.convertToLongId(dataId), data); return true; } if (CollUtil.isNotEmpty(maskFieldDataList)) { @@ -388,7 +406,9 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { this.compareAndSetMaskFieldData(table, maskFieldDataList, originalData); } String dataPermFilter = this.buildDataPermFilter(table); - return this.doUpdate(table, updateColumnList, filterList, dataPermFilter); + boolean updated = this.doUpdate(table, updateColumnList, filterList, dataPermFilter); + this.saveFileColumn(table, this.convertToLongId(dataId), data); + return updated; } @MultiDatabaseWriteMethod @@ -476,6 +496,11 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } else { this.doLogicDelete(table, table.getPrimaryKeyColumn(), dataId, dataPermFilter); } + Long businessId = this.convertToLongId(dataId); + if (businessId != null) { + // 级联清理该业务记录对应的附件数据。 + onlineFileService.removeByBusinessId(table.getTableId(), businessId); + } if (CollUtil.isEmpty(relationList)) { return true; } @@ -494,11 +519,27 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } List slaveFilterList = this.makeDefaultFilter(relation.getSlaveTable(), slaveColumn, columnValue); + // 先查出从表受影响记录的主键,用于附件清理。 + List slaveBusinessIdList = new LinkedList<>(); + OnlineColumn slavePkColumn = slaveTable.getPrimaryKeyColumn(); + if (slavePkColumn != null) { + List> slaveRows = onlineOperationMapper.getList(slaveTable.getTableName(), null, slavePkColumn.getColumnName(), slaveFilterList, null, null); + slaveBusinessIdList = slaveRows.stream() + .map(r -> r.get(slavePkColumn.getColumnName())) + .filter(ObjectUtil::isNotEmpty) + .map(v -> this.convertToLongId(v.toString())) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } if (slaveTable.getLogicDeleteColumn() == null) { this.doDelete(slaveTable, slaveFilterList, null); } else { this.doLogicDelete(slaveTable, slaveColumn, columnValue, null); } + // 级联清理从表业务记录对应的附件数据。 + for (Long slaveBusinessId : slaveBusinessIdList) { + onlineFileService.removeByBusinessId(slaveTable.getTableId(), slaveBusinessId); + } } return true; } @@ -560,6 +601,7 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { this.buildVirtualColumn(resultList, table, toManyRelationList); } this.reformatResultListWithOneToOneRelation(resultList, oneToOneRelationList); + this.fillFileColumn(resultList, table); return resultList.get(0); } @@ -575,6 +617,7 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { slaveTable, null, selectFields, filterList, dataPermFilter, null, null); List> resultList = pageData.getDataList(); this.buildDataListWithDict(resultList, slaveTable); + this.fillFileColumn(resultList, slaveTable); return CollUtil.isEmpty(resultList) ? null : resultList.get(0); } @@ -608,6 +651,7 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { this.buildVirtualColumn(resultList, table, toManyRelationList); } this.reformatResultListWithOneToOneRelation(resultList, oneToOneRelationList); + this.fillFileColumn(resultList, table); return pageData; } @@ -660,7 +704,16 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { }else if(objectValue.get(string) == null){ jsonObject.put(string, ""); }else if(objectValue.get(string) instanceof Date){ - jsonObject.put(string,DateTimeUtils.format((Date) objectValue.get(string) ,"yyyy-MM-dd")); + Date date = (Date) objectValue.get(string); + String timeStr = new SimpleDateFormat("HH:mm:ss").format(date); + String value = null; + // 判断时间是否为 00:00:00 + if ("00:00:00".equals(timeStr)) { + value = DateTimeUtils.format(date, "yyyy-MM-dd"); + } else { + value = DateTimeUtils.format(date, "yyyy-MM-dd HH:mm:ss"); + } + jsonObject.put(string, value); }else{ jsonObject.put(string, values.get(string)); } @@ -694,6 +747,7 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { this.buildDataListWithDict(resultList, slaveTable); this.reformatResultListWithOneToOneRelation(resultList, oneToOneRelationList); reformatResultListWithOneToOneRelation2(resultList, slaveTable.getTableName()); + this.fillFileColumn(resultList, slaveTable); return pageData; } @@ -1021,7 +1075,12 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } OnlineTable onlineTable = onlineTableService.getOnlineTableFromCache(onlineForm.getMasterTableId()); OnlFormHead onlFormHead = onlFormHeadService.getHeadTableNameCache(onlineTable.getTableName()); + // executePlugin 是所有按钮的统一入口,附件的写入/清理必须按插件类型分别设闸, + // 否则「审核」这类只提交部分字段的按钮会把附件全量覆盖掉,「删除」则不会清理附件。 + boolean saveOperation = this.containsPlugin(sortDtoList, SaveOperationServicePlugIn.class); + boolean deleteOperation = this.containsPlugin(sortDtoList, DeleteOperationServicePlugIn.class); ObjectValue finalObjectValue = null; + List pendingFileList = new LinkedList<>(); if (onlFormHead.getIsomerismMark() != null) { finalObjectValue = this.cntEasValue(onlinePluginExecuteDto, masterData, @@ -1031,12 +1090,294 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { } else { if(masterData != null){ finalObjectValue = onlineFormService.getObjectValue(masterData, slaveData, onlineTable.getTableName()); + if (saveOperation) { + // 附件上传/图片上传字段:先摘出入参值并把业务列置NULL,等ORM保存回写主键后再落 zz_online_file。 + pendingFileList = this.extractFileColumn(finalObjectValue, onlineTable, slaveData); + } } } List list = sortDtoList.stream().sorted(Comparator.comparing(OnlinePluginExecuteSortDto::getOrder)).collect(Collectors.toList()); - return exePlugin(list, onlinePluginExecuteDto.getBillId(), onlineTable.getTableName(), finalObjectValue, onlineForm); + // 删除会被ORM级联删掉分录行,附件归属的分录主键必须在删除前查出来,删完就查不到了。 + Map> removeFileBusinessIdMap = new LinkedHashMap<>(); + if (deleteOperation && onlFormHead.getIsomerismMark() == null) { + removeFileBusinessIdMap = this.collectFileBusinessIdForRemove(onlineTable, onlinePluginExecuteDto.getBillId()); + } + OperationResult operationResult = exePlugin(list, onlinePluginExecuteDto.getBillId(), onlineTable.getTableName(), finalObjectValue, onlineForm); + // ORM保存会把生成的主键回写到入参的ObjectValue实例上,此时才能确定附件归属的业务主键。 + this.flushPendingFileColumn(pendingFileList); + this.removeFileByBusinessIdMap(removeFileBusinessIdMap); + return operationResult; + } + + /** + * 判断当前按钮绑定的插件中是否包含指定插件。 + *

按钮与插件的绑定是 widgetJson 中 pluginList[].class 的全限定类名(见 QuickFormWidgetUtil#setSaveButtonEnabled), + * 用类名判断比用中文 buttonName 可靠。

+ */ + private boolean containsPlugin(List sortDtoList, Class pluginClass) { + if (CollUtil.isEmpty(sortDtoList)) { + return false; + } + return sortDtoList.stream().anyMatch(dto -> pluginClass.getName().equals(dto.getClassPath())); + } + + /** + * 删除前收集待清理附件的业务主键。 + *

表头主键取自billId;分录主键必须在删除前查出,因为ORM删除表头时会级联删除分录行, + * 删除后再查就拿不到,附件会在 zz_online_file 里变成孤儿记录。

+ * + * @param masterTable 表头对应的在线数据表。 + * @param billId 表头主键,批量删除时为逗号分隔的多个主键。 + * @return 在线表tableId -> 该表下待清理附件的业务主键列表。 + */ + private Map> collectFileBusinessIdForRemove(OnlineTable masterTable, String billId) { + Map> businessIdMap = new LinkedHashMap<>(); + if (masterTable == null || StrUtil.isBlank(billId)) { + return businessIdMap; + } + List masterIdList = new LinkedList<>(); + for (String idStr : StrUtil.split(billId, ',')) { + Long businessId = this.convertToLongId(StrUtil.trim(idStr)); + if (businessId != null) { + masterIdList.add(businessId); + } + } + if (CollUtil.isEmpty(masterIdList)) { + return businessIdMap; + } + businessIdMap.put(masterTable.getTableId(), masterIdList); + try { + this.collectEntryBusinessIdForRemove(masterTable, masterIdList, businessIdMap); + } catch (Exception e) { + // 分录主键查询失败不影响表头附件清理,仅记录日志。 + log.error("删除前查询分录主键失败,分录附件可能残留,table={}, billId={}", masterTable.getTableName(), billId, e); + } + return businessIdMap; + } + + /** + * 从ORM实体元数据中找出分录表(relationType=1),按表头主键查出各分录行主键。 + */ + private void collectEntryBusinessIdForRemove(OnlineTable masterTable, List masterIdList, Map> businessIdMap) { + EntityTable entityTable = ormGenDataSourceUtil.getEntityTable(masterTable.getTableName()); + if (entityTable == null || CollUtil.isEmpty(entityTable.getEntityColumnList())) { + return; + } + // 分录表名 -> 在线表tableId,用于把查询结果里的分录行归到对应的在线表。 + Map entryTableIdMap = new LinkedHashMap<>(); + Selector selector = new Selector(); + selector.getList().add(new SelectorItem("id")); + for (EntityColumn entityColumn : entityTable.getEntityColumnList()) { + if (!(entityColumn instanceof LinkEntityColumn)) { + continue; + } + EntityLinkRelation relation = ((LinkEntityColumn) entityColumn).getEntityLinkRelation(); + if (relation == null || relation.getRelationType() == null || !relation.getRelationType().equals(1)) { + continue; + } + String entryTableName = relation.getSuperTable().getTableName(); + OnlineTable entryTable = onlineTableService.getOne(new LambdaQueryWrapper().eq(OnlineTable::getTableName, entryTableName)); + if (entryTable == null) { + continue; + } + entryTableIdMap.put(entryTableName, entryTable.getTableId()); + // 只取分录主键,不需要其它字段。 + selector.getList().add(new SelectorItem(relation.getRelationName() + ".id")); + } + if (MapUtil.isEmpty(entryTableIdMap)) { + return; + } + Filter filter = new Filter(); + filter.add(new FilterItem("id", FilterItem.INNER, + masterIdList.stream().map(id -> "'" + id + "'").collect(Collectors.joining(",")))); + ObjectCollection collection = ormGenDataSourceUtil.query(masterTable.getTableName(), filter, selector); + for (int i = 0; i < collection.size(); i++) { + Map rowValues = collection.getObject(i).getValues(); + if (MapUtil.isEmpty(rowValues)) { + continue; + } + for (Object value : rowValues.values()) { + if (!(value instanceof ObjectCollection)) { + continue; + } + ObjectCollection entryCollection = (ObjectCollection) value; + for (int j = 0; j < entryCollection.size(); j++) { + ObjectValue entryRow = entryCollection.getObject(j); + // 分录行自带表名,按表名归类比猜ObjectValue上挂载的key名可靠。 + Long entryTableId = entryTableIdMap.get(entryRow.getTableName()); + if (entryTableId == null) { + continue; + } + Object entryIdValue = entryRow.get("id"); + Long entryId = entryIdValue == null ? null : this.convertToLongId(entryIdValue.toString()); + if (entryId != null) { + businessIdMap.computeIfAbsent(entryTableId, k -> new LinkedList<>()).add(entryId); + } + } + } + } + } + + /** + * 按收集到的业务主键清理 zz_online_file 中的附件数据。 + */ + private void removeFileByBusinessIdMap(Map> businessIdMap) { + if (MapUtil.isEmpty(businessIdMap)) { + return; + } + businessIdMap.forEach((onlineTableId, businessIdList) -> { + for (Long businessId : businessIdList) { + onlineFileService.removeByBusinessId(onlineTableId, businessId); + } + }); + } + + /** + * 附件字段暂存对象。 + *

持有表头/分录行的ObjectValue实例引用:新增时主键由ORM在保存过程中生成并回写到该实例上, + * 保存前拿不到主键,只能保存后按实例读回。

+ */ + private static class PendingFileColumn { + /** 附件所属的行对象,ORM保存后主键会回写到该实例上。 */ + private final ObjectValue row; + /** 行所属的在线数据表。 */ + private final OnlineTable table; + /** 附件字段的列名。 */ + private final String columnName; + /** 入参中该字段的文件JSON数组字符串,为空表示清空该字段附件。 */ + private final String fileJson; + + private PendingFileColumn(ObjectValue row, OnlineTable table, String columnName, String fileJson) { + this.row = row; + this.table = table; + this.columnName = columnName; + this.fileJson = fileJson; + } + } + + /** + * 摘出表头及各分录中附件上传/图片上传字段的入参值,并把业务列统一置NULL。 + *

按全量覆盖语义:表中所有 field_kind 为 1/2 的字段一律以入参为准,入参未包含该字段视为清空。

+ * + * @param objectValue 待保存的表头对象,分录以分录表名为key挂在其上。 + * @param masterTable 表头对应的在线数据表。 + * @param slaveData 分录入参,key为分录表的tableId。 + * @return 待落库的附件字段列表。 + */ + private List extractFileColumn(ObjectValue objectValue, OnlineTable masterTable, JSONObject slaveData) { + List pendingList = new LinkedList<>(); + if (objectValue == null) { + return pendingList; + } + this.extractFileColumnFromRow(objectValue, masterTable, pendingList); + if (slaveData == null) { + return pendingList; + } + // 分录:slaveData的key为分录表tableId,ObjectValue上以分录表名挂ObjectCollection(见OnlineFormServiceImpl#getObjectValue)。 + for (String tableIdStr : slaveData.keySet()) { + Long entryTableId = this.convertToLongId(tableIdStr); + if (entryTableId == null) { + continue; + } + OnlineTable entryTable = onlineTableService.getOnlineTableFromCache(entryTableId); + if (entryTable == null) { + continue; + } + Object collectionObj = objectValue.get(entryTable.getTableName()); + if (!(collectionObj instanceof ObjectCollection)) { + continue; + } + ObjectCollection collection = (ObjectCollection) collectionObj; + for (int i = 0; i < collection.size(); i++) { + this.extractFileColumnFromRow(collection.getObject(i), entryTable, pendingList); + } + } + return pendingList; + } + + /** + * 摘出单行中所有附件字段的值,并把该行对应列置NULL。 + */ + private void extractFileColumnFromRow(ObjectValue row, OnlineTable table, List pendingList) { + if (row == null || table == null || MapUtil.isEmpty(table.getColumnMap())) { + return; + } + for (OnlineColumn column : table.getColumnMap().values()) { + if (!FieldKind.isFileKind(column.getFieldKind())) { + continue; + } + String columnName = column.getColumnName(); + Object value = row.get(columnName); + pendingList.add(new PendingFileColumn(row, table, columnName, value == null ? null : value.toString())); + // 业务列仅作查询回填载体,不落库,统一置NULL。 + row.put(columnName, null); + } + } + + /** + * 保存完成后把暂存的附件数据落 zz_online_file,业务主键从ORM回写后的行对象上读取。 + */ + private void flushPendingFileColumn(List pendingList) { + if (CollUtil.isEmpty(pendingList)) { + return; + } + for (PendingFileColumn pending : pendingList) { + OnlineColumn primaryKeyColumn = pending.table.getPrimaryKeyColumn(); + if (primaryKeyColumn == null) { + log.warn("附件落库失败,表 [{}] 未配置主键字段", pending.table.getTableName()); + continue; + } + Object idValue = pending.row.get(primaryKeyColumn.getColumnName()); + Long businessId = idValue == null ? null : this.convertToLongId(idValue.toString()); + if (businessId == null) { + log.warn("附件落库失败,保存后未取到业务主键,table={}, column={}", pending.table.getTableName(), pending.columnName); + continue; + } + List fileList = this.parseOnlineFileList(pending.fileJson, pending.columnName); + onlineFileService.replaceFileList(pending.table.getTableId(), pending.columnName, businessId, fileList); + } + } + + /** + * 把文件字段的JSON数组字符串解析为 OnlineFile 列表。 + *

值为空或解析失败时返回空列表,即按清空该字段附件处理。

+ */ + private List parseOnlineFileList(String fileJson, String columnName) { + List fileList = new LinkedList<>(); + if (StrUtil.isBlank(fileJson)) { + return fileList; + } + JSONArray fileArray; + try { + fileArray = JSON.parseArray(fileJson); + } catch (Exception e) { + log.warn("解析文件字段 [{}] 数据失败,按清空处理,原值={}", columnName, fileJson); + return fileList; + } + if (fileArray == null) { + return fileList; + } + TokenData tokenData = TokenData.takeFromRequest(); + Long operatorUserId = tokenData == null ? null : tokenData.getUserId(); + for (int i = 0; i < fileArray.size(); i++) { + JSONObject fileItem = fileArray.getJSONObject(i); + if (fileItem == null) { + continue; + } + OnlineFile onlineFile = new OnlineFile(); + onlineFile.setBaseUrl(fileItem.getString("baseUrl")); + onlineFile.setDownloadUri(fileItem.getString("downloadUri")); + onlineFile.setFileName(fileItem.getString("filename")); + onlineFile.setUploadPath(fileItem.getString("uploadPath")); + onlineFile.setName(fileItem.getString("name")); + onlineFile.setUrl(fileItem.getString("url")); + onlineFile.setCreateUserId(operatorUserId); + onlineFile.setUpdateUserId(operatorUserId); + fileList.add(onlineFile); + } + return fileList; } @@ -1050,6 +1391,61 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { plugInArgs.endOperationTransaction(); } + /** + * 执行列表查询插件(LOADLISTDATA),插件未返回 gridData 时回退到 ORM/EAS 列表服务。 + * 由 OnlineOperationController 的私有方法提升到 service。 + */ + @Override + public GridData exeListPlugin( + Long formId, + List filterDtoList, + MyOrderParam orderParam, + MyPageParam pageParam, + TraceParamDto traceParamDto, + OnlFormHead onlFormHead, + ResponseResult datasourceResult, + OnlineEventPluginExecuteDto onlineEventPluginExecuteDto) { + if (onlineEventPluginExecuteDto == null) { + onlineEventPluginExecuteDto = new OnlineEventPluginExecuteDto(); + } + onlineEventPluginExecuteDto.setFormId(formId); + onlineEventPluginExecuteDto.setEventCode(EventEnum.LOADLISTDATA.getCode()); + Map listParams = onlineEventPluginExecuteDto.getListParams(); + if (listParams == null) { + listParams = new HashMap<>(); + } + listParams.put("filterDtoList", filterDtoList); + listParams.put("pageParam", pageParam); + listParams.put("orderParam", orderParam); + listParams.put("traceParamDto", traceParamDto); + listParams.put("onlFormHead", onlFormHead); + listParams.put("datasourceResult", datasourceResult); + onlineEventPluginExecuteDto.setListParams(listParams); + onlineEventPluginExecuteDto.setModel(new Model()); + List result = onlineFormService.eventPluginExecute(onlineEventPluginExecuteDto); + GridData gridData = null; + if (result != null) { + for (Object obj : result) { + if (obj instanceof OnlineEventPluginExecuteVo) { + OnlineEventPluginExecuteVo vo = (OnlineEventPluginExecuteVo) obj; + if (vo.getWidgetVariableName().equals("gridData")) { + gridData = (GridData) vo.getWidgetOperateContent(); + } + } + } + } + if (gridData == null) { + ListDataService listDataService = null; + if (onlFormHead.getIsomerismMark() != null) { + listDataService = new ListDataEasService(onlineEventPluginExecuteDto); + } else { + listDataService = new ListDataOrmService(onlineEventPluginExecuteDto); + } + gridData = listDataService.loadGridData(); + } + return gridData; + } + private OperationResult exePlugin(List pluginList, String billid, String tableName, ObjectValue objectValue , OnlineForm onlineForm) { PlugInArgs plugInArgs = new PlugInArgs(null, onlineForm); @@ -2203,6 +2599,155 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { return sb.substring(0, sb.length() - 1); } + /** + * 处理附件上传/图片上传字段:将入参中的文件JSON数组解析后存入 zz_online_file,并把业务字段置空。 + *

约定:入参未包含该字段或值为null时保留原附件数据;空数组时清空该字段的全部附件。

+ * + * @param table 在线数据表对象。 + * @param id 业务记录主键值。 + * @param data 入参数据。 + */ + private void saveFileColumn(OnlineTable table, Object id, JSONObject data) { + if (id == null) { + return; + } + Long businessId = this.convertToLongId(id.toString()); + if (businessId == null) { + return; + } + for (OnlineColumn column : table.getColumnMap().values()) { + if (!FieldKind.isFileKind(column.getFieldKind())) { + continue; + } + String columnName = column.getColumnName(); + // 入参没有该字段时不做处理,保留原有附件。 + if (!data.containsKey(columnName) || data.get(columnName) == null) { + continue; + } + JSONArray fileArray; + try { + fileArray = JSON.parseArray(data.getString(columnName)); + } catch (Exception e) { + // 解析失败说明入参格式异常,保留原有附件,不做清理。 + log.warn("解析文件字段 [{}] 数据失败,保留原有附件", columnName); + continue; + } + List fileList = new LinkedList<>(); + if (fileArray != null) { + for (int i = 0; i < fileArray.size(); i++) { + OnlineFile onlineFile = new OnlineFile(); + JSONObject fileJson = fileArray.getJSONObject(i); + onlineFile.setBaseUrl(fileJson.getString("baseUrl")); + onlineFile.setDownloadUri(fileJson.getString("downloadUri")); + onlineFile.setFileName(fileJson.getString("filename")); + onlineFile.setUploadPath(fileJson.getString("uploadPath")); + onlineFile.setName(fileJson.getString("name")); + onlineFile.setUrl(fileJson.getString("url")); + onlineFile.setCreateUserId(TokenData.takeFromRequest().getUserId()); + onlineFile.setUpdateUserId(TokenData.takeFromRequest().getUserId()); + fileList.add(onlineFile); + } + } + // 全量替换:先删旧再插入,空数组即清空。 + onlineFileService.replaceFileList(table.getTableId(), columnName, businessId, fileList); + // 业务表字段仅作为查询回填载体,不落库,统一置NULL。 + data.put(columnName, null); + } + } + + /** + * 查询结果回填:将附件上传/图片上传字段对应的 zz_online_file 数据组装成文件JSON数组回填到字段上。 + *

统一返回文件JSON数组的字符串形式:历史数据业务列直接存JSON字符串,新数据从 zz_online_file 组装后也转成字符串, + * 与保存侧 parseOnlineFileList 的字符串入参契约一致,前端无需区分新旧数据。

+ * + * @param resultList 查询结果列表。 + * @param table 在线数据表对象。 + */ + @Override + public void fillFileColumn(List> resultList, OnlineTable table) { + if (CollUtil.isEmpty(resultList)) { + return; + } + List fileColumnList = table.getColumnMap().values().stream() + .filter(c -> FieldKind.isFileKind(c.getFieldKind())) + .collect(Collectors.toList()); + if (CollUtil.isEmpty(fileColumnList)) { + return; + } + OnlineColumn primaryKeyColumn = table.getPrimaryKeyColumn(); + if (primaryKeyColumn == null) { + return; + } + List businessIdList = resultList.stream() + .map(r -> r.get(primaryKeyColumn.getColumnName())) + .filter(ObjectUtil::isNotEmpty) + .map(v -> this.convertToLongId(v.toString())) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + for (OnlineColumn column : fileColumnList) { + Map> fileMap = + onlineFileService.getOnlineFileMapByBusinessIds(table.getTableId(), column.getColumnName(), businessIdList); + if (MapUtil.isEmpty(fileMap)) { + continue; + } + for (Map result : resultList) { + Object idValue = result.get(primaryKeyColumn.getColumnName()); + if (ObjectUtil.isEmpty(idValue)) { + continue; + } + Long businessId = this.convertToLongId(idValue.toString()); + List fileList = fileMap.get(businessId); + if (CollUtil.isEmpty(fileList)) { + continue; + } + JSONArray fileArray = new JSONArray(); + for (OnlineFile onlineFile : fileList) { + JSONObject fileJson = new JSONObject(); + fileJson.put("baseUrl", onlineFile.getBaseUrl()); + fileJson.put("downloadUri", onlineFile.getDownloadUri()); + fileJson.put("filename", onlineFile.getFileName()); + fileJson.put("uploadPath", onlineFile.getUploadPath()); + fileJson.put("name", onlineFile.getName()); + fileJson.put("url", onlineFile.getUrl()); + fileJson.put("uploadFailed", false); + fileArray.add(fileJson); + } + // 统一返回文件JSON字符串,与历史数据(业务列直接存JSON字符串)及保存侧(parseOnlineFileList按字符串解析)契约一致。 + result.put(column.getColumnName(), fileArray.toJSONString()); + } + } + } + + /** + * 将文件字段的值在insert/update的值列表中统一置NULL,避免文件JSON写入业务表。 + *

columnValueList与columnDataList对齐(均跳过自增列)。

+ */ + private void nullifyFileColumnValues(List columnDataList, List columnValueList) { + int valueIndex = 0; + for (ColumnData columnData : columnDataList) { + if (BooleanUtil.isTrue(columnData.getColumn().getAutoIncrement())) { + continue; + } + if (FieldKind.isFileKind(columnData.getColumn().getFieldKind())) { + columnData.setColumnValue(null); + columnValueList.set(valueIndex, null); + } + valueIndex++; + } + } + + private Long convertToLongId(String dataId) { + if (StrUtil.isBlank(dataId)) { + return null; + } + try { + return Long.valueOf(dataId); + } catch (NumberFormatException e) { + return null; + } + } + private void makeupColumnValue(ColumnData columnData) { if (BooleanUtil.isTrue(columnData.getColumn().getAutoIncrement())) { return; diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlinePageServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlinePageServiceImpl.java index a27f08b..8cdb125 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlinePageServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlinePageServiceImpl.java @@ -265,6 +265,10 @@ public class OnlinePageServiceImpl extends BaseService impleme @Override public boolean existByPageCode(String pageCode) { + // pageCode 为空时不参与判重,避免把 page_code 过滤条件丢掉导致全表命中 + if (StrUtil.isBlank(pageCode)) { + return false; + } OnlinePage filter = new OnlinePage(); filter.setPageCode(pageCode); return CollUtil.isNotEmpty(this.getOnlinePageList(filter, null)); diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineQuickFormFieldServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineQuickFormFieldServiceImpl.java new file mode 100644 index 0000000..a76c283 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineQuickFormFieldServiceImpl.java @@ -0,0 +1,35 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.online.dao.OnlineQuickFormFieldMapper; +import apelet.common.online.model.OnlineQuickFormField; +import apelet.common.online.service.OnlineQuickFormFieldService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 在线快捷表单字段数据操作服务类。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +@Slf4j +@MyDataSourceResolver( + resolver = DefaultDataSourceResolver.class, + intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineQuickFormFieldService") +public class OnlineQuickFormFieldServiceImpl extends BaseService implements OnlineQuickFormFieldService { + + @Autowired + private OnlineQuickFormFieldMapper onlineQuickFormFieldMapper; + + @Override + protected BaseDaoMapper mapper() { + return onlineQuickFormFieldMapper; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineQuickFormServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineQuickFormServiceImpl.java new file mode 100644 index 0000000..f7ca24d --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineQuickFormServiceImpl.java @@ -0,0 +1,81 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.MyRelationParam; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.online.dao.OnlineQuickFormMapper; +import apelet.common.online.model.OnlineQuickForm; +import apelet.common.online.model.constant.FormType; +import apelet.common.online.service.OnlineQuickFormService; +import com.github.pagehelper.Page; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 在线快捷表单数据操作服务类。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +@Slf4j +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineQuickFormService") +public class OnlineQuickFormServiceImpl extends BaseService implements OnlineQuickFormService { + + @Autowired + private OnlineQuickFormMapper onlineQuickFormMapper; + + @Override + protected BaseDaoMapper mapper() { + return onlineQuickFormMapper; + } + + /** + * 获取单表查询结果。由于没有关联数据查询,因此在仅仅获取单表数据的场景下,效率更高。 + * 如果需要同时获取关联数据,请移步(getOnlineFormListWithRelation)方法。 + * + * @param filter 过滤对象。 + * @param orderBy 排序参数。 + * @return 查询结果集。 + */ + @Override + public List getOnlineFormList(OnlineQuickForm filter, String orderBy) { + if (filter == null) { + filter = new OnlineQuickForm(); + } + TokenData tokenData = TokenData.takeFromRequest(); + filter.setTenantId(tokenData.getTenantId()); + filter.setAppCode(tokenData.getAppCode()); + List resultList = onlineQuickFormMapper.getOnlineFormList(filter, orderBy); + // 快捷表单固定为快捷表单类型,回填用于 formTypeDictMap 关联展示 + resultList.forEach(qf -> qf.setFormType(FormType.QUICK_FORM)); + return resultList; + } + + /** + * 获取主表的查询结果,以及主表关联的字典数据和一对一从表数据,以及一对一从表的字典数据。 + * 该查询会涉及到一对一从表的关联过滤,或一对多从表的嵌套关联过滤,因此性能不如单表过滤。 + * 如果仅仅需要获取主表数据,请移步(getOnlineFormList),以便获取更好的查询性能。 + * + * @param filter 主表过滤对象。 + * @param orderBy 排序参数。 + * @return 查询结果集。 + */ + @Override + public List getOnlineFormListWithRelation(OnlineQuickForm filter, String orderBy) { + List resultList = this.getOnlineFormList(filter, orderBy); + // 在缺省生成的代码中,如果查询结果resultList不是Page对象,说明没有分页,那么就很可能是数据导出接口调用了当前方法。 + // 为了避免一次性的大量数据关联,规避因此而造成的系统运行性能冲击,这里手动进行了分批次读取,开发者可按需修改该值。 + int batchSize = resultList instanceof Page ? 0 : 1000; + // 用 full() 加载 @RelationOneToMany 的 onlineQuickFormFields 等全部关联 + this.buildRelationForDataList(resultList, MyRelationParam.full(), batchSize); + return resultList; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermCodeMapper.java b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermCodeMapper.java new file mode 100644 index 0000000..fbf01b9 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermCodeMapper.java @@ -0,0 +1,29 @@ +package apelet.common.online.upms.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.upms.model.SysPermCode; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; + +/** + * 权限字数据访问操作接口。 + *

在线表单发布自动生成权限字使用,精简自 tenant-admin 的同名 Mapper。为避免与 tenant-admin 同名 Mapper + * 的 bean 名冲突,采用 Online 前缀命名。

+ * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlineSysPermCodeMapper extends BaseDaoMapper { + + /** + * 按主键Id集合物理删除权限字。 + *

xy_sys_perm_code 上有 (perm_code, deleted_flag) 唯一索引,逻辑删除只是把 deleted_flag 置为 -1, + * 同一表单重复发布时第二次清理会与已存在的 -1 行冲突报 Duplicate entry。发布时自动生成的权限字属于派生数据, + * 无需保留删除痕迹,因此走物理删除。自定义 SQL 不受实体上 {@code @TableLogic} 的影响。

+ * + * @param permCodeIdList 权限字主键Id集合,不能为空。 + * @return 删除行数。 + */ + int physicalDeleteByIdList(@Param("permCodeIdList") Collection permCodeIdList); +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermCodePermMapper.java b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermCodePermMapper.java new file mode 100644 index 0000000..aeb7736 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermCodePermMapper.java @@ -0,0 +1,15 @@ +package apelet.common.online.upms.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.upms.model.SysPermCodePerm; + +/** + * 权限字与权限资源关系数据访问操作接口。 + *

在线表单发布自动生成权限字使用,精简自 tenant-admin 的同名 Mapper。为避免与 tenant-admin 同名 Mapper + * 的 bean 名冲突,采用 Online 前缀命名。

+ * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlineSysPermCodePermMapper extends BaseDaoMapper { +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermMapper.java b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermMapper.java new file mode 100644 index 0000000..0c04505 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermMapper.java @@ -0,0 +1,27 @@ +package apelet.common.online.upms.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.upms.model.SysPerm; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; + +/** + * 权限资源数据访问操作接口。 + *

在线表单发布自动生成权限字使用,精简自 tenant-admin 的同名 Mapper。为避免与 tenant-admin 同名 Mapper + * 的 bean 名冲突,采用 Online 前缀命名。

+ * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlineSysPermMapper extends BaseDaoMapper { + + /** + * 按主键Id集合物理删除权限资源。 + *

与权限字保持一致:发布时自动生成的权限资源属于派生数据,逻辑删除只会不断堆积 deleted_flag = -1 的垃圾行。

+ * + * @param permIdList 权限资源主键Id集合,不能为空。 + * @return 删除行数。 + */ + int physicalDeleteByIdList(@Param("permIdList") Collection permIdList); +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermModuleMapper.java b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermModuleMapper.java new file mode 100644 index 0000000..4264c63 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/dao/OnlineSysPermModuleMapper.java @@ -0,0 +1,27 @@ +package apelet.common.online.upms.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.upms.model.SysPermModule; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; + +/** + * 权限模块数据访问操作接口。 + *

在线表单发布自动生成权限字使用,精简自 tenant-admin 的同名 Mapper。为避免与 tenant-admin 同名 Mapper + * 的 bean 名冲突,采用 Online 前缀命名。

+ * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlineSysPermModuleMapper extends BaseDaoMapper { + + /** + * 按主键Id集合物理删除权限模块。 + *

与权限字保持一致:发布时自动生成的权限模块属于派生数据,逻辑删除只会不断堆积 deleted_flag = -1 的垃圾行。

+ * + * @param moduleIdList 权限模块主键Id集合,不能为空。 + * @return 删除行数。 + */ + int physicalDeleteByIdList(@Param("moduleIdList") Collection moduleIdList); +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/model/SysPerm.java b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPerm.java new file mode 100644 index 0000000..c77b5e6 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPerm.java @@ -0,0 +1,55 @@ +package apelet.common.online.upms.model; + +import apelet.common.core.base.model.BaseModel; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 权限资源实体对象。 + *

在线表单发布自动生成权限字使用,仅保留数据库字段,精简自 tenant-admin 的同名实体。

+ * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "xy_sys_perm") +public class SysPerm extends BaseModel { + + /** + * 权限Id。 + */ + @TableId(value = "perm_id") + private Long permId; + + /** + * 权限所在的权限模块Id。 + */ + @TableField(value = "module_id") + private Long moduleId; + + /** + * 权限名称。 + */ + @TableField(value = "perm_name") + private String permName; + + /** + * 关联的URL。 + */ + private String url; + + /** + * 权限在当前模块下的顺序,由小到大。 + */ + @TableField(value = "show_order") + private Integer showOrder; + + /** + * 逻辑删除标记字段(1: 正常 -1: 已删除)。 + */ + @TableLogic + @TableField(value = "deleted_flag") + private Integer deletedFlag; +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermCode.java b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermCode.java new file mode 100644 index 0000000..62ebf65 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermCode.java @@ -0,0 +1,62 @@ +package apelet.common.online.upms.model; + +import apelet.common.core.base.model.BaseModel; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 权限字实体对象。 + *

在线表单发布自动生成权限字使用,仅保留数据库字段,精简自 tenant-admin 的同名实体。

+ * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "xy_sys_perm_code") +public class SysPermCode extends BaseModel { + + /** + * 主键Id。 + */ + @TableId(value = "perm_code_id") + private Long permCodeId; + + /** + * 上级权限字Id。 + */ + @TableField(value = "parent_id") + private Long parentId; + + /** + * 权限字标识(一般为有含义的英文字符串)。 + */ + @TableField(value = "perm_code") + private String permCode; + + /** + * 权限类型(0: 表单 1: UI片段 2: 操作)。 + */ + @TableField(value = "perm_code_type") + private Integer permCodeType; + + /** + * 显示名称。 + */ + @TableField(value = "show_name") + private String showName; + + /** + * 显示顺序(数值越小,越靠前)。 + */ + @TableField(value = "show_order") + private Integer showOrder; + + /** + * 逻辑删除标记字段(1: 正常 -1: 已删除)。 + */ + @TableLogic + @TableField(value = "deleted_flag") + private Integer deletedFlag; +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermCodePerm.java b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermCodePerm.java new file mode 100644 index 0000000..b1fe2e4 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermCodePerm.java @@ -0,0 +1,29 @@ +package apelet.common.online.upms.model; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * 权限字与权限资源关联实体对象。 + *

在线表单发布自动生成权限字使用,精简自 tenant-admin 的同名实体。

+ * + * @author guifc + * @date 2023-08-04 + */ +@Data +@TableName(value = "xy_sys_perm_code_perm") +public class SysPermCodePerm { + + /** + * 权限字Id。 + */ + @TableField(value = "perm_code_id") + private Long permCodeId; + + /** + * 权限Id。 + */ + @TableField(value = "perm_id") + private Long permId; +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermModule.java b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermModule.java new file mode 100644 index 0000000..b60e159 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/model/SysPermModule.java @@ -0,0 +1,56 @@ +package apelet.common.online.upms.model; + +import apelet.common.core.base.model.BaseModel; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 权限模块实体对象。 + *

在线表单发布自动生成权限字使用,仅保留数据库字段,精简自 tenant-admin 的同名实体。

+ * + * @author guifc + * @date 2023-08-04 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "xy_sys_perm_module") +public class SysPermModule extends BaseModel { + + /** + * 权限模块Id。 + */ + @TableId(value = "module_id") + private Long moduleId; + + /** + * 上级权限模块Id。 + */ + @TableField(value = "parent_id") + private Long parentId; + + /** + * 权限模块名称。 + */ + @TableField(value = "module_name") + private String moduleName; + + /** + * 权限模块类型(0: 普通模块 1: Controller模块)。 + */ + @TableField(value = "module_type") + private Integer moduleType; + + /** + * 权限模块在当前层级下的顺序,由小到大。 + */ + @TableField(value = "show_order") + private Integer showOrder; + + /** + * 逻辑删除标记字段(1: 正常 -1: 已删除)。 + */ + @TableLogic + @TableField(value = "deleted_flag") + private Integer deletedFlag; +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/service/OnlinePermCodeService.java b/common/common-online/src/main/java/apelet/common/online/upms/service/OnlinePermCodeService.java new file mode 100644 index 0000000..4003aa6 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/service/OnlinePermCodeService.java @@ -0,0 +1,26 @@ +package apelet.common.online.upms.service; + +/** + * 在线表单权限字生成/清理服务接口。 + *

在线表单发布后自动生成权限字,删除时同步清理。实现类中的方法均为异步执行。

+ * + * @author guifc + * @date 2023-08-04 + */ +public interface OnlinePermCodeService { + + /** + * 在线表单发布后,异步生成权限字相关数据(幂等:先生成前先按 pageCode 清理旧数据再重建)。 + * + * @param pageId 页面主键Id。 + * @param userId 操作者Id。异步线程中没有请求上下文,因此由调用方传入,用于填充创建/更新人。 + */ + void genPermCodeByPageId(Long pageId, Long userId); + + /** + * 删除在线表单时,异步清理自动生成的权限字树及其关联的权限资源、权限模块。 + * + * @param pageCode 页面编码。 + */ + void removePermCodeByPageCode(String pageCode); +} diff --git a/common/common-online/src/main/java/apelet/common/online/upms/service/impl/OnlinePermCodeServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/upms/service/impl/OnlinePermCodeServiceImpl.java new file mode 100644 index 0000000..2896e2f --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/upms/service/impl/OnlinePermCodeServiceImpl.java @@ -0,0 +1,334 @@ +package apelet.common.online.upms.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.model.BaseModel; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.constant.GlobalDeletedFlag; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.generator.dao.OnlFormHeadMapper; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.online.dao.OnlinePageMapper; +import apelet.common.online.dao.OnlineTableMapper; +import apelet.common.online.model.OnlinePage; +import apelet.common.online.model.OnlineTable; +import apelet.common.online.upms.dao.OnlineSysPermCodeMapper; +import apelet.common.online.upms.dao.OnlineSysPermCodePermMapper; +import apelet.common.online.upms.dao.OnlineSysPermMapper; +import apelet.common.online.upms.dao.OnlineSysPermModuleMapper; +import apelet.common.online.upms.model.SysPerm; +import apelet.common.online.upms.model.SysPermCode; +import apelet.common.online.upms.model.SysPermCodePerm; +import apelet.common.online.upms.model.SysPermModule; +import apelet.common.online.upms.service.OnlinePermCodeService; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.annotation.PreDestroy; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 在线表单权限字生成/清理服务实现类。 + *

在线表单发布后自动生成权限字,删除时同步清理。生成与清理均为异步执行,内部自行捕获异常并记录日志。

+ * + * @author guifc + * @date 2023-08-04 + */ +@Slf4j +@MyDataSourceResolver( + resolver = DefaultDataSourceResolver.class, + intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlinePermCodeService") +public class OnlinePermCodeServiceImpl implements OnlinePermCodeService { + + /** 主表字段下载文件接口路径后缀。 */ + private static final String DOWNLOAD_DATASOURCE = "/onlineOperation/downloadDatasource"; + /** 数据源主表字段上传文件接口路径后缀。 */ + private static final String UPLOAD_DATASOURCE = "/onlineOperation/uploadDatasource"; + /** 数据源一对多关联的从表字段下载文件接口路径后缀。 */ + private static final String DOWNLOAD_ONE_TO_MANY = "/onlineOperation/downloadOneToManyRelation"; + /** 数据源一对多关联的从表字段上传文件接口路径后缀。 */ + private static final String UPLOAD_ONE_TO_MANY = "/onlineOperation/uploadOneToManyRelation"; + + @Value("${common-online.urlPrefix:}") + private String urlPrefix; + + /** + * 权限字生成/清理专用线程池,不依赖Spring异步体系,避免影响应用原有线程池配置。 + */ + private final ExecutorService permCodeExecutor = Executors.newFixedThreadPool(2, r -> { + Thread thread = new Thread(r); + thread.setName("perm-code-" + thread.getId()); + thread.setDaemon(true); + return thread; + }); + + @Autowired + private OnlinePageMapper onlinePageMapper; + @Autowired + private OnlFormHeadMapper onlFormHeadMapper; + @Autowired + private OnlineTableMapper onlineTableMapper; + @Autowired + private OnlineSysPermModuleMapper onlineSysPermModuleMapper; + @Autowired + private OnlineSysPermMapper onlineSysPermMapper; + @Autowired + private OnlineSysPermCodeMapper onlineSysPermCodeMapper; + @Autowired + private OnlineSysPermCodePermMapper onlineSysPermCodePermMapper; + @Autowired + private IdGeneratorWrapper idGenerator; + + /** + * 发布后异步生成权限字,先生成前先清理旧数据保证幂等。 + */ + @Override + public void genPermCodeByPageId(Long pageId, Long userId) { + permCodeExecutor.execute(() -> { + // 异步线程没有请求上下文,需显式指定在线表单数据源。 + Integer originalType = DefaultDataSourceResolver.setDataSourceType(ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE); + try { + this.doGenPermCode(pageId, userId); + } catch (Exception e) { + log.error("在线表单发布生成权限字失败,pageId={}", pageId, e); + } finally { + DefaultDataSourceResolver.unset(originalType); + } + }); + } + + /** + * 删除表单后异步清理权限字相关数据。 + */ + @Override + public void removePermCodeByPageCode(String pageCode) { + permCodeExecutor.execute(() -> { + // 异步线程没有请求上下文,需显式指定在线表单数据源。 + Integer originalType = DefaultDataSourceResolver.setDataSourceType(ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE); + try { + this.doRemovePermCode(pageCode); + } catch (Exception e) { + log.error("在线表单删除清理权限字失败,pageCode={}", pageCode, e); + } finally { + DefaultDataSourceResolver.unset(originalType); + } + }); + } + + /** + * 应用关闭时释放线程池。 + */ + @PreDestroy + public void shutdownExecutor() { + permCodeExecutor.shutdown(); + } + + private void doGenPermCode(Long pageId, Long userId) { + OnlinePage page = onlinePageMapper.selectById(pageId); + if (page == null || StrUtil.isBlank(page.getPageCode())) { + log.warn("生成权限字失败,页面不存在或页面编码为空,pageId={}", pageId); + return; + } + String pageCode = page.getPageCode(); + String pageName = StrUtil.nullToEmpty(page.getPageName()); + // 先清理旧数据,保证幂等。 + this.doRemovePermCode(pageCode); + // 主表。 + OnlFormHead mainHead = onlFormHeadMapper.selectById(page.getMasterTableId()); + if (mainHead == null || StrUtil.isBlank(mainHead.getTableName())) { + log.warn("生成权限字失败,找不到主表对应的 onl_form_head,pageId={}, masterTableId={}", + pageId, page.getMasterTableId()); + return; + } + // 附表:sort 取主表 table_name。 + QueryWrapper slaveWrapper = new QueryWrapper<>(); + slaveWrapper.eq("sort", mainHead.getTableName()); + List headList = new ArrayList<>(); + headList.add(mainHead); + headList.addAll(onlFormHeadMapper.selectList(slaveWrapper)); + // 组装 map:onl_form_head.table_name -> zz_online_table.model_name。 + Map modelNameMap = new HashMap<>(); + for (OnlFormHead head : headList) { + if (StrUtil.isBlank(head.getTableName())) { + continue; + } + OnlineTable onlineTable = this.getOnlineTableByTableName(head.getTableName()); + if (onlineTable != null && StrUtil.isNotBlank(onlineTable.getModelName())) { + modelNameMap.put(head.getTableName(), onlineTable.getModelName()); + } + } + String mainModelName = modelNameMap.get(mainHead.getTableName()); + if (StrUtil.isBlank(mainModelName)) { + log.warn("生成权限字失败,主表 {} 未匹配到 zz_online_table", mainHead.getTableName()); + return; + } + Date now = new Date(); + Long operatorUserId = userId != null ? userId : 0L; + // 权限模块:moduleType=0 根模块 + moduleType=1 子模块,所有权限挂在子模块下。 + SysPermModule rootModule = this.buildModule(0L, 0, pageName, 1, operatorUserId, now); + onlineSysPermModuleMapper.insert(rootModule); + SysPermModule childModule = this.buildModule(rootModule.getModuleId(), 1, pageName, 1, operatorUserId, now); + onlineSysPermModuleMapper.insert(childModule); + // 权限字树:type=0 根 -> type=1 片段 -> type=2 操作。 + SysPermCode code0 = this.buildPermCode(0L, pageCode, 0, pageName, 1, operatorUserId, now); + onlineSysPermCodeMapper.insert(code0); + String code1Str = pageCode + ":" + pageCode; + SysPermCode code1 = this.buildPermCode(code0.getPermCodeId(), code1Str, 1, pageName, 1, operatorUserId, now); + onlineSysPermCodeMapper.insert(code1); + // 主表生成下载/上传两条 perm、两条操作权限字及其关联;分录表存在时才生成一套(取第一条),不存在则不生成。 + List permHeadList = new ArrayList<>(); + permHeadList.add(mainHead); + if (headList.size() > 1) { + permHeadList.add(headList.get(1)); + } + for (OnlFormHead head : permHeadList) { + if (StrUtil.isBlank(head.getTableName())) { + continue; + } + String modelName = modelNameMap.get(head.getTableName()); + if (StrUtil.isBlank(modelName)) { + continue; + } + boolean isMain = head == mainHead; + // perm_name:主表 = table_txt + 下载/上传;附表 = 主表table_txt + 附表table_txt + 下载/上传。 + String permNameBase = isMain ? StrUtil.nullToEmpty(head.getTableTxt()) + : StrUtil.nullToEmpty(mainHead.getTableTxt()) + StrUtil.nullToEmpty(head.getTableTxt()); + // 上传下载 URL 统一使用主表 model_name,仅路径区分主表/附表。 + String downloadUrl = this.makeUrl(isMain ? DOWNLOAD_DATASOURCE : DOWNLOAD_ONE_TO_MANY, mainModelName); + String uploadUrl = this.makeUrl(isMain ? UPLOAD_DATASOURCE : UPLOAD_ONE_TO_MANY, mainModelName); + String code2Base = code1Str + ":" + modelName; + SysPerm downloadPerm = this.buildPerm(childModule.getModuleId(), permNameBase + "下载文件", downloadUrl, 1, operatorUserId, now); + onlineSysPermMapper.insert(downloadPerm); + SysPerm uploadPerm = this.buildPerm(childModule.getModuleId(), permNameBase + "上传文件", uploadUrl, 2, operatorUserId, now); + onlineSysPermMapper.insert(uploadPerm); + SysPermCode code2Download = this.buildPermCode(code1.getPermCodeId(), code2Base + "download", 2, permNameBase + "下载文件", 1, operatorUserId, now); + onlineSysPermCodeMapper.insert(code2Download); + SysPermCode code2Upload = this.buildPermCode(code1.getPermCodeId(), code2Base + "upload", 2, permNameBase + "上传文件", 2, operatorUserId, now); + onlineSysPermCodeMapper.insert(code2Upload); + this.insertPermCodePerm(code2Download.getPermCodeId(), downloadPerm.getPermId()); + this.insertPermCodePerm(code2Upload.getPermCodeId(), uploadPerm.getPermId()); + } + log.info("在线表单发布生成权限字完成,pageId={}, pageCode={}, 表数={}", pageId, pageCode, headList.size()); + } + + /** + * 按 pageCode 清理权限字树及其关联的权限资源、权限模块。 + */ + private void doRemovePermCode(String pageCode) { + if (StrUtil.isBlank(pageCode)) { + return; + } + SysPermCode root = onlineSysPermCodeMapper.selectOne(new QueryWrapper().eq("perm_code", pageCode).eq("perm_code_type", 0)); + if (root == null) { + return; + } + // 收集整棵权限字树(type=0 -> 1 -> 2)。 + List codeIdList = new ArrayList<>(); + codeIdList.add(root.getPermCodeId()); + Set codeIdSet = new HashSet<>(codeIdList); + List parentIdList = new ArrayList<>(codeIdList); + while (CollUtil.isNotEmpty(parentIdList)) { + List childList = onlineSysPermCodeMapper.selectList(new QueryWrapper().in("parent_id", parentIdList)); + parentIdList = childList.stream().map(SysPermCode::getPermCodeId).collect(Collectors.toList()); + codeIdSet.addAll(parentIdList); + } + // 权限字与权限关联、权限资源及其模块。注意 SysPermCode/SysPerm/SysPermModule 都带 @TableLogic, + // mapper.delete 只会把 deleted_flag 置为 -1,而 xy_sys_perm_code 上有 (perm_code, deleted_flag) 唯一索引, + // 同一表单重复发布时第二次清理必然撞 Duplicate entry,因此这里统一走物理删除。 + List relationList = onlineSysPermCodePermMapper.selectList(new QueryWrapper().in("perm_code_id", codeIdSet)); + if (CollUtil.isNotEmpty(relationList)) { + onlineSysPermCodePermMapper.delete(new QueryWrapper().in("perm_code_id", codeIdSet)); + } + Set permIdSet = relationList.stream().map(SysPermCodePerm::getPermId).collect(Collectors.toSet()); + Set moduleIdSet = new HashSet<>(); + if (CollUtil.isNotEmpty(permIdSet)) { + List permList = onlineSysPermMapper.selectList(new QueryWrapper().in("perm_id", permIdSet)); + moduleIdSet = permList.stream().map(SysPerm::getModuleId).collect(Collectors.toSet()); + onlineSysPermMapper.physicalDeleteByIdList(permIdSet); + } + onlineSysPermCodeMapper.physicalDeleteByIdList(codeIdSet); + // 删除权限模块(子模块及其上级根模块)。 + if (CollUtil.isNotEmpty(moduleIdSet)) { + List moduleList = onlineSysPermModuleMapper.selectList(new QueryWrapper().in("module_id", moduleIdSet)); + moduleIdSet = moduleList.stream().flatMap(m -> Stream.of(m.getModuleId(), m.getParentId())).filter(Objects::nonNull).filter(id -> id != 0).collect(Collectors.toSet()); + if (CollUtil.isNotEmpty(moduleIdSet)) { + onlineSysPermModuleMapper.physicalDeleteByIdList(moduleIdSet); + } + } + log.info("在线表单删除清理权限字完成,pageCode={}, 权限字数={}", pageCode, codeIdSet.size()); + } + + private OnlineTable getOnlineTableByTableName(String tableName) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("table_name", tableName); + return onlineTableMapper.selectOne(wrapper); + } + + private String makeUrl(String path, String modelName) { + return urlPrefix + path + "/" + modelName; + } + + private SysPermModule buildModule(Long parentId, Integer moduleType, String moduleName, + Integer showOrder, Long userId, Date now) { + SysPermModule module = new SysPermModule(); + module.setModuleId(idGenerator.nextLongId()); + module.setParentId(parentId); + module.setModuleName(moduleName); + module.setModuleType(moduleType); + module.setShowOrder(showOrder); + module.setDeletedFlag(GlobalDeletedFlag.NORMAL); + this.fillCommonFields(module, userId, now); + return module; + } + + private SysPerm buildPerm(Long moduleId, String permName, String url, + Integer showOrder, Long userId, Date now) { + SysPerm perm = new SysPerm(); + perm.setPermId(idGenerator.nextLongId()); + perm.setModuleId(moduleId); + perm.setPermName(permName); + perm.setUrl(url); + perm.setShowOrder(showOrder); + perm.setDeletedFlag(GlobalDeletedFlag.NORMAL); + this.fillCommonFields(perm, userId, now); + return perm; + } + + private SysPermCode buildPermCode(Long parentId, String permCode, Integer permCodeType, String showName, + Integer showOrder, Long userId, Date now) { + SysPermCode code = new SysPermCode(); + code.setPermCodeId(idGenerator.nextLongId()); + code.setParentId(parentId); + code.setPermCode(permCode); + code.setPermCodeType(permCodeType); + code.setShowName(showName); + code.setShowOrder(showOrder); + code.setDeletedFlag(GlobalDeletedFlag.NORMAL); + this.fillCommonFields(code, userId, now); + return code; + } + + private void fillCommonFields(BaseModel entity, Long userId, Date now) { + entity.setCreateUserId(userId); + entity.setUpdateUserId(userId); + entity.setCreateTime(now); + entity.setUpdateTime(now); + } + + private void insertPermCodePerm(Long permCodeId, Long permId) { + SysPermCodePerm permCodePerm = new SysPermCodePerm(); + permCodePerm.setPermCodeId(permCodeId); + permCodePerm.setPermId(permId); + onlineSysPermCodePermMapper.insert(permCodePerm); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/util/Jexl3Util.java b/common/common-online/src/main/java/apelet/common/online/util/Jexl3Util.java index a8306eb..3613ef6 100644 --- a/common/common-online/src/main/java/apelet/common/online/util/Jexl3Util.java +++ b/common/common-online/src/main/java/apelet/common/online/util/Jexl3Util.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.math.BigDecimal; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -46,8 +47,7 @@ public class Jexl3Util { // 创建Context对象,为表达式中的未知数赋值 JexlContext context = new MapContext(); if (paramMap != null) { - paramMap.forEach((key, value) -> - context.set(key.toLowerCase(), value instanceof String ? ((String) value).toLowerCase() : value) + paramMap.forEach((key, value) -> context.set(key.toLowerCase(), normalize(value)) ); } // 使用表达式引擎创建表达式对象 @@ -59,6 +59,27 @@ public class Jexl3Util { return evaluate; } + + /** + * 数值参数归一:整型转 Double 避免 JEXL 整数除法截断(如 21/2=10 应为 10.5); + * BigDecimal 保留精度(避免大数转 Double 丢精度);String 转小写与表达式归一一致;其余原样。 + */ + private Object normalize(Object value) { + if (value instanceof Integer) { + return ((Integer) value).doubleValue(); + } + if (value instanceof Long) { + return ((Long) value).doubleValue(); + } + if (value instanceof BigDecimal) { + return value; + } + if (value instanceof String) { + return ((String) value).toLowerCase(); + } + return value; + } + public Map getMapValue(JSONObject obj, ObjectValue objectValue) { HashMap map = new HashMap<>(); for (String tab : obj.keySet()) { diff --git a/common/common-online/src/main/java/apelet/common/online/util/OnlineOperationHelper.java b/common/common-online/src/main/java/apelet/common/online/util/OnlineOperationHelper.java index 3d8fd37..1482d4e 100644 --- a/common/common-online/src/main/java/apelet/common/online/util/OnlineOperationHelper.java +++ b/common/common-online/src/main/java/apelet/common/online/util/OnlineOperationHelper.java @@ -167,7 +167,8 @@ public class OnlineOperationHelper { return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage); } } else { - if (value == null && !column.getNullable() && StrUtil.isBlank(column.getEncodedRule())) { + if (value == null && !column.getNullable() && StrUtil.isBlank(column.getEncodedRule()) + && !FieldKind.isFileKind(column.getFieldKind())) { errorMessage = "数据验证失败,数据表 [" + table.getTableName() + "] 字段 [" + column.getColumnName() + "] 不能为空值!"; return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage); diff --git a/common/common-online/src/main/java/apelet/common/online/util/QuickFormWidgetUtil.java b/common/common-online/src/main/java/apelet/common/online/util/QuickFormWidgetUtil.java new file mode 100644 index 0000000..7ed153d --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/util/QuickFormWidgetUtil.java @@ -0,0 +1,584 @@ +package apelet.common.online.util; + +import apelet.common.online.model.OnlineQuickFormField; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 快捷表单 widgetJson 生成工具类。 + *

+ * 基于参考 json 模板(列表页/编辑页),根据快捷表单字段生成 pc/mobile 两套组件json。 + * 模板只提供结构外壳(operationList、tableWidget、listCard、leftWidget 等), + * 字段控件、绑定关系、按钮 formId 由字段数据重新生成。 + * + * @author chenchuchuan + * @date 2026-08-18 + */ +public class QuickFormWidgetUtil { + + private static final String LIST_TEMPLATE = "quick-form/quick-form-list.json"; + private static final String EDIT_TEMPLATE = "quick-form/quick-form-edit.json"; + + private static final JSONObject LIST_TEMPLATE_JSON = load(LIST_TEMPLATE); + private static final JSONObject EDIT_TEMPLATE_JSON = load(EDIT_TEMPLATE); + + private QuickFormWidgetUtil() { + } + + private static JSONObject load(String path) { + try (InputStream in = QuickFormWidgetUtil.class.getClassLoader().getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("快捷表单模板不存在: " + path); + } + return JSON.parseObject(new String(IoUtil.readBytes(in), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException("加载快捷表单模板失败: " + path, e); + } + } + + /** + * 生成编辑页 widgetJson(pc + mobile)。 + * + * @param fields 快捷表单字段 + * @param bindId 控件绑定的数据id(参考json中编辑页/列表页为同一个快捷表单id) + * @param masterTableId 关联主表id + * @param masterTableName 关联主表名 + * @return widgetJson 根对象 + */ + public static JSONObject buildEditWidgetJson(List fields, Long bindId, Long masterTableId, String masterTableName) { + JSONObject json = JSON.parseObject(EDIT_TEMPLATE_JSON.toJSONString()); + for (String device : new String[]{"pc", "mobile"}) { + JSONObject deviceObj = json.getJSONObject(device); + JSONArray widgetList = buildWidgetList(fields, device, bindId, masterTableId); + // 编辑页控件固定 supportOperation=false,与参考json一致 + for (int i = 0; i < widgetList.size(); i++) { + widgetList.getJSONObject(i).put("supportOperation", false); + } + deviceObj.put("widgetList", widgetList); + setSaveButtonEnabled(deviceObj); + rebuildMeta(deviceObj, fields, device, masterTableName, null); + } + return json; + } + + /** + * 生成列表页 widgetJson(pc + mobile)。 + *

+ * 列表页结构(文档补充逻辑): + *

    + *
  • tableWidget 恒存在:pc 的 widgetType=100 表格,列来自 isList 字段的 tableColumnList;mobile 的 widgetType=103 列表卡片只放 isList 字段;
  • + *
  • 查询区:pc 的 widgetList、mobile 的 tableWidget.childWidgetList(listCard 之后)只放 isFilter 的字段;
  • + *
  • 其余字段只进编辑页,不进列表页。
  • + *
+ * + * @param fields 快捷表单字段 + * @param listFormId 列表页 zz_online_form 主键(用于 tableWidget variableName) + * @param editFormId 编辑页 zz_online_form 主键(关联到新建/编辑按钮) + * @param bindId 控件绑定的数据id(参考json中编辑页/列表页为同一个快捷表单id) + * @param masterTableId 关联主表id + * @param masterTableName 关联主表名 + * @param columnIdMap 字段名(小写) → zz_online_column.column_id,用于 tableColumnList 回填 + * @return widgetJson 根对象 + */ + public static JSONObject buildListWidgetJson(List fields, Long listFormId, Long editFormId, Long bindId, Long masterTableId, String masterTableName, Map columnIdMap) { + JSONObject json = JSON.parseObject(LIST_TEMPLATE_JSON.toJSONString()); + String tableVar = "table" + listFormId; + List listFields = fields == null ? Collections.emptyList() : fields.stream().filter(QuickFormWidgetUtil::isListField).collect(Collectors.toList()); + List filterFields = fields == null ? Collections.emptyList() : fields.stream().filter(QuickFormWidgetUtil::isFilterField).collect(Collectors.toList()); + for (String device : new String[]{"pc", "mobile"}) { + JSONObject deviceObj = json.getJSONObject(device); + setButtonFormId(deviceObj, editFormId); + JSONObject tableWidget = deviceObj.getJSONObject("tableWidget"); + if (tableWidget != null) { + tableWidget.put("variableName", tableVar); + JSONObject bindData = tableWidget.getJSONObject("bindData"); + if (bindData == null) { + bindData = new JSONObject(); + tableWidget.put("bindData", bindData); + } + if ("pc".equals(device)) { + // 参考:pc tableWidget 才有 id/tableId,mobile 只有 defaultValue/dataType + bindData.put("id", bindId); + bindData.put("tableId", masterTableId); + } + } + if ("pc".equals(device)) { + deviceObj.put("widgetList", buildWidgetList(filterFields, device, bindId, masterTableId)); + if (tableWidget != null) { + JSONObject props = tableWidget.getJSONObject("props"); + if (props != null) { + props.put("tableColumnList", buildTableColumnList(listFields, masterTableId, columnIdMap)); + } + } + rebuildMeta(deviceObj, filterFields, device, masterTableName, tableVar); + } else { + rebuildMobileList(deviceObj, listFields, filterFields, bindId, listFormId, masterTableId); + rebuildMeta(deviceObj, mergeFields(listFields, filterFields), device, masterTableName, tableVar); + } + } + return json; + } + + /** + * 是否列表展示字段。 + */ + private static boolean isListField(OnlineQuickFormField field) { + return field != null && field.getIsList() != null && field.getIsList() == 1; + } + + /** + * 是否过滤字段:是过滤条件即进查询区;filterType 决定数据过滤方式,不影响控件展示。 + */ + private static boolean isFilterField(OnlineQuickFormField field) { + return field != null && field.getIsFilter() != null && field.getIsFilter() == 1; + } + + /** + * 合并列表展示字段与过滤字段(按字段名去重,列表展示优先)。 + */ + private static List mergeFields(List listFields, List filterFields) { + Map merged = new LinkedHashMap<>(); + if (listFields != null) { + for (OnlineQuickFormField field : listFields) { + merged.put(field.getFieldName(), field); + } + } + if (filterFields != null) { + for (OnlineQuickFormField field : filterFields) { + merged.putIfAbsent(field.getFieldName(), field); + } + } + return new ArrayList<>(merged.values()); + } + + /** + * 构建 pc 列表表格列(tableColumnList),仅含列表展示字段。 + */ + private static JSONArray buildTableColumnList(List listFields, Long masterTableId, Map columnIdMap) { + JSONArray columnList = new JSONArray(); + if (listFields == null) { + return columnList; + } + for (int i = 0; i < listFields.size(); i++) { + OnlineQuickFormField field = listFields.get(i); + String fieldName = field.getFieldName(); + Long columnId = columnIdMap == null ? null : columnIdMap.get(fieldName.toLowerCase()); + JSONObject column = new JSONObject(); + column.put("columnId", columnId == null ? "" : String.valueOf(columnId)); + column.put("tableId", String.valueOf(masterTableId)); + column.put("showName", StringUtils.isNotBlank(field.getFieldRemark()) ? field.getFieldRemark() : fieldName); + column.put("sortable", false); + // 列表表格列固定 fieldType=0,与参考json一致;按真实字段类型映射会导致日期等列无法展示 + column.put("fieldType", 0); + column.put("comType", 41); + column.put("switchClose", "false"); + column.put("selectCode", 1); + column.put("switchOpen", "true"); + column.put("showColumn", true); + column.put("showRequired", false); + column.put("F7Info", ""); + column.put("f7ShowName", StringUtils.isNotBlank(field.getDisplayField()) ? field.getDisplayField() : ""); + column.put("id", columnId == null ? "column_" + i : "column_" + columnId); + column.put("soleId", (columnId == null ? String.valueOf(i) : columnId + "") + i); + String showFieldName = fieldName; + if (StringUtils.isNotBlank(field.getDisplayField())) { + showFieldName = showFieldName + "." + field.getDisplayField(); + } + column.put("showFieldName", showFieldName); + columnList.add(column); + } + return columnList; + } + + /** + * 移动端过滤控件类型:模板专用类型映射,未映射的类型复用字段控件本身类型。 + */ + private static int resolveMobileFilterType(Integer fieldType) { + if (fieldType == null) { + return 502; + } + switch (fieldType) { + case 1: + return 502; // 文本 → 移动端文本过滤框 + case 7: + return 500; // 开关/单选 → 移动端字典过滤 + default: + return fieldType; // 下拉(10)、F7(402) 等保持原类型 + } + } + + /** + * 生成移动端过滤控件(查询区),放在列表卡片之后的独立过滤组件。 + */ + private static JSONObject buildMobileFilterWidget(OnlineQuickFormField field, Long formId, Long masterTableId) { + return buildFieldWidget(field, resolveMobileFilterType(field.getFieldType()), "mobile", formId, masterTableId); + } + + // ============ 字段控件生成 ============ + + private static JSONArray buildWidgetList(List fields, String device, Long formId, Long masterTableId) { + JSONArray widgetList = new JSONArray(); + if (fields == null) { + return widgetList; + } + for (OnlineQuickFormField field : fields) { + widgetList.add(buildFieldWidget(field, device, formId, masterTableId)); + } + return widgetList; + } + + private static JSONObject buildFieldWidget(OnlineQuickFormField field, String device, Long formId, Long masterTableId) { + return buildFieldWidget(field, resolveWidgetType(field.getFieldType(), device), device, formId, masterTableId); + } + + private static JSONObject buildFieldWidget(OnlineQuickFormField field, int wt, String device, Long formId, Long masterTableId) { + String varName = StrUtil.toCamelCase(field.getFieldName()); + String showName = StringUtils.isNotBlank(field.getFieldRemark()) ? field.getFieldRemark() : field.getFieldName(); + + JSONObject bindData = new JSONObject(); + JSONObject defaultValue = new JSONObject(); + defaultValue.put("valueType", 20); + bindData.put("defaultValue", defaultValue); + bindData.put("dataType", 0); + bindData.put("columnFieldName", field.getFieldName()); + bindData.put("tableId", masterTableId); + bindData.put("id", formId); + if (wt == 402 && field.getRefTableId() != null) { + bindData.put("slaveTableId", field.getRefTableId()); + if (StrUtil.isNotBlank(field.getRefTableName())) { + bindData.put("slaveTableName", field.getRefTableName()); + } + } + + JSONObject widget = new JSONObject(); + widget.put("widgetType", wt); + widget.put("bindData", bindData); + widget.put("showName", showName); + widget.put("variableName", varName); + widget.put("props", buildProps(wt, field, device)); + widget.put("eventList", new JSONArray()); + widget.put("childWidgetList", new JSONArray()); + widget.put("style", new JSONObject()); + if ("mobile".equals(device)) { + widget.put("supportOperation", false); + } + return widget; + } + + private static int resolveWidgetType(Integer fieldType, String device) { + int wt = fieldType == null ? 1 : fieldType; + if ("mobile".equals(device) && wt == 3) { + return 15; + } + return wt; + } + + /** + * 元数据(widgetListMapData)里的控件类型:移动端纯过滤字段用过滤控件类型,其余用字段控件类型。 + */ + private static int resolveMetaWidgetType(OnlineQuickFormField field, String device) { + if ("mobile".equals(device) && !isListField(field) && isFilterField(field)) { + return resolveMobileFilterType(field.getFieldType()); + } + return resolveWidgetType(field.getFieldType(), device); + } + + private static JSONObject buildProps(int wt, OnlineQuickFormField field, String device) { + int span = "mobile".equals(device) ? 24 : 12; + boolean required = field.getIsEmpty() != null && field.getIsEmpty() == 0; + JSONObject props = new JSONObject(); + props.put("span", span); + props.put("required", required); + props.put("disabled", false); + props.put("hide", false); + props.put("styleCssText", ""); + props.put("actions", new JSONObject()); + JSONObject dictInfo = new JSONObject(); + dictInfo.put("paramList", new JSONArray()); + if (field.getRefDict() != null && wt != 402) { + dictInfo.put("dictId", field.getRefDict()); + } + props.put("dictInfo", dictInfo); + if (StringUtils.isNotBlank(field.getDefaultValue())) { + props.put("placeholder", field.getDefaultValue()); + } + switch (wt) { + case 1: // 文本输入框 + props.put("type", "text"); + props.put("inputHight", 40); + props.put("textLineTb", false); + props.put("inputNumMin", 0); + props.put("inputNumMax", 0); + props.put("plarceColor", ""); + props.put("inputWidth", ""); + props.put("transparentTitle", 0); + props.put("inputWidthBlan", false); + props.put("colorTitle", ""); + props.put("colorInput", ""); + props.put("labelWight", 2); + props.put("inputHeight", 2); + if (!props.containsKey("placeholder")) props.put("placeholder", ""); + props.put("show-password", false); + props.put("show-word-limit", false); + props.put("inCopy", false); + props.put("inCopyImg", false); + props.put("readonly", false); + props.put("teartextMore", false); + props.put("borderBottom", false); + props.put("textRedBd", false); + props.put("readyModeHiddenEmpty", false); + props.put("tooltipText", ""); + break; + case 3: // 数字输入框 + props.put("step", 1); + props.put("controls", true); + if (!props.containsKey("placeholder")) props.put("placeholder", ""); + props.put("tooltipText", ""); + break; + case 15: // 移动端数字输入框 + props.put("labelWight", 2); + props.put("step", 1); + props.put("input-width", "32px"); + props.put("button-size", "28px"); + props.put("integer", false); + break; + case 7: // 开关/单选 + props.put("inputHight", 40); + props.put("inputWidth", ""); + props.put("transparentTitle", 0); + props.put("colorTitle", ""); + props.put("labelWight", 2); + props.put("direction", "horizontal"); + props.put("borderBottom", false); + props.put("icon-size", "20px"); + props.put("tooltipText", ""); + break; + case 10: // 下拉选择框 + if (!props.containsKey("placeholder")) props.put("placeholder", ""); + props.put("inputHight", "44"); + props.put("inputWidth", ""); + props.put("transparentTitle", 0); + props.put("colorTitle", ""); + props.put("colorInput", ""); + props.put("labelWight", 2); + props.put("borderBottom", false); + props.put("multiple", false); + props.put("fiterSaicn", false); + props.put("inputSeletUr", false); + props.put("readyModeHiddenEmpty", false); + props.put("tooltipText", ""); + break; + case 20: // 日期选择框 + if (!props.containsKey("placeholder")) props.put("placeholder", ""); + props.put("type", "date"); + props.put("tooltipText", ""); + break; + case 402: // 用户/关联选择 + props.put("inCopy", false); + props.put("plarceColor", ""); + props.put("transparentTitle", 0); + props.put("inputHight", 40); + props.put("inputWidth", ""); + if (!props.containsKey("placeholder")) props.put("placeholder", ""); + props.put("colorTitle", ""); + props.put("colorInput", ""); + props.put("wightInput", 0); + props.put("borderBottom", false); + props.put("fiterSaicn", false); + props.put("relativeTable", buildRelativeTable(field)); + props.put("isMultiple", false); + props.put("readyModeHiddenEmpty", false); + props.put("tooltipText", ""); + break; + default: + props.put("type", "text"); + props.put("inputHight", 40); + if (!props.containsKey("placeholder")) props.put("placeholder", ""); + break; + } + return props; + } + + private static JSONObject buildRelativeTable(OnlineQuickFormField field) { + JSONObject relativeTable = new JSONObject(); + relativeTable.put("relativeFormId", ""); + relativeTable.put("relativeTableName", StrUtil.isNotBlank(field.getRefTableName()) ? field.getRefTableName() : ""); + relativeTable.put("relativeColumn", "id"); + relativeTable.put("relationId", ""); + relativeTable.put("datasourceId", ""); + relativeTable.put("variableName", ""); + relativeTable.put("displayField", StringUtils.isNotBlank(field.getDisplayField()) ? field.getDisplayField() : "fname"); + relativeTable.put("FieldFormId", ""); + relativeTable.put("fieldAddSwich", ""); + relativeTable.put("relativeFormType", 2); + relativeTable.put("relativeFormCode", StrUtil.isNotBlank(field.getRefFormCode()) ? field.getRefFormCode() : "query_all_pm_user"); + relativeTable.put("onlineFormId", ""); + relativeTable.put("onlineFormName", ""); + return relativeTable; + } + + // ============ 移动端列表页结构 ============ + + private static void rebuildMobileList(JSONObject deviceObj, List listFields, List filterFields, Long bindId, Long listFormId, Long masterTableId) { + JSONObject tableWidget = deviceObj.getJSONObject("tableWidget"); + if (tableWidget == null) { + return; + } + JSONArray childList = tableWidget.getJSONArray("childWidgetList"); + if (childList == null) { + childList = new JSONArray(); + tableWidget.put("childWidgetList", childList); + } + // 找到 listCard(widgetType=303),保留 image(42) 子控件,其余字段重新生成 + JSONObject listCard = null; + for (int i = 0; i < childList.size(); i++) { + JSONObject child = childList.getJSONObject(i); + if (child != null && 303 == child.getIntValue("widgetType")) { + listCard = child; + break; + } + } + if (listCard == null) { + listCard = new JSONObject(); + listCard.put("widgetType", 303); + listCard.put("bindData", baseBindData()); + listCard.put("showName", "listCard"); + listCard.put("variableName", "listCard" + listFormId); + JSONObject props = new JSONObject(); + props.put("imagePosition", "left"); + props.put("imageAlign", "center"); + props.put("hide", false); + props.put("styleCssText", ""); + props.put("actions", new JSONObject()); + listCard.put("props", props); + listCard.put("eventList", new JSONArray()); + listCard.put("style", new JSONObject()); + listCard.put("supportOperation", false); + } + JSONArray cardChildren = listCard.getJSONArray("childWidgetList"); + if (cardChildren == null) { + cardChildren = new JSONArray(); + } + JSONArray newCardChildren = new JSONArray(); + for (int i = 0; i < cardChildren.size(); i++) { + JSONObject child = cardChildren.getJSONObject(i); + if (child != null && 42 == child.getIntValue("widgetType")) { + newCardChildren.add(child); + break; + } + } + if (listFields != null) { + for (OnlineQuickFormField field : listFields) { + newCardChildren.add(buildFieldWidget(field, "mobile", bindId, masterTableId)); + } + } + listCard.put("childWidgetList", newCardChildren); + // 列表卡片 + 过滤控件:过滤字段作为独立查询组件放在 listCard 之后 + JSONArray newChildList = new JSONArray(); + newChildList.add(listCard); + if (filterFields != null) { + for (OnlineQuickFormField field : filterFields) { + newChildList.add(buildMobileFilterWidget(field, bindId, masterTableId)); + } + } + tableWidget.put("childWidgetList", newChildList); + } + + private static JSONObject baseBindData() { + JSONObject defaultValue = new JSONObject(); + defaultValue.put("valueType", 20); + JSONObject bindData = new JSONObject(); + bindData.put("defaultValue", defaultValue); + bindData.put("dataType", 0); + return bindData; + } + + // ============ 按钮 formId ============ + + private static void setButtonFormId(JSONObject deviceObj, Long editFormId) { + JSONArray opList = deviceObj.getJSONArray("operationList"); + if (opList == null) { + return; + } + for (int i = 0; i < opList.size(); i++) { + JSONObject op = opList.getJSONObject(i); + Integer type = op.getInteger("type"); + if ((type != null && (type == 0 || type == 1)) && Boolean.TRUE.equals(op.getBoolean("enabled"))) { + op.put("formId", editFormId); + } + } + } + + private static void setSaveButtonEnabled(JSONObject deviceObj) { + JSONArray opList = deviceObj.getJSONArray("operationList"); + if (opList == null) { + return; + } + for (int i = 0; i < opList.size(); i++) { + JSONObject op = opList.getJSONObject(i); + if (30 == op.getIntValue("type")) { + op.put("enabled", true); + JSONArray pluginList = new JSONArray(); + JSONObject plugin = new JSONObject(); + plugin.put("name", "保存"); + plugin.put("class", "apelet.common.online.plugin.pre.SaveOperationServicePlugIn"); + plugin.put("order", "1"); + plugin.put("describe", "保存"); + plugin.put("isEnable", true); + plugin.put("_disabled", true); + plugin.put("_X_ROW_KEY", "row_" + i); + pluginList.add(plugin); + op.put("pluginList", pluginList); + } + } + } + + // ============ 元数据(mapData/keyNameObj) ============ + + private static void rebuildMeta(JSONObject deviceObj, List fields, String device, String masterTableName, String tableVar) { + JSONObject widgetListMapData = new JSONObject(); + if (fields != null) { + for (OnlineQuickFormField field : fields) { + String var = StrUtil.toCamelCase(field.getFieldName()); + JSONObject m = new JSONObject(); + m.put("widgetType", resolveMetaWidgetType(field, device)); + m.put("variableName", var); + widgetListMapData.put(var, m); + } + } + JSONObject otherWidgetListMapData = new JSONObject(); + if (StringUtils.isNotBlank(tableVar)) { + JSONObject tableMap = new JSONObject(); + tableMap.put("widgetType", 100); + tableMap.put("variableName", tableVar); + widgetListMapData.put(tableVar, tableMap); + otherWidgetListMapData.put(tableVar, tableMap); + } + deviceObj.put("widgetListMapData", widgetListMapData); + deviceObj.put("otherWidgetListMapData", otherWidgetListMapData); + deviceObj.put("keyNameObj", buildKeyNameObj(fields, masterTableName)); + } + + private static JSONObject buildKeyNameObj(List fields, String masterTableName) { + JSONObject keyNameObj = new JSONObject(); + if (fields == null) { + return keyNameObj; + } + for (OnlineQuickFormField field : fields) { + JSONArray arr = new JSONArray(); + arr.add(masterTableName + "." + StrUtil.toCamelCase(field.getFieldName())); + keyNameObj.put(field.getFieldName(), arr); + } + return keyNameObj; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/util/WidgetFieldTypeMapping.java b/common/common-online/src/main/java/apelet/common/online/util/WidgetFieldTypeMapping.java index 50d6801..3d1d752 100644 --- a/common/common-online/src/main/java/apelet/common/online/util/WidgetFieldTypeMapping.java +++ b/common/common-online/src/main/java/apelet/common/online/util/WidgetFieldTypeMapping.java @@ -17,6 +17,8 @@ public class WidgetFieldTypeMapping { DEFAULT_LENGTH.put(1, 255); // 数字输入框 → int WIDGET_TO_FIELD_TYPE.put(3, "1"); + // 移动端数字输入框 → int + WIDGET_TO_FIELD_TYPE.put(15, "1"); // 开关组件 → int WIDGET_TO_FIELD_TYPE.put(5, "1"); // 滑块组件 → decimal diff --git a/common/common-online/src/main/java/apelet/common/online/util/WidgetJsonUtil.java b/common/common-online/src/main/java/apelet/common/online/util/WidgetJsonUtil.java index 199d6ee..e7ceec6 100644 --- a/common/common-online/src/main/java/apelet/common/online/util/WidgetJsonUtil.java +++ b/common/common-online/src/main/java/apelet/common/online/util/WidgetJsonUtil.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; /** * widgetJson 数据操作工具类。 @@ -96,6 +97,198 @@ public class WidgetJsonUtil { } /** + * 读取 pc/mobile 端 tableWidget 组件 props 中的配置项(如 sortField/filterField)。 + * 优先 pc,pc 未配置时回退 mobile,与列表查询读取保持一致。 + * + * @param widgetJson 在线表单的 widgetJson。 + * @param key props 中的配置项 key。 + * @return 配置值,widgetJson 为空或两端的 tableWidget/props 均不存在时返回 null。 + */ + public static String getTableWidgetConfig(String widgetJson, String key) { + if (StrUtil.isBlank(widgetJson) || StrUtil.isBlank(key)) { + return null; + } + try { + JSONObject root = JSON.parseObject(widgetJson); + String value = readTableWidgetConfig(root.getJSONObject("pc"), key); + if (StrUtil.isNotBlank(value)) { + return value; + } + return readTableWidgetConfig(root.getJSONObject("mobile"), key); + } catch (Exception e) { + return null; + } + } + + private static String readTableWidgetConfig(JSONObject modeObj, String key) { + if (modeObj == null) { + return null; + } + JSONObject tableWidget = modeObj.getJSONObject("tableWidget"); + if (tableWidget == null) { + return null; + } + JSONObject props = tableWidget.getJSONObject("props"); + if (props == null) { + return null; + } + return props.getString(key); + } + + /** + * 收集子表控件配置的默认排序/过滤条件(props.sortField / props.filterField)。 + * 递归遍历 pc/mobile 两端的 widgetList 及 tableWidget.childWidgetList, + * 按 bindData.tableId != 主表 tableId 识别子表控件;以子表物理表名为 key 回填两个 Map(仅含非空配置)。 + * 子表物理表名优先用 tableNameResolver 解析,缺失时兜底 bindData.tableName。 + * 同一子表在 pc/mobile 均出现时,以先遍历到的 pc 配置为准(putIfAbsent)。 + * + * @param widgetJson 在线表单的 widgetJson。 + * @param masterTableId 主表 tableId,用于排除主表字段控件。 + * @param tableNameResolver 子表 tableId → 物理表名 的解析函数,可传 null。 + * @param filterFieldMap 输出:子表物理表名 → filterField。 + * @param sortFieldMap 输出:子表物理表名 → sortField。 + */ + public static void collectSubTableSortFilter(JSONObject widgetJson, Long masterTableId, + Function tableNameResolver, + Map filterFieldMap, Map sortFieldMap) { + if (widgetJson == null) { + return; + } + for (String mode : new String[]{"pc", "mobile"}) { + JSONObject modeObj = widgetJson.getJSONObject(mode); + if (modeObj == null) { + continue; + } + collectSubTableSortFilter(modeObj.getJSONArray("widgetList"), masterTableId, tableNameResolver, filterFieldMap, sortFieldMap); + JSONObject tableWidget = modeObj.getJSONObject("tableWidget"); + if (tableWidget != null) { + collectSubTableSortFilter(tableWidget.getJSONArray("childWidgetList"), masterTableId, tableNameResolver, filterFieldMap, sortFieldMap); + } + } + } + + private static void collectSubTableSortFilter(JSONArray widgetList, Long masterTableId, + Function tableNameResolver, + Map filterFieldMap, Map sortFieldMap) { + if (CollUtil.isEmpty(widgetList)) { + return; + } + for (int i = 0; i < widgetList.size(); i++) { + JSONObject widget = widgetList.getJSONObject(i); + if (widget == null) { + continue; + } + JSONArray childList = widget.getJSONArray("childWidgetList"); + if (CollUtil.isNotEmpty(childList)) { + collectSubTableSortFilter(childList, masterTableId, tableNameResolver, filterFieldMap, sortFieldMap); + } + JSONObject bindData = widget.getJSONObject("bindData"); + if (bindData == null || bindData.isEmpty() || !bindData.containsKey("tableId")) { + continue; + } + Long tableId = bindData.getLong("tableId"); + if (tableId == null || tableId.equals(masterTableId)) { + continue; + } + JSONObject props = widget.getJSONObject("props"); + if (props == null) { + continue; + } + String tableName = tableNameResolver == null ? null : tableNameResolver.apply(tableId); + if (StrUtil.isEmpty(tableName)) { + tableName = bindData.getString("tableName"); + } + if (StrUtil.isEmpty(tableName)) { + continue; + } + String filterField = props.getString("filterField"); + String sortField = props.getString("sortField"); + if (StrUtil.isNotBlank(filterField)) { + filterFieldMap.putIfAbsent(tableName, filterField); + } + if (StrUtil.isNotBlank(sortField)) { + sortFieldMap.putIfAbsent(tableName, sortField); + } + } + } + + /** + * 读取关联组件(widgetType=402,props.relativeTable.relativeFormType==6 且 onlineFormId 匹配)上配置的默认排序/过滤。 + * 遍历 pc/mobile 的 widgetList 及 tableWidget.childWidgetList。 + * + * @param widgetJson 在线表单的 widgetJson。 + * @param targetFormId 目标在线表单id,匹配 relativeTable.onlineFormId。 + * @return Map:key 为 sortField/filterField(仅含非空项),找不到返回空 map。 + */ + public static Map getRelativeOnlineFormSortFilter(String widgetJson, Long targetFormId) { + Map config = new HashMap<>(); + if (StrUtil.isBlank(widgetJson) || targetFormId == null) { + return config; + } + try { + JSONObject root = JSON.parseObject(widgetJson); + for (String mode : new String[]{"pc", "mobile"}) { + JSONObject modeObj = root.getJSONObject(mode); + if (modeObj == null) { + continue; + } + collectRelativeOnlineFormSortFilter(modeObj.getJSONArray("widgetList"), targetFormId, config); + JSONObject tableWidget = modeObj.getJSONObject("tableWidget"); + if (tableWidget != null) { + collectRelativeOnlineFormSortFilter(tableWidget.getJSONArray("childWidgetList"), targetFormId, config); + } + } + } catch (Exception e) { + // 忽略解析异常,返回已收集到的配置 + } + return config; + } + + private static void collectRelativeOnlineFormSortFilter(JSONArray widgetList, Long targetFormId, Map config) { + if (CollUtil.isEmpty(widgetList)) { + return; + } + for (int i = 0; i < widgetList.size(); i++) { + JSONObject widget = widgetList.getJSONObject(i); + if (widget == null) { + continue; + } + JSONArray childList = widget.getJSONArray("childWidgetList"); + if (CollUtil.isNotEmpty(childList)) { + collectRelativeOnlineFormSortFilter(childList, targetFormId, config); + } + Integer widgetType = widget.getInteger("widgetType"); + if (widgetType == null || widgetType != 402) { + continue; + } + JSONObject props = widget.getJSONObject("props"); + if (props == null) { + continue; + } + JSONObject relativeTable = props.getJSONObject("relativeTable"); + if (relativeTable == null) { + continue; + } + Integer formType = relativeTable.getInteger("relativeFormType"); + String onlineFormId = relativeTable.getString("onlineFormId"); + if (formType == null || formType != 6 || StrUtil.isBlank(onlineFormId)) { + continue; + } + if (!targetFormId.toString().equals(onlineFormId)) { + continue; + } + String sortField = props.getString("sortField"); + String filterField = props.getString("filterField"); + if (StrUtil.isNotBlank(sortField)) { + config.putIfAbsent("sortField", sortField); + } + if (StrUtil.isNotBlank(filterField)) { + config.putIfAbsent("filterField", filterField); + } + } + } + + /** * 收集子表控件(widgetType=100) bindData.tableName 的名称集合。 * * @param widgetJson 在线表单的 widgetJson。 diff --git a/common/common-online/src/main/java/apelet/common/online/vo/OnlineFormVo.java b/common/common-online/src/main/java/apelet/common/online/vo/OnlineFormVo.java index 618c710..a890bb2 100644 --- a/common/common-online/src/main/java/apelet/common/online/vo/OnlineFormVo.java +++ b/common/common-online/src/main/java/apelet/common/online/vo/OnlineFormVo.java @@ -1,5 +1,6 @@ package apelet.common.online.vo; +import apelet.common.online.model.OnlineQuickFormField; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -102,6 +103,18 @@ public class OnlineFormVo { private Long updateUserId; /** + * 快捷表单ID。 + */ + @Schema(description = "快捷表单ID") + private Long quickFormId; + + /** + * 排版类型。 + */ + @Schema(description = "排版类型") + private Integer layoutType; + + /** * masterTableId 的一对一关联数据对象,数据对应类型为OnlineTableVo。 */ @Schema(description = "asterTableId 的一对一关联数据对象") @@ -124,4 +137,10 @@ public class OnlineFormVo { */ @Schema(description = "当前表单关联的数据源Id集合") private List datasourceIdList; + + /** + * 在线快捷表单字段实体对象。 + */ + @Schema(description = "在线快捷表单字段实体对象") + private List onlineQuickFormFields; } diff --git a/common/common-online/src/main/resources/quick-form/quick-form-edit.json b/common/common-online/src/main/resources/quick-form/quick-form-edit.json new file mode 100644 index 0000000..cab45f9 --- /dev/null +++ b/common/common-online/src/main/resources/quick-form/quick-form-edit.json @@ -0,0 +1 @@ +{"pc":{"gutter":20,"labelWidth":100,"labelPosition":"right","leftWidget":{"showName":"树形选择组件","variableName":"tree1786936219434","eventList":[],"childWidgetList":[],"style":{},"widgetType":13,"bindData":{"defaultValue":{"valueType":20},"dataType":0},"props":{"hide":false,"disabled":false,"styleCssText":"","dictInfo":{},"actions":{},"required":false,"iconRefrush":false,"span":24,"height":300,"treeProjectName":""}},"operationList":[{"id":55,"type":703,"name":"提交","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":10,"type":654,"name":"审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":1,"type":655,"name":"撤销","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"eventList":[],"readOnly":false,"showOrder":1,"editOrg":true},{"id":2,"type":656,"name":"反审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"editOrg":true},{"id":30,"type":30,"name":"保存","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":3,"editOrg":true,"btnClass":"table-btn primary","exportColumnList":[],"fieldOptions":{"id":"","code":"","fieldName":""},"printParamList":[],"skipHeader":true,"isPopup":false,"isAfterClose":false,"isDis":false,"isPiLiang":false,"isDuibi":false,"isRouterSelect":false,"isImportDialog":false,"isRouterIcon":false,"ishiddxzIcon":false,"iskanban":false,"kanbanStr":"","isRouterNew":false,"isRouterEng":"","isRouterName":""}],"customFieldList":[],"widgetList":[{"showName":"名称","variableName":"name","eventList":[],"childWidgetList":[],"style":{},"widgetType":1,"bindData":{"defaultValue":{"valueType":20},"columnId":"2089242983487508480","dataType":0,"tableId":"2089242982703173632","id":"2089242984389283840","columnFieldName":"name"},"props":{"colorInput":"","readyModeHiddenEmpty":false,"inCopyImg":false,"colorTitle":"","styleCssText":"","type":"text","show-password":false,"required":false,"plarceColor":"","inputWidthBlan":false,"readonly":false,"disabled":false,"placeholder":"","textLineTb":false,"borderBottom":false,"dictInfo":{"paramList":[]},"inCopy":false,"tooltipText":"","inputWidth":"","textRedBd":false,"hide":false,"labelWight":2,"inputHight":40,"teartextMore":false,"inputHeight":2,"show-word-limit":false,"actions":{},"span":12}},{"showName":"编码","variableName":"number","eventList":[],"childWidgetList":[],"style":{},"widgetType":3,"bindData":{"defaultValue":{"valueType":20},"columnId":"2089242983432982528","dataType":0,"tableId":"2089242982703173632","id":"2089242984389283840","columnFieldName":"number"},"props":{"hide":false,"controls":true,"tooltipText":"","step":1,"disabled":false,"styleCssText":"","placeholder":"","dictInfo":{"paramList":[]},"actions":{},"required":false,"span":12}},{"showName":"公司","variableName":"org","eventList":[],"childWidgetList":[],"style":{},"widgetType":10,"bindData":{"defaultValue":{"valueType":20},"columnId":"2089242984045350912","dataType":0,"tableId":"2089242982703173632","id":"2089242984389283840","columnFieldName":"org"},"props":{"colorInput":"","readyModeHiddenEmpty":false,"tooltipText":"","multiple":false,"colorTitle":"","styleCssText":"","inputWidth":"","required":false,"inputSeletUr":false,"fiterSaicn":false,"hide":false,"labelWight":2,"inputHight":"44","disabled":false,"placeholder":"","dictInfo":{"paramList":[],"dictId":"1836652536447115347"},"borderBottom":false,"actions":{},"span":12}},{"showName":"逻辑删除标记(1: 正常 -1: 已删除)","variableName":"deletedFlag","eventList":[],"childWidgetList":[],"style":{},"widgetType":7,"bindData":{"defaultValue":{"valueType":20},"columnId":"2089255899041370112","dataType":0,"tableId":"2089242982703173632","id":"2089242984389283840","columnFieldName":"deleted_flag"},"props":{"tooltipText":"","colorTitle":"","styleCssText":"","icon-size":"20px","inputWidth":"","required":false,"hide":false,"labelWight":2,"inputHight":40,"disabled":false,"dictInfo":{"paramList":[],"dictId":"1778264269813452800"},"borderBottom":false,"actions":{},"span":12,"direction":"horizontal"}},{"showName":"创建日期","variableName":"createTime","eventList":[],"childWidgetList":[],"style":{},"widgetType":20,"bindData":{"defaultValue":{"valueType":20},"columnId":"2089242982925471744","dataType":0,"tableId":"2089242982703173632","id":"2089242984389283840","columnFieldName":"create_time"},"props":{"hide":false,"tooltipText":"","disabled":false,"styleCssText":"","placeholder":"","type":"date","dictInfo":{"paramList":[]},"actions":{},"required":false,"span":12}},{"showName":"创建人登录名称","variableName":"createUserId","eventList":[],"childWidgetList":[],"style":{},"widgetType":402,"bindData":{"slaveTableId":"1836652414610972704","defaultValue":{"valueType":20},"columnId":"2089242982887723008","slaveTableName":"t_pm_user","dataType":0,"tableId":"2089242982703173632","id":"2089242984389283840","columnFieldName":"create_user_id"},"props":{"relativeTable":{"FieldFormId":"","variableName":"","onlineFormId":"","relativeFormId":"","relativeColumn":"id","relationId":"","relativeFormCode":"query_all_pm_user","fieldAddSwich":"","relativeFormType":2,"datasourceId":"","relativeTableName":"","displayField":"fname","onlineFormName":""},"inCopy":false,"colorInput":"","readyModeHiddenEmpty":false,"tooltipText":"","wightInput":0,"colorTitle":"","isMultiple":false,"styleCssText":"","inputWidth":"","required":false,"plarceColor":"","transparentTitle":0,"fiterSaicn":false,"hide":false,"inputHight":40,"disabled":false,"placeholder":"","borderBottom":false,"dictInfo":{"paramList":[]},"actions":{},"span":12}}],"otherWidgetList":[],"widgetListMapData":{"number":{"variableName":"number","widgetType":3},"createUserId":{"variableName":"createUserId","widgetType":402},"org":{"variableName":"org","widgetType":10},"createTime":{"variableName":"createTime","widgetType":20},"deletedFlag":{"variableName":"deletedFlag","widgetType":7},"name":{"variableName":"name","widgetType":1}},"otherWidgetListMapData":{},"keyNameObj":{"name":["t_fastform.name"],"number":["t_fastform.number"],"org":["t_fastform.org"],"deleted_flag":["t_fastform.deletedFlag"],"create_time":["t_fastform.createTime"],"create_user_id":["t_fastform.createUserId"]},"formEventList":[],"maskFieldList":[],"width":800,"fullscreen":false,"readyModeHiddenEmpty":false,"approvalProcess":false,"twoDialog":false,"pcWidthNs":0,"baiseDi":false,"onloadShow":false,"buttonDisabled":false,"advanceQuery":false},"mobile":{"gutter":20,"labelWidth":100,"labelPosition":"right","leftWidget":{"showName":"树形选择组件","variableName":"tree1786936219434","eventList":[],"childWidgetList":[],"style":{},"widgetType":13,"bindData":{"defaultValue":{"valueType":20},"dataType":0},"props":{"hide":false,"disabled":false,"styleCssText":"","dictInfo":{},"actions":{},"required":false,"iconRefrush":false,"span":24,"height":300,"treeProjectName":""}},"operationList":[{"id":55,"type":703,"name":"提交","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":10,"type":654,"name":"审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":1,"type":655,"name":"撤销","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"eventList":[],"readOnly":false,"showOrder":1,"editOrg":true},{"id":2,"type":656,"name":"反审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"editOrg":true},{"id":30,"type":30,"name":"保存","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":3,"editOrg":true}],"customFieldList":[],"widgetList":[{"widgetType":1,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"id":"2089242984389283840","tableId":"2089242982703173632","columnFieldName":"name","columnId":"2089242983487508480"},"showName":"名称","variableName":"name","props":{"span":24,"type":"text","inputHight":40,"textLineTb":false,"inputNumMin":0,"inputNumMax":0,"plarceColor":"","inputWidth":"","transparentTitle":0,"inputWidthBlan":false,"colorTitle":"","colorInput":"","labelWight":2,"inputHeight":2,"placeholder":"","show-password":false,"show-word-limit":false,"inCopy":false,"inCopyImg":false,"required":false,"disabled":false,"readonly":false,"hide":false,"teartextMore":false,"borderBottom":false,"textRedBd":false,"readyModeHiddenEmpty":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":7,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"id":"2089242984389283840","tableId":"2089242982703173632","columnFieldName":"deleted_flag","columnId":"2089255899041370112"},"showName":"逻辑删除标记(1: 正常 -1: 已删除)","variableName":"deletedFlag","props":{"span":24,"dictInfo":{"paramList":[]},"inputHight":40,"inputWidth":"","transparentTitle":0,"colorTitle":"","labelWight":2,"direction":"horizontal","borderBottom":false,"icon-size":"20px","required":false,"disabled":false,"hide":false,"tooltipText":"","styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":15,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"id":"2089242984389283840","tableId":"2089242982703173632","columnFieldName":"number","columnId":"2089242983432982528"},"showName":"编码","variableName":"number","props":{"span":24,"labelWight":2,"step":1,"input-width":"32px","button-size":"28px","integer":false,"required":false,"disabled":false,"hide":false,"styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":402,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"create_user_id","id":"2089242984389283840","tableId":"2089242982703173632","columnId":"2089242982887723008","slaveTableId":"1836652414610972704","slaveTableName":"t_pm_user"},"showName":"创建人登录名称","variableName":"createUserId","props":{"span":24,"inCopy":false,"plarceColor":"","transparentTitle":0,"inputHight":40,"inputWidth":"","placeholder":"","colorTitle":"","colorInput":"","wightInput":0,"borderBottom":false,"required":false,"fiterSaicn":false,"disabled":false,"relativeTable":{"relativeFormId":"","relativeTableName":"","relativeColumn":"id","relationId":"","datasourceId":"","variableName":"","displayField":"fname","FieldFormId":"","fieldAddSwich":"","relativeFormType":2,"relativeFormCode":"query_all_pm_user","onlineFormId":"","onlineFormName":""},"isMultiple":false,"hide":false,"readyModeHiddenEmpty":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false}],"otherWidgetList":[],"widgetListMapData":{"name":{"widgetType":1,"variableName":"name"},"deletedFlag":{"widgetType":7,"variableName":"deletedFlag"},"number":{"widgetType":15,"variableName":"number"},"createUserId":{"widgetType":402,"variableName":"createUserId"}},"otherWidgetListMapData":{},"keyNameObj":{"name":["t_fastform.name"],"deleted_flag":["t_fastform.deletedFlag"],"number":["t_fastform.number"],"create_user_id":["t_fastform.createUserId"]},"formEventList":[],"maskFieldList":[],"width":800,"fullscreen":true,"readyModeHiddenEmpty":false,"approvalProcess":false,"twoDialog":false,"pcWidthNs":0,"baiseDi":false,"onloadShow":false,"buttonDisabled":false}} \ No newline at end of file diff --git a/common/common-online/src/main/resources/quick-form/quick-form-list.json b/common/common-online/src/main/resources/quick-form/quick-form-list.json new file mode 100644 index 0000000..aa6fb26 --- /dev/null +++ b/common/common-online/src/main/resources/quick-form/quick-form-list.json @@ -0,0 +1 @@ +{"pc":{"gutter":20,"filterItemWidth":350,"labelWidth":100,"labelPosition":"right","tableWidget":{"widgetType":100,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":""},"operationList":[{"id":1,"type":10,"name":"批量删除","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"readOnly":false,"showOrder":0,"eventList":[]},{"id":2,"type":0,"name":"新建","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"readOnly":false,"showOrder":1,"eventList":[]},{"id":3,"type":1,"name":"编辑","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn success","readOnly":false,"showOrder":10,"eventList":[]},{"id":4,"type":2,"name":"删除","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn delete","readOnly":false,"showOrder":15,"eventList":[]},{"id":5,"type":5,"name":"新增行","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn success","readOnly":false,"showOrder":10,"eventList":[]},{"id":6,"type":6,"name":"导入","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn success","readOnly":false,"showOrder":10,"eventList":[]},{"id":7,"type":53,"name":"上移","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn primary","readOnly":false,"showOrder":11,"eventList":[]},{"id":8,"type":54,"name":"下移","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn primary","readOnly":false,"showOrder":12,"eventList":[]}],"showName":"表格组件","variableName":"table1786936219434","props":{"span":24,"height":300,"heightAuto":false,"AutoHeight":"","paddingBottom":0,"isShowGantt":false,"paged":true,"queryPage":false,"pageSize":10,"operationColumnWidth":160,"isHiddenOrder":true,"isShowCheckbox":true,"disabled":false,"treeModelConfig":{},"tableColumnList":[],"hide":false,"styleCssText":"","actions":{},"dictInfo":{"paramList":[]}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":true},"leftWidget":{"widgetType":13,"bindData":{"defaultValue":{"valueType":20},"dataType":0},"showName":"树形选择组件","variableName":"tree1786936219434","props":{"span":24,"height":300,"treeProjectName":"","dictInfo":{},"required":false,"disabled":false,"hide":false,"iconRefrush":false,"styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{}},"operationList":[{"id":0,"type":3,"name":"导出","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0},{"id":10,"type":4,"name":"导入","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0},{"id":55,"type":703,"name":"提交","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":100,"type":654,"name":"审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":710,"type":710,"name":"project导入","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":720,"type":720,"name":"project导出","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0},{"id":1,"type":10,"name":"批量删除","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"eventList":[],"readOnly":false,"showOrder":1,"editOrg":true},{"id":101,"type":655,"name":"撤销","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"eventList":[],"readOnly":false,"showOrder":1,"editOrg":true},{"id":2,"type":0,"name":"新建","enabled":true,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"isPopup":true,"btnClass":"table-btn primary","formId":"2089253728354832384","exportColumnList":[],"skipHeader":true,"isAfterClose":false,"isDis":false,"isPiLiang":false,"isDuibi":false,"isRouterSelect":false,"isImportDialog":false,"isRouterIcon":false,"ishiddxzIcon":false,"iskanban":false,"kanbanStr":"","isRouterNew":false,"isRouterEng":"","isRouterName":"","printParamList":[],"pluginList":[],"fieldOptions":{"id":"","code":"","fieldName":""}},{"id":102,"type":656,"name":"反审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"editOrg":true},{"id":103,"type":702,"name":"流程图","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"editOrg":true},{"id":3,"type":1,"name":"编辑","enabled":true,"builtin":true,"rowOperation":false,"btnClass":"table-btn success","eventList":[],"readOnly":false,"showOrder":10,"plain":false,"isPopup":true,"formId":"2089253728354832384","exportColumnList":[],"skipHeader":true,"isAfterClose":false,"isDis":false,"isPiLiang":false,"isDuibi":false,"isRouterSelect":false,"isImportDialog":false,"isRouterIcon":false,"ishiddxzIcon":false,"iskanban":false,"kanbanStr":"","isRouterNew":false,"isRouterEng":"","isRouterName":"","printParamList":[],"pluginList":[],"fieldOptions":{"id":"","code":"","fieldName":""}},{"id":4,"type":2,"name":"删除","enabled":false,"builtin":true,"rowOperation":false,"btnClass":"table-btn delete","eventList":[],"readOnly":false,"showOrder":15,"editOrg":true},{"id":5,"type":650,"name":"下推","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":20},{"id":6,"type":651,"name":"上查","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":21},{"id":7,"type":652,"name":"下查","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":22},{"id":8,"type":53,"name":"上移","enabled":false,"builtin":true,"rowOperation":true,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":23},{"id":9,"type":54,"name":"下移","enabled":false,"builtin":true,"rowOperation":true,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":24}],"customFieldList":[],"widgetList":[{"widgetType":1,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"name","columnId":"2089242983487508480","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"名称","variableName":"name","props":{"span":12,"type":"text","inputHight":40,"textLineTb":false,"inputNumMin":0,"inputNumMax":0,"plarceColor":"","inputWidth":"","transparentTitle":0,"inputWidthBlan":false,"colorTitle":"","colorInput":"","labelWight":2,"inputHeight":2,"placeholder":"","show-password":false,"show-word-limit":false,"inCopy":false,"inCopyImg":false,"required":false,"disabled":false,"readonly":false,"hide":false,"teartextMore":false,"borderBottom":false,"textRedBd":false,"readyModeHiddenEmpty":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{}},{"widgetType":3,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"number","columnId":"2089242983432982528","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"编码","variableName":"number","props":{"span":12,"placeholder":"","step":1,"controls":true,"required":false,"disabled":false,"hide":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{}},{"widgetType":402,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"create_user_id","columnId":"2089242982887723008","slaveTableId":"1836652414610972704","slaveTableName":"t_pm_user","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"创建人登录名称","variableName":"createUserId","props":{"span":12,"inCopy":false,"plarceColor":"","transparentTitle":0,"inputHight":40,"inputWidth":"","placeholder":"","colorTitle":"","colorInput":"","wightInput":0,"borderBottom":false,"required":false,"fiterSaicn":false,"disabled":false,"relativeTable":{"relativeFormId":"","relativeTableName":"","relativeColumn":"id","relationId":"","datasourceId":"","variableName":"","displayField":"fname","FieldFormId":"","fieldAddSwich":"","relativeFormType":2,"relativeFormCode":"query_all_pm_user","onlineFormId":"","onlineFormName":""},"isMultiple":false,"hide":false,"readyModeHiddenEmpty":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{}},{"widgetType":7,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"deleted_flag","columnId":"2089242983294570496","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"逻辑删除标记","variableName":"deletedFlag","props":{"span":12,"dictInfo":{"dictId":"1778264269813452800","paramList":[]},"inputHight":40,"inputWidth":"","transparentTitle":0,"colorTitle":"","labelWight":2,"direction":"horizontal","borderBottom":false,"icon-size":"20px","required":false,"disabled":false,"hide":false,"tooltipText":"","styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{}},{"widgetType":20,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"create_time","columnId":"2089242982925471744","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"创建日期","variableName":"createTime","props":{"span":12,"placeholder":"","type":"date","required":false,"disabled":false,"hide":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{}},{"widgetType":10,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"org","columnId":"2089242984045350912","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"公司","variableName":"org","props":{"span":12,"placeholder":"","transparentTitle":0,"inputHight":"44","inputWidth":"","colorTitle":"","colorInput":"","dictInfo":{"dictId":"1836652536447115347","paramList":[]},"labelWight":2,"borderBottom":false,"required":false,"multiple":false,"fiterSaicn":false,"inputSeletUr":false,"disabled":false,"readyModeHiddenEmpty":false,"hide":false,"tooltipText":"","styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{}}],"otherWidgetList":[],"widgetListMapData":{"name":{"widgetType":1,"variableName":"name"},"number":{"widgetType":3,"variableName":"number"},"createUserId":{"widgetType":402,"variableName":"createUserId"},"deletedFlag":{"widgetType":7,"variableName":"deletedFlag"},"createTime":{"widgetType":20,"variableName":"createTime"},"org":{"widgetType":10,"variableName":"org"},"table1786936219434":{"widgetType":100,"variableName":"table1786936219434"}},"otherWidgetListMapData":{"table1786936219434":{"widgetType":100,"variableName":"table1786936219434"}},"keyNameObj":{"name":["t_fastform.name"],"number":["t_fastform.number"],"create_user_id":["t_fastform.createUserId"],"deleted_flag":["t_fastform.deletedFlag"],"create_time":["t_fastform.createTime"],"org":["t_fastform.org"]},"formEventList":[],"maskFieldList":[],"width":800,"fullscreen":true,"approvalProcess":false,"twoDialog":false,"pcWidthNs":0,"baiseDi":false,"onloadShow":false,"buttonDisabled":false,"advanceQuery":false},"mobile":{"gutter":20,"filterItemWidth":350,"labelWidth":100,"labelPosition":"right","tableWidget":{"widgetType":103,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":""},"operationList":[{"id":1,"type":10,"name":"批量删除","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"readOnly":false,"showOrder":0,"eventList":[]},{"id":2,"type":0,"name":"新建","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"readOnly":false,"showOrder":1,"eventList":[]},{"id":3,"type":1,"name":"编辑","enabled":false,"builtin":true,"rowOperation":true,"btnClass":"table-btn success","readOnly":false,"showOrder":10,"eventList":[]}],"showName":"查询列表","props":{"span":24,"paddingBottom":0,"card":303,"hideDel":false,"mobileTags":{},"orderList":[],"hide":false,"styleCssText":"","actions":{},"dictInfo":{"paramList":[]}},"eventList":[],"childWidgetList":[{"widgetType":303,"bindData":{"defaultValue":{"valueType":20},"dataType":0},"showName":"listCard","variableName":"listCard1786952452820","props":{"imagePosition":"left","imageAlign":"center","hide":false,"styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[{"widgetType":42,"bindData":{"defaultValue":{"valueType":20},"dataType":0},"showName":"图片","variableName":"image1786952452857","props":{"span":12,"fit":"fill","align":"start","width":"100px","height":"100px","radius":3,"round":false,"paddingBottom":0,"hide":false,"styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":1,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"name","columnId":"2089242983487508480","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"名称","variableName":"name","props":{"span":24,"type":"text","inputHight":40,"textLineTb":false,"inputNumMin":0,"inputNumMax":0,"plarceColor":"","inputWidth":"","transparentTitle":0,"inputWidthBlan":false,"colorTitle":"","colorInput":"","labelWight":2,"inputHeight":2,"placeholder":"","show-password":false,"show-word-limit":false,"inCopy":false,"inCopyImg":false,"required":false,"disabled":false,"readonly":false,"hide":false,"teartextMore":false,"borderBottom":false,"textRedBd":false,"readyModeHiddenEmpty":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":7,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"deleted_flag","columnId":"2089255899041370112","id":"2089242984389283840","tableId":"2089242982703173632"},"showName":"逻辑删除标记(1: 正常 -1: 已删除)","variableName":"deletedFlag","props":{"span":24,"dictInfo":{"paramList":[]},"inputHight":40,"inputWidth":"","transparentTitle":0,"colorTitle":"","labelWight":2,"direction":"horizontal","borderBottom":false,"icon-size":"20px","required":false,"disabled":false,"hide":false,"tooltipText":"","styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false}],"style":{},"supportOperation":false},{"widgetType":502,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"name","id":"2089242984389283840","tableId":"2089242982703173632","columnId":"2089242983487508480"},"showName":"名称","variableName":"name","props":{"placeholder":"","hide":false,"span":24,"dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":402,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"create_user_id","id":"2089242984389283840","tableId":"2089242982703173632","columnId":"2089242982887723008","slaveTableId":"1836652414610972704","slaveTableName":"t_pm_user"},"showName":"创建人登录名称","variableName":"createUserId","props":{"span":24,"inCopy":false,"plarceColor":"","transparentTitle":0,"inputHight":40,"inputWidth":"","placeholder":"","colorTitle":"","colorInput":"","wightInput":0,"borderBottom":false,"required":false,"fiterSaicn":false,"disabled":false,"relativeTable":{"relativeFormId":"","relativeTableName":"","relativeColumn":"id","relationId":"","datasourceId":"","variableName":"","displayField":"fname","FieldFormId":"","fieldAddSwich":"","relativeFormType":2,"relativeFormCode":"query_all_pm_user","onlineFormId":"","onlineFormName":""},"isMultiple":false,"hide":false,"readyModeHiddenEmpty":false,"tooltipText":"","styleCssText":"","dictInfo":{"paramList":[]},"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":500,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"deleted_flag","id":"2089242984389283840","tableId":"2089242982703173632","columnId":"2089255899041370112"},"showName":"逻辑删除标记(1: 正常 -1: 已删除)","variableName":"deletedFlag","props":{"dictInfo":{"paramList":[]},"hide":false,"span":24,"actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false},{"widgetType":10,"bindData":{"defaultValue":{"valueType":20},"dataType":0,"columnFieldName":"org","id":"2089242984389283840","tableId":"2089242982703173632","columnId":"2089242984045350912"},"showName":"公司","variableName":"org","props":{"span":24,"placeholder":"","transparentTitle":0,"inputHight":"44","inputWidth":"","colorTitle":"","colorInput":"","dictInfo":{"dictId":"1836652536447115347","paramList":[]},"labelWight":2,"borderBottom":false,"required":false,"multiple":false,"fiterSaicn":false,"inputSeletUr":false,"disabled":false,"readyModeHiddenEmpty":false,"hide":false,"tooltipText":"","styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{},"supportOperation":false}],"style":{},"supportOperation":true},"leftWidget":{"widgetType":13,"bindData":{"defaultValue":{"valueType":20},"dataType":0},"showName":"树形选择组件","variableName":"tree1786936219434","props":{"span":24,"height":300,"treeProjectName":"","dictInfo":{},"required":false,"disabled":false,"hide":false,"iconRefrush":false,"styleCssText":"","actions":{}},"eventList":[],"childWidgetList":[],"style":{}},"operationList":[{"id":55,"type":703,"name":"提交","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":100,"type":654,"name":"审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":true,"paramList":[],"eventList":[],"readOnly":false,"showOrder":0,"editOrg":true},{"id":101,"type":655,"name":"撤销","enabled":false,"builtin":true,"rowOperation":false,"btnType":"danger","plain":true,"eventList":[],"readOnly":false,"showOrder":1,"editOrg":true},{"id":2,"type":0,"name":"新建","enabled":true,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"isPopup":false,"btnClass":"table-btn primary","formId":"2089253728354832384","exportColumnList":[],"skipHeader":true,"isAfterClose":false,"isDis":false,"isPiLiang":false,"isDuibi":false,"isRouterSelect":false,"isImportDialog":false,"isRouterIcon":false,"ishiddxzIcon":false,"iskanban":false,"kanbanStr":"","isRouterNew":false,"isRouterEng":"","isRouterName":"","printParamList":[],"pluginList":[],"fieldOptions":{"id":"","code":"","fieldName":""}},{"id":102,"type":656,"name":"反审核","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":2,"editOrg":true},{"id":3,"type":1,"name":"编辑","enabled":true,"builtin":true,"rowOperation":false,"btnClass":"table-btn success","eventList":[],"readOnly":false,"showOrder":10,"plain":false,"isPopup":false,"formId":"2089253728354832384","exportColumnList":[],"skipHeader":true,"isAfterClose":false,"isDis":false,"isPiLiang":false,"isDuibi":false,"isRouterSelect":false,"isImportDialog":false,"isRouterIcon":false,"ishiddxzIcon":false,"iskanban":false,"kanbanStr":"","isRouterNew":false,"isRouterEng":"","isRouterName":"","printParamList":[],"pluginList":[],"fieldOptions":{"id":"","code":"","fieldName":""}},{"id":4,"type":2,"name":"删除","enabled":false,"builtin":true,"rowOperation":false,"btnClass":"table-btn delete","eventList":[],"readOnly":false,"showOrder":15},{"id":8,"type":701,"name":"查看","enabled":false,"builtin":true,"rowOperation":false,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":22},{"id":9,"type":53,"name":"上移","enabled":false,"builtin":true,"rowOperation":true,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":23},{"id":10,"type":54,"name":"下移","enabled":false,"builtin":true,"rowOperation":true,"btnType":"primary","plain":false,"eventList":[],"readOnly":false,"showOrder":24}],"customFieldList":[],"widgetList":[],"otherWidgetList":[],"widgetListMapData":{"undefined":{"widgetType":103}},"otherWidgetListMapData":{"undefined":{"widgetType":103}},"keyNameObj":{},"formEventList":[],"maskFieldList":[],"fullscreen":true,"approvalProcess":false,"twoDialog":false,"pcWidthNs":0,"baiseDi":false,"onloadShow":false,"buttonDisabled":false,"advanceQuery":false}} \ No newline at end of file diff --git a/common/common-openapi/pom.xml b/common/common-openapi/pom.xml new file mode 100644 index 0000000..75b435e --- /dev/null +++ b/common/common-openapi/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + apelet + common + 1.0.0 + + + common-openapi + 1.0.0 + common-openapi + jar + + + 8 + 8 + UTF-8 + + + + + apelet + common-core + 1.0.1 + + + + apelet + common-online + 1.0.5 + + + + apelet + common-generator + 1.0.5 + + + + apelet + common-redis + 1.0.0 + + + \ No newline at end of file diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/config/OpenApiProperties.java b/common/common-openapi/src/main/java/apelet/common/openapi/config/OpenApiProperties.java new file mode 100644 index 0000000..d5588bd --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/config/OpenApiProperties.java @@ -0,0 +1,37 @@ +package apelet.common.openapi.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * OpenAPI 平台配置项。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +@Component +@ConfigurationProperties(prefix = "common-openapi") +public class OpenApiProperties { + + /** + * 对外开放路由前缀(不要以反斜杠结尾),默认 /openapi。 + */ + private String urlPrefix = "/openapi"; + + /** + * 是否启用 OpenAPI 开放平台(false 时所有 Controller 接口不可用)。 + */ + private Boolean operationEnabled = true; + + /** + * JWT 签名密钥(与开放 token 校验使用同一密钥)。 + */ + private String tokenSigningKey = "apelet-openapi-signing-key-default"; + + /** + * token 有效期(秒),默认 7200。 + */ + private Long tokenExpiration = 7200L; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/constant/OpenApiFieldSource.java b/common/common-openapi/src/main/java/apelet/common/openapi/constant/OpenApiFieldSource.java new file mode 100644 index 0000000..be7fbcd --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/constant/OpenApiFieldSource.java @@ -0,0 +1,30 @@ +package apelet.common.openapi.constant; + +/** + * OpenAPI 字段来源/条件目标常量(param_config / output_config 中的 source、target 取值)。 + * + * @author chenchuchuan + * @date 2026-09-03 + */ +public final class OpenApiFieldSource { + + /** + * 表单主表字段(onl_form_field,headId=主表)。 + */ + public static final String FORM_FIELD = "FORM_FIELD"; + /** + * 关联字段属性:field=关联存储字段,attr=id/编码/名称。 + */ + public static final String RELATION_FIELD = "RELATION_FIELD"; + /** + * 一对多分录明细(OnlFormHead.childName 附表),值为数组。 + */ + public static final String ENTRY_ARRAY = "ENTRY_ARRAY"; + /** + * 分录明细内的字段(附表的 onl_form_field)。 + */ + public static final String ENTRY_FIELD = "ENTRY_FIELD"; + + private OpenApiFieldSource() { + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/constant/OpenApiParamMode.java b/common/common-openapi/src/main/java/apelet/common/openapi/constant/OpenApiParamMode.java new file mode 100644 index 0000000..05d485f --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/constant/OpenApiParamMode.java @@ -0,0 +1,26 @@ +package apelet.common.openapi.constant; + +/** + * OpenAPI 参数模式常量(xy_sys_open_api_config.param_mode 取值)。 + * + * @author chenchuchuan + * @date 2026-09-03 + */ +public final class OpenApiParamMode { + + /** + * 基础查询:operation_code=query,走列表插件。 + */ + public static final String QUERY = "QUERY"; + /** + * 字段直传:保存/提交操作,走 executePlugin。 + */ + public static final String FIELD_DIRECT = "FIELD_DIRECT"; + /** + * 条件匹配:其他操作,先按条件匹配单据集合再执行。 + */ + public static final String CONDITION_MATCH = "CONDITION_MATCH"; + + private OpenApiParamMode() { + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiAppController.java b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiAppController.java new file mode 100644 index 0000000..70977a2 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiAppController.java @@ -0,0 +1,173 @@ +package apelet.common.openapi.controller; + +import apelet.common.core.annotation.MyRequestBody; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.MyCommonUtil; +import apelet.common.core.util.MyPageUtil; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.service.OpenApiAppService; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.github.pagehelper.page.PageMethod; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Date; +import java.util.List; + +/** + * OpenAPI 第三方应用管理接口(内部,供前端开放平台界面调用)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Tag(name = "OpenAPI第三方应用接口") +@Slf4j +@RestController +@RequestMapping("${common-openapi.urlPrefix:/openapi}/app") +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiAppController { + + @Autowired + private OpenApiAppService openApiAppService; + + /** + * 分页查询应用列表(不回显 appSecret)。 + */ + @PostMapping("/list") + @McpToolClass(name = "openapi_app_list", description = "分页查询 OpenAPI 第三方应用列表(appSecret 已打码),可按 appCode/appName/status 过滤") + public ResponseResult> list(@MyRequestBody @McpToolParam(fieldName = "openApiAppFilter", type = McpToolParamType.OBJECT, description = "应用过滤条件", schemaClass = OpenApiApp.class) OpenApiApp openApiAppFilter + , @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + List appList = openApiAppService.getOpenApiAppList(openApiAppFilter, "create_time DESC"); + // 列表密钥打码:前3后4明文,中间 *;完整密钥需调 /viewSecret 查看 + appList.forEach(app -> app.setAppSecret(this.maskSecret(app.getAppSecret()))); + return ResponseResult.success(MyPageUtil.makeResponseData(appList)); + } + + /** + * 新增应用:appCode 未填则自动生成,appSecret 仅此刻返回明文。 + */ + @PostMapping("/add") + @McpToolClass(name = "openapi_app_add", description = "新增 OpenAPI 第三方应用(appCode 不传自动生成),返回对象含明文 appSecret,请妥善保存") + public ResponseResult add(@MyRequestBody @McpToolParam(fieldName = "openApiApp", type = McpToolParamType.OBJECT, description = "应用信息(appName 必填)", schemaClass = OpenApiApp.class, required = true) OpenApiApp openApiApp) { + if (openApiApp == null || MyCommonUtil.existBlankArgument(openApiApp.getAppName())) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "应用名称不能为空!"); + } + if (openApiApp.getAppCode() != null && openApiAppService.existOne("appCode", openApiApp.getAppCode())) { + return ResponseResult.error(ErrorCodeEnum.DUPLICATED_UNIQUE_KEY, "应用编码已存在!"); + } + return ResponseResult.success(openApiAppService.addNew(openApiApp)); + } + + /** + * 更新应用(不修改密钥)。 + */ + @PostMapping("/update") + @McpToolClass(name = "openapi_app_update", description = "更新 OpenAPI 第三方应用(appCode/appSecret 不可改,需传 id)") + public ResponseResult update(@MyRequestBody @McpToolParam(fieldName = "openApiApp", type = McpToolParamType.OBJECT, description = "应用信息(id 必填,appName/status 等可改)", schemaClass = OpenApiApp.class, required = true) OpenApiApp openApiApp) { + if (openApiApp == null || MyCommonUtil.existBlankArgument(openApiApp.getId())) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + OpenApiApp original = openApiAppService.getById(openApiApp.getId()); + if (original == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + // 应用编码作为对外唯一标识且被授权关联表(xy_sys_open_app_api)引用,不允许修改 + if (StrUtil.isNotBlank(openApiApp.getAppCode()) + && !openApiApp.getAppCode().equals(original.getAppCode())) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "应用编码不允许修改!"); + } + openApiApp.setAppCode(original.getAppCode()); + openApiApp.setAppSecret(original.getAppSecret()); + openApiApp.setCreateTime(original.getCreateTime()); + openApiApp.setUpdateTime(new Date()); + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData != null) { + openApiApp.setUpdateUserId(tokenData.getUserId()); + } + if (!openApiAppService.updateById(openApiApp)) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + return ResponseResult.success(); + } + + /** + * 删除应用:已被开放配置引用的应用禁止删除。 + */ + @PostMapping("/delete") + @McpToolClass(name = "openapi_app_delete", description = "删除 OpenAPI 第三方应用(已被开放接口配置引用时禁止删除),需传 id") + public ResponseResult delete(@MyRequestBody @McpToolParam(fieldName = "id", type = McpToolParamType.INTEGER, description = "应用主键Id", required = true) Long id) { + if (MyCommonUtil.existBlankArgument(id)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + if (!openApiAppService.removeAppWithRelationCheck(id)) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "该应用已被分配开放接口,不能删除!"); + } + return ResponseResult.success(); + } + + /** + * 重置应用密钥:旧密钥立即失效。 + */ + @PostMapping("/resetSecret") + @McpToolClass(name = "openapi_app_reset_secret", description = "重置 OpenAPI 第三方应用密钥(旧密钥立即失效),返回新明文 appSecret") + public ResponseResult resetSecret(@MyRequestBody @McpToolParam(fieldName = "id", type = McpToolParamType.INTEGER, description = "应用主键Id", required = true) Long id) { + if (MyCommonUtil.existBlankArgument(id)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + String newSecret = openApiAppService.resetSecret(id); + if (newSecret == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + JSONObject data = new JSONObject(); + data.put("appSecret", newSecret); + return ResponseResult.success(data); + } + + /** + * 查看应用密钥明文(前端"小眼睛"调用;受应用登录鉴权保护)。 + */ + @PostMapping("/viewSecret") + @McpToolClass(name = "openapi_app_view_secret", description = "查看 OpenAPI 第三方应用密钥明文,需传 id") + public ResponseResult viewSecret(@MyRequestBody @McpToolParam(fieldName = "id", type = McpToolParamType.INTEGER, description = "应用主键Id", required = true) Long id) { + if (MyCommonUtil.existBlankArgument(id)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + OpenApiApp app = openApiAppService.getById(id); + if (app == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + JSONObject data = new JSONObject(); + data.put("appSecret", app.getAppSecret()); + return ResponseResult.success(data); + } + + /** + * 密钥打码:前3后4明文,中间用 * 占位(长度不足 7 时整体打码)。 + */ + private String maskSecret(String secret) { + if (secret == null) { + return null; + } + int len = secret.length(); + if (len <= 7) { + return StrUtil.repeat("*", len); + } + return secret.substring(0, 3) + StrUtil.repeat("*", len - 7) + secret.substring(len - 4); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiAuthController.java b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiAuthController.java new file mode 100644 index 0000000..2be2619 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiAuthController.java @@ -0,0 +1,47 @@ +package apelet.common.openapi.controller; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import apelet.common.core.object.ResponseResult; +import apelet.common.openapi.dto.OpenApiTokenDto; +import apelet.common.openapi.service.OpenApiAuthService; +import com.alibaba.fastjson.JSONObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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; + +/** + * OpenAPI 对外鉴权接口(公网)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Tag(name = "OpenAPI对外鉴权接口") +@Slf4j +@RestController +@RequestMapping("${common-openapi.urlPrefix:/openapi}/auth") +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiAuthController { + + @Autowired + private OpenApiAuthService openApiAuthService; + + /** + * 按应用编码 + 密钥 + 用户名换取 token(第三方标准扁平 JSON body)。 + */ + @PostMapping("/token") + @McpToolClass(name = "openapi_auth_token", description = "按应用编码+密钥+用户名换取 OpenAPI 访问 token(返回 accessToken/expiresIn)") + public ResponseResult token(@RequestBody(required = false) @McpToolParam(fieldName = "tokenDto", type = McpToolParamType.OBJECT, description = "换 token 请求体(appCode/appSecret/username)", schemaClass = OpenApiTokenDto.class) OpenApiTokenDto tokenDto) { + if (tokenDto == null) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + return openApiAuthService.issueToken(tokenDto.getAppCode(), tokenDto.getAppSecret(), tokenDto.getUsername()); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiConfigController.java b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiConfigController.java new file mode 100644 index 0000000..ae52ffc --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiConfigController.java @@ -0,0 +1,173 @@ +package apelet.common.openapi.controller; + +import apelet.common.core.annotation.MyRequestBody; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.util.MyCommonUtil; +import apelet.common.core.util.MyPageUtil; +import apelet.common.openapi.dto.*; +import apelet.common.openapi.model.OpenApiConfig; +import apelet.common.openapi.model.OpenAppApiRelation; +import apelet.common.openapi.service.OpenApiConfigService; +import apelet.common.openapi.vo.OpenApiConfigVo; +import cn.hutool.core.collection.CollectionUtil; +import com.github.pagehelper.page.PageMethod; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * OpenAPI 开放接口配置管理接口(内部,供前端开放平台界面调用)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Tag(name = "OpenAPI开放接口配置接口") +@Slf4j +@RestController +@RequestMapping("${common-openapi.urlPrefix:/openapi}/config") +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiConfigController { + + @Autowired + private OpenApiConfigService openApiConfigService; + + /** + * 在线页面列表(配置步骤1 选择),可按页面名称模糊搜索。 + */ + @GetMapping("/pages") + @McpToolClass(name = "openapi_config_page_options", description = "OpenAPI 配置步骤1:在线页面下拉,可按页面名称模糊搜索") + public ResponseResult> pages(@RequestParam(required = false) @McpToolParam(fieldName = "keyword", type = McpToolParamType.STRING, description = "页面名称关键字,空返回全部") String keyword) { + return ResponseResult.success(openApiConfigService.getPageOptionList(keyword)); + } + + /** + * 该页面关联的表单列表(配置步骤2 联动)。 + */ + @GetMapping("/forms") + @McpToolClass(name = "openapi_config_form_options", description = "OpenAPI 配置步骤2:指定页面对应的表单下拉") + public ResponseResult> forms(@RequestParam @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "页面主键Id", required = true) Long pageId) { + return ResponseResult.success(openApiConfigService.getFormOptionList(pageId)); + } + + /** + * 可选操作下拉:查询(query) + operationList 各项(配置步骤3)。 + */ + @GetMapping("/operations") + @McpToolClass(name = "openapi_config_operation_options", description = "OpenAPI 配置步骤3:指定表单的可选操作(查询 + operationList 各项)") + public ResponseResult> operations(@RequestParam @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "在线表单主键Id", required = true) Long formId) { + return ResponseResult.success(openApiConfigService.getOperationList(formId)); + } + + /** + * 表单可配置字段树(FIELD_DIRECT 字段直传专用)。 + */ + @GetMapping("/fields") + @McpToolClass(name = "openapi_config_field_tree", description = "OpenAPI 配置:指定表单的可配置字段树(主表字段/关联字段属性/一对多分录)") + public ResponseResult> fields(@RequestParam @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "在线表单主键Id", required = true) Long formId) { + return ResponseResult.success(openApiConfigService.getFieldTree(formId)); + } + + /** + * 分页查询开放接口配置(查询条件:应用Id + 状态)。 + */ + @PostMapping("/list") + @McpToolClass(name = "openapi_config_list", description = "分页查询 OpenAPI 开放接口配置,查询条件:openApiConfigFilter.appId(应用Id,传了仅返回该应用已授权的配置)+ openApiConfigFilter.status") + public ResponseResult> list(@MyRequestBody @McpToolParam(fieldName = "openApiConfigFilter", type = McpToolParamType.OBJECT, description = "查询条件(appId/status)", schemaClass = OpenApiConfigFilterDto.class) OpenApiConfigFilterDto openApiConfigFilter, @MyRequestBody @McpToolParam(fieldName = "pageParam", type = McpToolParamType.OBJECT, description = "分页参数(pageNum/pageSize)", schemaClass = MyPageParam.class) MyPageParam pageParam) { + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + List configList = openApiConfigService.getOpenApiConfigListByFilter(openApiConfigFilter, "create_time DESC"); + MyPageData configVoMyPageData = MyPageUtil.makeResponseData(configList, OpenApiConfig.INSTANCE); + if (CollectionUtil.isNotEmpty(configVoMyPageData.getDataList())) { + // 一次查出本页全部配置的授权关联行,按 api_id 分组回填 appIdList + List configIdList = configList.stream().map(OpenApiConfig::getId).collect(Collectors.toList()); + Map> appIdMap = openApiConfigService.getAppApiRelationListByConfigIdList(configIdList).stream() + .collect(Collectors.groupingBy(OpenAppApiRelation::getApiId, Collectors.mapping(OpenAppApiRelation::getAppId, Collectors.toList()))); + configVoMyPageData.getDataList().forEach(configVo -> configVo.setAppIdList(appIdMap.getOrDefault(configVo.getId(), new ArrayList<>()))); + } + return ResponseResult.success(configVoMyPageData); + } + + /** + * 查看配置详情(返回 VO,含已授权应用编码集合)。 + */ + @GetMapping("/view") + @McpToolClass(name = "openapi_config_view", description = "查看 OpenAPI 开放接口配置详情(返回 VO,appCodes 为已授权应用编码集合)") + public ResponseResult view(@RequestParam @McpToolParam(fieldName = "configId", type = McpToolParamType.INTEGER, description = "配置主键Id", required = true) Long configId) { + OpenApiConfig config = openApiConfigService.getById(configId); + if (config == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + OpenApiConfigVo configVo = OpenApiConfig.INSTANCE.fromModel(config); + configVo.setAppCodes(openApiConfigService.getAppCodesByConfigId(configId)); + return ResponseResult.success(configVo); + } + + /** + * 新增开放接口配置。 + */ + @PostMapping("/add") + @McpToolClass(name = "openapi_config_add", description = "新增 OpenAPI 开放接口配置(pageId/formId/formCode/operationCode 必填,自动推导 paramMode 与 requestPath;应用关联通过 assignApps 分配)") + public ResponseResult add(@MyRequestBody @McpToolParam(fieldName = "openApiConfig", type = McpToolParamType.OBJECT, description = "开放接口配置信息", schemaClass = OpenApiConfig.class, required = true) OpenApiConfig openApiConfig) { + if (openApiConfig == null || MyCommonUtil.existBlankArgument(openApiConfig.getPageId(), openApiConfig.getFormId(), openApiConfig.getFormCode(), openApiConfig.getOperationCode())) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + return ResponseResult.success(openApiConfigService.addNew(openApiConfig)); + } + + /** + * 更新开放接口配置。 + */ + @PostMapping("/update") + @McpToolClass(name = "openapi_config_update", description = "更新 OpenAPI 开放接口配置(重新推导 paramMode/requestPath,需传 id)") + public ResponseResult update(@MyRequestBody @McpToolParam(fieldName = "openApiConfig", type = McpToolParamType.OBJECT, description = "开放接口配置信息(id 必填)", schemaClass = OpenApiConfig.class, required = true) OpenApiConfig openApiConfig) { + if (openApiConfig == null || MyCommonUtil.existBlankArgument(openApiConfig.getId())) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + if (openApiConfigService.updateConfig(openApiConfig) == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + return ResponseResult.success(); + } + + /** + * 分配应用:全量替换该接口配置的应用授权(空集合即清空授权)。 + */ + @PostMapping("/assignApps") + @McpToolClass(name = "openapi_config_assign_apps", description = "给 OpenAPI 开放接口配置分配第三方应用(全量替换授权,appIds 空即清空),返回已授权应用Id 集合") + public ResponseResult> assignApps(@MyRequestBody @McpToolParam(fieldName = "configId", type = McpToolParamType.INTEGER, description = "开放接口配置Id", required = true) Long configId, + @MyRequestBody @McpToolParam(fieldName = "appIds", type = McpToolParamType.ARRAY, description = "授权应用Id 集合(全量替换,空集合即清空授权)") List appIds) { + if (MyCommonUtil.existBlankArgument(configId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + return ResponseResult.success(openApiConfigService.assignApps(configId, appIds)); + } + + /** + * 删除开放接口配置。 + */ + @PostMapping("/delete") + @McpToolClass(name = "openapi_config_delete", description = "删除 OpenAPI 开放接口配置,需传 configId") + public ResponseResult delete(@MyRequestBody @McpToolParam(fieldName = "configId", type = McpToolParamType.INTEGER, description = "配置主键Id", required = true) Long configId) { + if (MyCommonUtil.existBlankArgument(configId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + if (!openApiConfigService.removeById(configId)) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + return ResponseResult.success(); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiRouterController.java b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiRouterController.java new file mode 100644 index 0000000..9555aca --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiRouterController.java @@ -0,0 +1,54 @@ +package apelet.common.openapi.controller; + +import apelet.common.core.mcp.annotation.McpToolClass; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import apelet.common.core.object.ResponseResult; +import apelet.common.openapi.config.OpenApiProperties; +import apelet.common.openapi.service.OpenApiExecService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.Map; + +/** + * OpenAPI 对外开放动态路由({urlPrefix}/v1/{pageCode}/{formCode}/{operationCode})。 + *

鉴权由 OpenApiAuthInterceptor 完成;本控制器按路径定位配置并执行。

+ * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Tag(name = "OpenAPI对外开放动态路由") +@Slf4j +@RestController +@RequestMapping("${common-openapi.urlPrefix:/openapi}/v1") +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiRouterController { + + @Autowired + private OpenApiExecService openApiExecService; + @Autowired + private OpenApiProperties openApiProperties; + + /** + * 执行指定开放接口配置(MCP 工具:openapi_route_execute)。 + *

MCP 入参 requestBody 为 key-value 形式的自由对象:key 为该开放接口配置的参数字段(如 param_config.conditions 里的 param、FIELD_DIRECT 的 fields.alias),value 为对应字段值。

+ *

注意:MCP 直调走的是 McpToolScanner 反射调用(method.invoke),不经过本控制器的 HTTP 拦截链路, + * 因此不会触发 OpenApiAuthInterceptor 的开放 token 校验,注入的 TokenData 无 appCode 归属信息, + * OpenApiExecServiceImpl#execute 的 junction 授权校验(token.appCode → app → xy_sys_open_app_api)必然不通过, + * 当前会返回「当前应用无权调用该开放接口」。本工具仅做接口暴露占位,供后续补充内部执行入口(绕过授权校验)后真正可用。

+ */ + @PostMapping("/{pageCode}/{formCode}/{operationCode}") + @McpToolClass(name = "openapi_route_execute", description = "执行 OpenAPI 开放接口配置(HTTP 由 token 鉴权;MCP 直调暂返回无权,仅占位)") + public ResponseResult execute(@PathVariable @McpToolParam(fieldName = "pageCode", type = McpToolParamType.STRING, description = "页面编码", required = true) String pageCode, + @PathVariable @McpToolParam(fieldName = "formCode", type = McpToolParamType.STRING, description = "表单编码", required = true) String formCode, + @PathVariable @McpToolParam(fieldName = "operationCode", type = McpToolParamType.STRING, description = "操作编码", required = true) String operationCode, + @RequestBody(required = false) @McpToolParam(fieldName = "requestBody", type = McpToolParamType.OBJECT, description = "请求体,key-value 形式(key 为参数字段,value 为字段值)") Map requestBody) { + String requestPath = openApiProperties.getUrlPrefix() + "/v1/" + pageCode + "/" + formCode + "/" + operationCode; + return openApiExecService.execute(requestPath, requestBody == null ? Collections.emptyMap() : requestBody); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenApiAppMapper.java b/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenApiAppMapper.java new file mode 100644 index 0000000..8bcd190 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenApiAppMapper.java @@ -0,0 +1,25 @@ +package apelet.common.openapi.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.openapi.model.OpenApiApp; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * OpenAPI 第三方应用数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiAppMapper extends BaseDaoMapper { + + /** + * 获取过滤后的应用列表。 + * + * @param openApiAppFilter 应用过滤对象。 + * @param orderBy 排序字符串,order by从句的参数。 + * @return 应用列表。 + */ + List getOpenApiAppList(@Param("openApiAppFilter") OpenApiApp openApiAppFilter, @Param("orderBy") String orderBy); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenApiConfigMapper.java b/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenApiConfigMapper.java new file mode 100644 index 0000000..46f6cc7 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenApiConfigMapper.java @@ -0,0 +1,36 @@ +package apelet.common.openapi.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.openapi.model.OpenApiConfig; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * OpenAPI 开放接口配置数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiConfigMapper extends BaseDaoMapper { + + /** + * 获取过滤后的配置列表。 + * + * @param openApiConfigFilter 配置过滤对象。 + * @param orderBy 排序字符串,order by从句的参数。 + * @return 配置列表。 + */ + List getOpenApiConfigList( + @Param("openApiConfigFilter") OpenApiConfig openApiConfigFilter, @Param("orderBy") String orderBy); + + /** + * 按应用Id 分页查询其已授权的配置列表(join 多对多授权表;status 为空时不带状态条件)。 + * + * @param appId 应用Id。 + * @param status 状态(0: 停用 1: 启用),可空。 + * @param orderBy 排序字符串,order by从句的参数。 + * @return 配置列表。 + */ + List getOpenApiConfigListByAppId(@Param("appId") Long appId, @Param("status") Integer status, @Param("orderBy") String orderBy); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenAppApiRelationMapper.java b/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenAppApiRelationMapper.java new file mode 100644 index 0000000..2daec3c --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/OpenAppApiRelationMapper.java @@ -0,0 +1,65 @@ +package apelet.common.openapi.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.openapi.model.OpenAppApiRelation; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * OpenAPI 应用-接口配置多对多关联数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-09-08 + */ +public interface OpenAppApiRelationMapper extends BaseDaoMapper { + + /** + * 批量插入授权关联行。 + * + * @param list 关联行集合。 + * @return 插入行数。 + */ + int batchInsert(@Param("list") List list); + + /** + * 删除指定接口配置的全部授权关联行。 + * + * @param apiId 开放接口配置Id。 + * @return 删除行数。 + */ + int deleteByApiId(@Param("apiId") Long apiId); + + /** + * 判断授权关联是否存在。 + * + * @param appId 应用Id。 + * @param apiId 开放接口配置Id。 + * @return 存在返回 true。 + */ + int existByAppIdAndApiId(@Param("appId") Long appId, @Param("apiId") Long apiId); + + /** + * 按接口配置Id 集合查询授权关联列表。 + * + * @param apiIdList 开放接口配置Id 集合。 + * @return 授权关联列表。 + */ + List selectByApiIdList(@Param("apiIdList") List apiIdList); + + /** + * 统计某应用的授权关联行数(应用删除拦截用)。 + * + * @param appId 应用Id。 + * @return 授权关联行数。 + */ + int countByAppId(@Param("appId") Long appId); + + /** + * 按应用Id 查询已授权接口配置Id 集合。 + * + * @param appId 应用Id。 + * @return 接口配置Id 集合。 + */ + List selectApiIdsByAppId(@Param("appId") Long appId); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenApiAppMapper.xml b/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenApiAppMapper.xml new file mode 100644 index 0000000..4e44d85 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenApiAppMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + AND xy_sys_open_app.app_code = #{openApiAppFilter.appCode} + + + + AND xy_sys_open_app.app_name LIKE #{safeAppName} + + + AND xy_sys_open_app.status = #{openApiAppFilter.status} + + + + + + diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenApiConfigMapper.xml b/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenApiConfigMapper.xml new file mode 100644 index 0000000..6c81ef0 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenApiConfigMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + AND xy_sys_open_api_config.page_id = #{openApiConfigFilter.pageId} + + + AND xy_sys_open_api_config.form_code = #{openApiConfigFilter.formCode} + + + AND xy_sys_open_api_config.status = #{openApiConfigFilter.status} + + + + + + + + diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenAppApiRelationMapper.xml b/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenAppApiRelationMapper.xml new file mode 100644 index 0000000..79fdb86 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/mapper/OpenAppApiRelationMapper.xml @@ -0,0 +1,38 @@ + + + + + + + + + + INSERT INTO xy_sys_open_app_api_relation (app_id, api_id) VALUES + + (#{item.appId}, #{item.apiId}) + + + + + DELETE FROM xy_sys_open_app_api_relation WHERE api_id = #{apiId} + + + + + + + + + + diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/document/OpenApiDocumentCustomizer.java b/common/common-openapi/src/main/java/apelet/common/openapi/document/OpenApiDocumentCustomizer.java new file mode 100644 index 0000000..bcfb70d --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/document/OpenApiDocumentCustomizer.java @@ -0,0 +1,175 @@ +package apelet.common.openapi.document; + +import apelet.common.openapi.constant.OpenApiFieldSource; +import apelet.common.openapi.constant.OpenApiParamMode; +import apelet.common.openapi.model.OpenApiConfig; +import apelet.common.openapi.service.OpenApiConfigService; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.customizers.OpenApiCustomiser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * OpenAPI 文档动态生成器:启动时扫描启用的开放接口配置,为每条配置生成一个 path + operation, + * schema 由 param_config / output_config 推导,嵌套结构生成嵌套 schema(knife4j /v3/api-docs 展示)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiDocumentCustomizer implements OpenApiCustomiser { + + @Autowired + private OpenApiConfigService openApiConfigService; + + @Override + public void customise(OpenAPI openApi) { + OpenApiConfig filter = new OpenApiConfig(); + filter.setStatus(1); + List configs = openApiConfigService.getOpenApiConfigList(filter, null); + for (OpenApiConfig config : configs) { + this.registerPath(openApi, config); + } + log.info("OpenAPI 文档自定义完成,共注册 {} 个开放接口 path", configs.size()); + } + + private void registerPath(OpenAPI openApi, OpenApiConfig config) { + if (openApi.getPaths() != null && openApi.getPaths().containsKey(config.getRequestPath())) { + return; + } + Operation operation = new Operation(); + operation.setOperationId(config.getOperationCode() + "_" + config.getFormCode()); + operation.setSummary(config.getOperationName()); + operation.setDescription(config.getOperationName()); + operation.addTagsItem(config.getFormCode()); + Schema requestSchema = this.buildRequestSchema(config); + if (requestSchema != null) { + operation.setRequestBody(new RequestBody().required(true) + .content(new Content().addMediaType("application/json", + new MediaType().schema(requestSchema)))); + } + Schema responseSchema = new Schema<>().type("object") + .addProperty("code", new Schema<>().type("integer")) + .addProperty("msg", new Schema<>().type("string")) + .addProperty("data", this.buildDataSchema(config.getOutputConfig())); + operation.responses(new ApiResponses().addApiResponse("200", + new ApiResponse().description("成功") + .content(new Content().addMediaType("application/json", + new MediaType().schema(responseSchema))))); + PathItem pathItem = new PathItem(); + if ("GET".equalsIgnoreCase(config.getRequestMethod())) { + openApi.path(config.getRequestPath(), pathItem.get(operation)); + } else { + openApi.path(config.getRequestPath(), pathItem.post(operation)); + } + } + + /** + * 请求体 schema:FIELD_DIRECT 取 fields,QUERY/CONDITION_MATCH 取 params;QUERY 追加 pageNo/pageSize。 + */ + private Schema buildRequestSchema(OpenApiConfig config) { + if (StrUtil.isBlank(config.getParamConfig())) { + return null; + } + JSONObject paramConfig = JSON.parseObject(config.getParamConfig()); + Schema root = new Schema<>().type("object"); + JSONArray props = OpenApiParamMode.FIELD_DIRECT.equals(config.getParamMode()) + ? paramConfig.getJSONArray("fields") + : paramConfig.getJSONArray("params"); + if (props != null) { + for (int i = 0; i < props.size(); i++) { + JSONObject p = props.getJSONObject(i); + String name = p.getString("alias"); + if (name == null) { + name = p.getString("name"); + } + if (StrUtil.isBlank(name)) { + continue; + } + Schema schema = this.buildSchema(p.getString("fieldType"), p.getString("source")); + if (StrUtil.isNotBlank(p.getString("description"))) { + schema.setDescription(p.getString("description")); + } + root.addProperty(name, schema); + } + } + if (OpenApiParamMode.QUERY.equals(config.getParamMode())) { + root.addProperty("pageNo", new Schema<>().type("integer")); + root.addProperty("pageSize", new Schema<>().type("integer")); + } + return root; + } + + /** + * 输出 schema:ResponseResult.data 的层级结构(dataFields,支持一对多分录嵌套)。 + */ + private Schema buildDataSchema(String outputConfigJson) { + Schema root = new Schema<>().type("object"); + if (StrUtil.isBlank(outputConfigJson)) { + return root; + } + JSONObject outputConfig = JSON.parseObject(outputConfigJson); + JSONArray dataFields = outputConfig.getJSONArray("dataFields"); + if (dataFields == null) { + return root; + } + for (int i = 0; i < dataFields.size(); i++) { + JSONObject df = dataFields.getJSONObject(i); + String alias = df.getString("alias"); + if (StrUtil.isBlank(alias)) { + continue; + } + if (OpenApiFieldSource.ENTRY_ARRAY.equals(df.getString("target"))) { + Schema itemSchema = this.buildEntryItemSchema(df.getJSONArray("children")); + root.addProperty(alias, new ArraySchema().items(itemSchema)); + } else { + root.addProperty(alias, new Schema<>().type("string")); + } + } + return root; + } + + private Schema buildEntryItemSchema(JSONArray children) { + Schema item = new Schema<>().type("object"); + if (children != null) { + for (int i = 0; i < children.size(); i++) { + JSONObject child = children.getJSONObject(i); + String alias = child.getString("alias"); + if (StrUtil.isBlank(alias)) { + continue; + } + item.addProperty(alias, new Schema<>().type("string")); + } + } + return item; + } + + private Schema buildSchema(String fieldType, String source) { + if ("array".equals(fieldType) || OpenApiFieldSource.ENTRY_ARRAY.equals(source)) { + return new ArraySchema().items(new Schema<>()); + } + if ("integer".equals(fieldType) || "number".equals(fieldType) || "boolean".equals(fieldType)) { + return new Schema<>().type(fieldType); + } + return new Schema<>().type("string"); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiConfigFilterDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiConfigFilterDto.java new file mode 100644 index 0000000..1dec947 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiConfigFilterDto.java @@ -0,0 +1,30 @@ +package apelet.common.openapi.dto; + +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * OpenAPI 开放接口配置列表查询条件Dto对象。 + * + * @author chenchuchuan + * @date 2026-09-08 + */ +@Schema(description = "OpenAPI开放接口配置列表查询条件Dto对象") +@Data +public class OpenApiConfigFilterDto { + + /** + * 应用Id(传入时仅返回该应用已授权的配置)。 + */ + @Schema(description = "应用Id,传入时仅返回该应用已授权的配置") + @McpToolParam(fieldName = "appId", type = McpToolParamType.INTEGER, description = "应用Id,传入时仅返回该应用已授权的配置") + private Long appId; + /** + * 状态(0: 停用 1: 启用)。 + */ + @Schema(description = "状态(0: 停用 1: 启用)") + @McpToolParam(fieldName = "status", type = McpToolParamType.INTEGER, description = "状态(0: 停用 1: 启用)") + private Integer status; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiFieldDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiFieldDto.java new file mode 100644 index 0000000..c8cb5b1 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiFieldDto.java @@ -0,0 +1,44 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +import java.util.List; + +/** + * OpenAPI 字段树节点(用于开放接口配置时选择字段,支持主表字段/关联字段属性/一对多分录)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +public class OpenApiFieldDto { + + /** + * 字段来源:FORM_FIELD / RELATION_FIELD / ENTRY_ARRAY / ENTRY_FIELD。 + */ + private String source; + /** + * 字段名(FORM_FIELD/ENTRY_FIELD),或关联存储字段(RELATION_FIELD)。 + */ + private String field; + /** + * RELATION_FIELD 的属性取值:id / number / name。 + */ + private String attr; + /** + * ENTRY_ARRAY / ENTRY_FIELD 所属的一对多分录附表表名。 + */ + private String table; + /** + * 接口字段名(alias)。 + */ + private String alias; + /** + * 展示名称。 + */ + private String label; + /** + * ENTRY_ARRAY 下的分录字段列表。 + */ + private List children; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiFormOptionDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiFormOptionDto.java new file mode 100644 index 0000000..a122fde --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiFormOptionDto.java @@ -0,0 +1,25 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * OpenAPI 配置页面表单下拉项。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiFormOptionDto { + + /** + * 在线表单主键Id。 + */ + private Long formId; + /** + * 表单编码。 + */ + private String formCode; + /** + * 表单名称。 + */ + private String formName; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiOperationDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiOperationDto.java new file mode 100644 index 0000000..06fbc9c --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiOperationDto.java @@ -0,0 +1,26 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * OpenAPI 可选操作下拉项(查询 + widgetJson.operationList 各项)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +public class OpenApiOperationDto { + + /** + * 操作编码:query 或 operationList 中操作的 code。 + */ + private String code; + /** + * 操作名称:查询 或 operationList 中操作的 name。 + */ + private String name; + /** + * 是否启用。 + */ + private Boolean enabled; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiPageOptionDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiPageOptionDto.java new file mode 100644 index 0000000..9d77768 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiPageOptionDto.java @@ -0,0 +1,25 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * OpenAPI 配置页面上线页面下拉项。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiPageOptionDto { + + /** + * 在线页面主键Id。 + */ + private Long pageId; + /** + * 页面编码。 + */ + private String pageCode; + /** + * 页面名称。 + */ + private String pageName; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiTokenDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiTokenDto.java new file mode 100644 index 0000000..4786b68 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiTokenDto.java @@ -0,0 +1,31 @@ +package apelet.common.openapi.dto; + +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import lombok.Data; + +/** + * OpenAPI 按用户名换取 token 的请求体。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +public class OpenApiTokenDto { + + /** + * 应用编码。 + */ + @McpToolParam(fieldName = "appCode", type = McpToolParamType.STRING, description = "应用编码", required = true) + private String appCode; + /** + * 应用密钥。 + */ + @McpToolParam(fieldName = "appSecret", type = McpToolParamType.STRING, description = "应用密钥", required = true) + private String appSecret; + /** + * 调用用户名。 + */ + @McpToolParam(fieldName = "username", type = McpToolParamType.STRING, description = "调用用户名", required = true) + private String username; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiUserInfo.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiUserInfo.java new file mode 100644 index 0000000..771ebf7 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiUserInfo.java @@ -0,0 +1,30 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * OpenAPI 按用户名换取的调用用户信息(由接入方实现 OpenApiUserService 提供)。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +public class OpenApiUserInfo { + + /** + * 用户Id。 + */ + private Long userId; + /** + * 登录名。 + */ + private String loginName; + /** + * 显示名称。 + */ + private String showName; + /** + * 租户Id。 + */ + private Long tenantId; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java b/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java new file mode 100644 index 0000000..2bd33bb --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java @@ -0,0 +1,55 @@ +package apelet.common.openapi.interceptor; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.openapi.config.OpenApiProperties; +import apelet.common.openapi.service.OpenApiAuthService; +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * OpenAPI 对外开放路由({urlPrefix}/v1/**)的 token 校验拦截器。 + *

在 SpringMVC 拦截器链中注册(见接入方 InterceptorConfig),只拦截 {urlPrefix}/v1/**; + * token 签发(/auth/token)与配置管理(/config/**、/app/**,走应用自身登录态)不经过本拦截器。

+ * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiAuthInterceptor implements HandlerInterceptor { + + @Autowired + private OpenApiAuthService openApiAuthService; + @Autowired + private OpenApiProperties openApiProperties; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + // 兜底:即便注册路径放开,也仅对对外开放的动态路由做 token 校验 + if (!request.getRequestURI().startsWith(openApiProperties.getUrlPrefix() + "/v1/")) { + return true; + } + String token = request.getHeader("Authorization"); + TokenData tokenData = openApiAuthService.validateToken(token); + if (tokenData == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(JSON.toJSONString( + ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "OpenAPI token 无效或已过期!"))); + return false; + } + TokenData.addToRequest(tokenData); + return true; + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenApiApp.java b/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenApiApp.java new file mode 100644 index 0000000..1907b46 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenApiApp.java @@ -0,0 +1,77 @@ +package apelet.common.openapi.model; + +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * OpenAPI 第三方应用实体对象。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +@TableName(value = "xy_sys_open_app") +public class OpenApiApp { + + /** + * 主键Id。 + */ + @TableId(value = "id") + @McpToolParam(fieldName = "id", type = McpToolParamType.INTEGER, description = "主键Id(新增时不传)") + private Long id; + /** + * 应用编码(对外暴露唯一)。 + */ + @TableField(value = "app_code") + @McpToolParam(fieldName = "appCode", type = McpToolParamType.STRING, description = "应用编码(对外唯一;新增时不传则自动生成)") + private String appCode; + /** + * 应用密钥(仅后台可见,可重置)。 + */ + @TableField(value = "app_secret") + private String appSecret; + /** + * 应用名称。 + */ + @TableField(value = "app_name") + @McpToolParam(fieldName = "appName", type = McpToolParamType.STRING, description = "应用名称", required = true) + private String appName; + /** + * 归属租户Id(本期默认为空,为空表示不限租户)。 + */ + @TableField(value = "tenant_id") + @McpToolParam(fieldName = "tenantId", type = McpToolParamType.INTEGER, description = "归属租户Id(为空表示不限租户)") + private Long tenantId; + /** + * 状态(0: 停用 1: 启用)。 + */ + @TableField(value = "status") + @McpToolParam(fieldName = "status", type = McpToolParamType.INTEGER, description = "状态(0: 停用 1: 启用)") + private Integer status; + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + /** + * 创建者Id。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + /** + * 更新者Id。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenApiConfig.java b/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenApiConfig.java new file mode 100644 index 0000000..9434c03 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenApiConfig.java @@ -0,0 +1,147 @@ +package apelet.common.openapi.model; + +import apelet.common.core.base.mapper.BaseModelMapper; +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import apelet.common.openapi.vo.OpenApiConfigVo; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.Date; + +/** + * OpenAPI 开放接口配置实体对象。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Data +@TableName(value = "xy_sys_open_api_config") +public class OpenApiConfig { + + /** + * 主键Id。 + */ + @TableId(value = "id") + @McpToolParam(fieldName = "id", type = McpToolParamType.INTEGER, description = "主键Id(新增时不传)") + private Long id; + /** + * 在线页面主键(OnlinePage.pageId)。 + */ + @TableField(value = "page_id") + @McpToolParam(fieldName = "pageId", type = McpToolParamType.INTEGER, description = "在线页面主键Id", required = true) + private Long pageId; + /** + * 页面编码(冗余存储,用于组装开放路径)。 + */ + @TableField(value = "page_code") + @McpToolParam(fieldName = "pageCode", type = McpToolParamType.STRING, description = "页面编码(不传时按 pageId 反查)") + private String pageCode; + /** + * 该页面下关联的在线表单编码(OnlineForm.formCode,用于组装开放路径 URL)。 + */ + @TableField(value = "form_code") + @McpToolParam(fieldName = "formCode", type = McpToolParamType.STRING, description = "在线表单编码", required = true) + private String formCode; + /** + * 关联的在线表单主键Id(OnlineForm.formId),执行时优先按 formId 精确定位表单。 + */ + @TableField(value = "form_id") + @McpToolParam(fieldName = "formId", type = McpToolParamType.INTEGER, description = "在线表单主键Id", required = true) + private Long formId; + /** + * 操作编码:query 或 operationList 中操作的 code。 + */ + @TableField(value = "operation_code") + @McpToolParam(fieldName = "operationCode", type = McpToolParamType.STRING, description = "操作编码:query 或操作按钮的 code", required = true) + private String operationCode; + /** + * 操作名称:查询 或 operationList 中操作的 name。 + */ + @TableField(value = "operation_name") + @McpToolParam(fieldName = "operationName", type = McpToolParamType.STRING, description = "操作名称:查询 / 操作按钮 name") + private String operationName; + /** + * 参数模式(后端自动推导):FIELD_DIRECT / CONDITION_MATCH / QUERY。 + */ + @TableField(value = "param_mode") + private String paramMode; + /** + * 开放路径:/openapi/v1/{pageCode}/{formCode}/{operationCode},全局唯一。 + */ + @TableField(value = "request_path") + private String requestPath; + /** + * 请求方法 GET / POST。 + */ + @TableField(value = "request_method") + @McpToolParam(fieldName = "requestMethod", type = McpToolParamType.STRING, description = "请求方法(默认 POST)") + private String requestMethod; + /** + * JSON:输入参数+条件配置(见设计文档 6.1)。 + */ + @TableField(value = "param_config") + @McpToolParam(fieldName = "paramConfig", type = McpToolParamType.STRING, description = "参数/条件配置 JSON(字段直传填 fields,条件匹配/查询填 conditions)") + private String paramConfig; + /** + * JSON:输出层级结构(见设计文档 6.2)。 + */ + @TableField(value = "output_config") + @McpToolParam(fieldName = "outputConfig", type = McpToolParamType.STRING, description = "输出层级配置 JSON(dataFields)") + private String outputConfig; + /** + * 状态(0: 停用 1: 启用)。 + */ + @TableField(value = "status") + @McpToolParam(fieldName = "status", type = McpToolParamType.INTEGER, description = "状态(0: 停用 1: 启用,不传默认 1)") + private Integer status; + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + /** + * 创建者Id。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + /** + * 更新者Id。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; + + /** + * OpenApiConfig 与 OpenApiConfigVo 的转换器(MapStruct,appCodes 引用编码集合由业务层单独填充)。 + */ + + @Mapper + public interface OpenApiConfigModelMapper extends BaseModelMapper { + /** + * 转换Vo对象到实体对象。 + * + * @param openApiConfigVo 域对象。 + * @return 实体对象。 + */ + @Override + OpenApiConfig toModel(OpenApiConfigVo openApiConfigVo); + /** + * 转换实体对象到VO对象。 + * + * @param openApiConfig 实体对象。 + * @return 域对象。 + */ + @Override + OpenApiConfigVo fromModel(OpenApiConfig openApiConfig); + } + public static final OpenApiConfigModelMapper INSTANCE = Mappers.getMapper(OpenApiConfigModelMapper.class); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenAppApiRelation.java b/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenAppApiRelation.java new file mode 100644 index 0000000..52a87d6 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/model/OpenAppApiRelation.java @@ -0,0 +1,32 @@ +package apelet.common.openapi.model; + +import apelet.common.core.mcp.annotation.McpToolParam; +import apelet.common.core.mcp.model.McpToolParamType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * OpenAPI 应用-接口配置多对多关联实体对象。 + *

多对多关联表统一极简设计:仅业务 id 字段 + 联合主键(即唯一索引),无自增主键/审计字段。

+ * + * @author chenchuchuan + * @date 2026-09-08 + */ +@Data +@TableName(value = "xy_sys_open_app_api_relation") +public class OpenAppApiRelation { + + /** + * 应用Id(xy_sys_open_app.id)。 + */ + @TableField(value = "app_id") + @McpToolParam(fieldName = "appId", type = McpToolParamType.INTEGER, description = "应用Id") + private Long appId; + /** + * 开放接口配置Id(xy_sys_open_api_config.id)。 + */ + @TableField(value = "api_id") + @McpToolParam(fieldName = "apiId", type = McpToolParamType.INTEGER, description = "开放接口配置Id") + private Long apiId; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiAppService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiAppService.java new file mode 100644 index 0000000..453b974 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiAppService.java @@ -0,0 +1,48 @@ +package apelet.common.openapi.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.openapi.model.OpenApiApp; + +import java.util.List; + +/** + * OpenAPI 第三方应用数据操作服务接口。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiAppService extends IBaseService { + + /** + * 获取过滤后的应用列表。 + * + * @param filter 过滤对象。 + * @param orderBy 排序字符串。 + * @return 应用列表。 + */ + List getOpenApiAppList(OpenApiApp filter, String orderBy); + + /** + * 新增应用:appCode 为空时自动生成,appSecret 自动生成并返回明文。 + * + * @param app 应用对象。 + * @return 保存后的应用(含明文 appSecret)。 + */ + OpenApiApp addNew(OpenApiApp app); + + /** + * 重置应用密钥:旧密钥立即失效。 + * + * @param id 应用主键Id。 + * @return 新的明文 appSecret。 + */ + String resetSecret(Long id); + + /** + * 删除应用(已被分配开放接口时拒绝,返回 false)。 + * + * @param id 应用主键Id。 + * @return 删除成功返回 true;应用不存在或已被分配返回 false。 + */ + boolean removeAppWithRelationCheck(Long id); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiAuthService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiAuthService.java new file mode 100644 index 0000000..b2890d7 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiAuthService.java @@ -0,0 +1,32 @@ +package apelet.common.openapi.service; + +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import com.alibaba.fastjson.JSONObject; + +/** + * OpenAPI 鉴权服务接口:token 签发与校验。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiAuthService { + + /** + * 校验应用凭证 + 调用用户名,签发 token。 + * + * @param appCode 应用编码。 + * @param appSecret 应用密钥。 + * @param username 调用用户名。 + * @return data 含 accessToken(不含 Bearer 前缀)与 expiresIn。 + */ + ResponseResult issueToken(String appCode, String appSecret, String username); + + /** + * 校验 Bearer token:验签 + 有效期 + Redis 会话存在性,还原 TokenData。 + * + * @param token Authorization 头值(含 Bearer 前缀)。 + * @return TokenData,无效或已失效返回null。 + */ + TokenData validateToken(String token); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiConfigService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiConfigService.java new file mode 100644 index 0000000..4f46bd4 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiConfigService.java @@ -0,0 +1,119 @@ +package apelet.common.openapi.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.openapi.dto.OpenApiFieldDto; +import apelet.common.openapi.dto.OpenApiFormOptionDto; +import apelet.common.openapi.dto.OpenApiOperationDto; +import apelet.common.openapi.dto.OpenApiPageOptionDto; +import apelet.common.openapi.model.OpenApiConfig; +import apelet.common.openapi.model.OpenAppApiRelation; + +import java.util.List; + +/** + * OpenAPI 开放接口配置数据操作服务接口。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiConfigService extends IBaseService { + + /** + * 获取过滤后的配置列表。 + * + * @param filter 过滤对象。 + * @param orderBy 排序字符串。 + * @return 配置列表。 + */ + List getOpenApiConfigList(OpenApiConfig filter, String orderBy); + + /** + * 列表统一查询入口:appId 有值时按应用已授权过滤(join junction,SQL 内按需叠加 status),否则走普通过滤查询。 + * + * @param filter 查询条件Dto(appId/status 均可空)。 + * @param orderBy 排序字符串。 + * @return 配置列表。 + */ + List getOpenApiConfigListByFilter(apelet.common.openapi.dto.OpenApiConfigFilterDto filter, String orderBy); + + /** + * 获取在线页面下拉列表(配置步骤1),可按页面名称模糊搜索。 + * + * @param keyword 页面名称关键字,为空返回全部。 + * @return 页面列表。 + */ + List getPageOptionList(String keyword); + + /** + * 获取指定页面关联的表单下拉列表(配置步骤2 联动)。 + * + * @param pageId 页面主键Id。 + * @return 表单列表。 + */ + List getFormOptionList(Long pageId); + + /** + * 获取可选操作下拉:查询(query) + widgetJson.operationList 各项(配置步骤3)。 + * + * @param formId 在线表单主键Id。 + * @return 操作列表。 + */ + List getOperationList(Long formId); + + /** + * 获取表单可配置字段树:主表字段 + 关联字段属性 + 一对多分录附表字段。 + * + * @param formId 在线表单主键Id。 + * @return 字段树。 + */ + List getFieldTree(Long formId); + + /** + * 新增配置:后端推导 paramMode、组装 requestPath。 + * + * @param config 配置对象。 + * @return 保存后的配置。 + */ + OpenApiConfig addNew(OpenApiConfig config); + + /** + * 更新配置:重新推导 paramMode、组装 requestPath。 + * + * @param config 配置对象。 + * @return 更新后的配置,不存在返回null。 + */ + OpenApiConfig updateConfig(OpenApiConfig config); + + /** + * 按开放路径查找启用的配置(动态路由执行用)。 + * + * @param requestPath 开放路径。 + * @return 配置对象。 + */ + OpenApiConfig getByRequestPath(String requestPath); + + /** + * 分配应用:全量替换该接口配置的应用授权(先删该 configId 全部授权行,再按 appIds 插入;空集合即清空)。 + * + * @param configId 开放接口配置Id。 + * @param appIds 授权应用Id 集合(自动去重)。 + * @return 替换后该配置已授权的应用Id 集合。 + */ + List assignApps(Long configId, List appIds); + + /** + * 查询指定接口配置已授权的应用编码集合(详情 VO 回显用)。 + * + * @param configId 开放接口配置Id。 + * @return 已授权应用的应用编码集合(xy_sys_open_app.app_code)。 + */ + List getAppCodesByConfigId(Long configId); + + /** + * 批量获取接口配置的授权关联列表(app_id + api_id,一次查询;单个 configId 用单元素集合传入)。 + * + * @param configIdList 开放接口配置Id 集合。 + * @return 授权关联列表。 + */ + List getAppApiRelationListByConfigIdList(List configIdList); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiExecService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiExecService.java new file mode 100644 index 0000000..e6a49f8 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiExecService.java @@ -0,0 +1,23 @@ +package apelet.common.openapi.service; + +import apelet.common.core.object.ResponseResult; + +import java.util.Map; + +/** + * OpenAPI 开放接口执行服务接口:按配置定位表单并执行。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiExecService { + + /** + * 按开放路径执行开放接口(鉴权由拦截器完成,本方法假定已注入 TokenData)。 + * + * @param requestPath 开放路径(/openapi/v1/{pageCode}/{formCode}/{operationCode})。 + * @param requestParams 请求体参数(扁平 JSON,含 pageNo/pageSize 或字段直传/条件参数)。 + * @return 执行结果。 + */ + ResponseResult execute(String requestPath, Map requestParams); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiOnlineQueryService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiOnlineQueryService.java new file mode 100644 index 0000000..2d4c500 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiOnlineQueryService.java @@ -0,0 +1,49 @@ +package apelet.common.openapi.service; + +import apelet.common.online.model.OnlineForm; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * OpenAPI 在线表数据查询辅助接口(运行在在线数据源上下文)。 + *

用于关联字段反查等需要直接查询在线表(zz_online_*)的场景,避免在普通上下文误查默认库。

+ * + * @author chenchuchuan + * @date 2026-09-03 + */ +public interface OpenApiOnlineQueryService { + + /** + * 关联字段反查:按 master 表单的关联字段(主表存储列)定位关联表,用 attr 列匹配值,返回关联记录主键。 + * + * @param masterForm 主表单(含 masterTableId)。 + * @param masterColumnName 主表存储列名(onl_form_field.field_name,如 customer_id)。 + * @param attrColumn 关联表要匹配的列名(如 customer_code / customer_name / id)。 + * @param attrValue 关联表该列要匹配的值。 + * @return 关联记录主键;未找到返回 null。 + */ + Object resolveRelatedId(OnlineForm masterForm, String masterColumnName, String attrColumn, Object attrValue); + + /** + * 关联字段批量反查:返回关联表中 attr 列 = attrValue 的**全部**记录主键集合(条件过滤用)。 + * + * @param masterForm 主表单(含 masterTableId)。 + * @param masterColumnName 主表存储列名。 + * @param attrColumn 关联表要匹配的列名。 + * @param attrValue 关联表该列要匹配的值。 + * @return 匹配到的关联记录主键集合(可能为空,不会为 null)。 + */ + Set resolveRelatedIds(OnlineForm masterForm, String masterColumnName, String attrColumn, Object attrValue); + + /** + * 查询一对多分录(附表)数据:按 master 行主键查子表关联行(用于输出配置的 ENTRY_ARRAY)。 + * + * @param masterForm 主表单。 + * @param masterId master 行主键值。 + * @param subTableName 子表表名(一对多分录附表)。 + * @return 子表行列表(可能为空,不会为 null)。 + */ + List> querySubTableRows(OnlineForm masterForm, Object masterId, String subTableName); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiUserService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiUserService.java new file mode 100644 index 0000000..443f96c --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiUserService.java @@ -0,0 +1,23 @@ +package apelet.common.openapi.service; + +import apelet.common.core.object.ResponseResult; +import apelet.common.openapi.dto.OpenApiUserInfo; + +/** + * OpenAPI 调用用户查询 SPI:由接入方(如 tenant-admin)提供实现,按登录名查询可模拟身份的用户。 + *

common-openapi 不依赖应用层用户服务,通过 Spring 自动装配该接口;未装配时按用户名签发 token 返回明确错误。

+ * + * @author chenchuchuan + * @date 2026-09-02 + */ +public interface OpenApiUserService { + + /** + * 按登录名查询用户。 + * + * @param loginName 登录名。 + * @param tenantId 应用归属租户Id(为空表示不限租户,全局查)。 + * @return 用户信息。 + */ + ResponseResult getByLoginName(String loginName, Long tenantId); +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAppServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAppServiceImpl.java new file mode 100644 index 0000000..1ce4db5 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAppServiceImpl.java @@ -0,0 +1,97 @@ +package apelet.common.openapi.service.impl; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.object.TokenData; +import apelet.common.openapi.dao.OpenApiAppMapper; +import apelet.common.openapi.dao.OpenAppApiRelationMapper; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.service.OpenApiAppService; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; + +/** + * OpenAPI 第三方应用数据操作服务类。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Slf4j +@Service("openApiAppService") +public class OpenApiAppServiceImpl extends BaseService implements OpenApiAppService { + + @Autowired + private OpenApiAppMapper openApiAppMapper; + @Autowired + private OpenAppApiRelationMapper openAppApiRelationMapper; + + @Override + protected BaseDaoMapper mapper() { + return openApiAppMapper; + } + + @Override + public List getOpenApiAppList(OpenApiApp filter, String orderBy) { + if (filter == null) { + filter = new OpenApiApp(); + } + return openApiAppMapper.getOpenApiAppList(filter, orderBy); + } + + @Override + public OpenApiApp addNew(OpenApiApp app) { + app.setId(IdUtil.getSnowflakeNextId()); + if (StrUtil.isBlank(app.getAppCode())) { + app.setAppCode("open_app_" + RandomUtil.randomString(8)); + } + app.setAppSecret(RandomUtil.randomString(32)); + Date now = new Date(); + TokenData tokenData = TokenData.takeFromRequest(); + Long currentUserId = tokenData == null ? null : tokenData.getUserId(); + app.setCreateTime(now); + app.setCreateUserId(currentUserId); + app.setUpdateTime(now); + app.setUpdateUserId(currentUserId); + if (app.getStatus() == null) { + app.setStatus(1); + } + this.save(app); + return app; + } + + @Override + public String resetSecret(Long id) { + OpenApiApp app = this.getById(id); + if (app == null) { + return null; + } + String newSecret = RandomUtil.randomString(32); + app.setAppSecret(newSecret); + app.setUpdateTime(new Date()); + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData != null) { + app.setUpdateUserId(tokenData.getUserId()); + } + this.updateById(app); + return newSecret; + } + + @Override + public boolean removeAppWithRelationCheck(Long id) { + if (id == null || this.getById(id) == null) { + return false; + } + // 配置与应用的关联已全部走授权关联表,存在授权行即禁止删除 + if (openAppApiRelationMapper.countByAppId(id) > 0) { + return false; + } + return this.removeById(id); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java new file mode 100644 index 0000000..d1f66fd --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java @@ -0,0 +1,136 @@ +package apelet.common.openapi.service.impl; + +import apelet.common.core.constant.AppDeviceType; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.JwtUtil; +import apelet.common.core.util.RedisKeyUtil; +import apelet.common.openapi.config.OpenApiProperties; +import apelet.common.openapi.dto.OpenApiUserInfo; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.service.OpenApiAppService; +import apelet.common.openapi.service.OpenApiAuthService; +import apelet.common.openapi.service.OpenApiUserService; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import io.jsonwebtoken.Claims; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * OpenAPI 鉴权服务实现。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Slf4j +@Service("openApiAuthService") +public class OpenApiAuthServiceImpl implements OpenApiAuthService { + + /** + * JWT claims 中的会话Id键(与应用登录 token 保持一致)。 + */ + private static final String CLAIM_SESSION_ID = "sessionId"; + /** + * Bearer 前缀(JwtUtil 生成结果自带,对外返回时去除)。 + */ + private static final String BEARER_PREFIX = "Bearer "; + + @Autowired + private OpenApiAppService openApiAppService; + @Autowired + private RedissonClient redissonClient; + @Autowired + private OpenApiProperties openApiProperties; + @Autowired(required = false) + private OpenApiUserService openApiUserService; + + @Override + public ResponseResult issueToken(String appCode, String appSecret, String username) { + if (StrUtil.hasBlank(appCode, appSecret, username)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "应用编码、密钥、用户名均不能为空!"); + } + // ① 校验应用凭证与状态 + OpenApiApp app = openApiAppService.getOne(new LambdaQueryWrapper().eq(OpenApiApp::getAppCode, appCode)); + if (app == null || !app.getAppSecret().equals(appSecret)) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "应用编码或密钥错误!"); + } + if (app.getStatus() == null || app.getStatus() != 1) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "应用已被停用!"); + } + // ② 按用户名查询调用用户 + if (openApiUserService == null) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "未配置 OpenApiUserService 实现,无法按用户名签发 token!"); + } + ResponseResult userResult = openApiUserService.getByLoginName(username, app.getTenantId()); + if (!userResult.isSuccess() || userResult.getData() == null) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "调用用户不存在!"); + } + OpenApiUserInfo user = userResult.getData(); + // ③ 组装 TokenData,签发 JWT(claims 仅放 sessionId,会话状态存 Redis 可吊销) + TokenData tokenData = new TokenData(); + tokenData.setUserId(user.getUserId()); + tokenData.setLoginName(user.getLoginName()); + tokenData.setShowName(user.getShowName()); + tokenData.setTenantId(user.getTenantId()); + tokenData.setAppCode(appCode); + tokenData.setDeviceType(AppDeviceType.WEB); + tokenData.setIsAdmin(false); + String sessionId = "openapi_" + IdUtil.simpleUUID(); + tokenData.setSessionId(sessionId); + Map claims = new HashMap<>(3); + claims.put(CLAIM_SESSION_ID, sessionId); + Long expirationSeconds = openApiProperties.getTokenExpiration(); + String token = JwtUtil.generateToken(claims, expirationSeconds * 1000, openApiProperties.getTokenSigningKey()); + this.putTokenDataToSessionCache(tokenData, expirationSeconds); + JSONObject data = new JSONObject(); + data.put("accessToken", StrUtil.removePrefix(token, BEARER_PREFIX)); + data.put("expiresIn", expirationSeconds); + return ResponseResult.success(data); + } + + @Override + public TokenData validateToken(String token) { + Claims c = JwtUtil.parseToken(token, openApiProperties.getTokenSigningKey()); + if (JwtUtil.isNullOrExpired(c)) { + return null; + } + String sessionId = (String) c.get(CLAIM_SESSION_ID); + if (StrUtil.isBlank(sessionId)) { + return null; + } + String sessionIdKey = RedisKeyUtil.makeSessionIdKey(sessionId); + RBucket bucket = redissonClient.getBucket(sessionIdKey); + if (!bucket.isExists()) { + return null; + } + TokenData tokenData = JSON.parseObject(bucket.get(), TokenData.class); + if (tokenData != null) { + tokenData.setToken(token); + } + return tokenData; + } + + /** + * TokenData 写入 Redis 会话缓存(key 与应用登录会话共用 RedisKeyUtil.makeSessionIdKey,可吊销)。 + */ + private void putTokenDataToSessionCache(TokenData tokenData, Long expirationSeconds) { + String sessionIdKey = RedisKeyUtil.makeSessionIdKey(tokenData.getSessionId()); + String sessionData = JSON.toJSONString(tokenData, SerializerFeature.WriteNonStringValueAsString); + RBucket bucket = redissonClient.getBucket(sessionIdKey); + bucket.set(sessionData); + bucket.expire(expirationSeconds, TimeUnit.SECONDS); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiConfigServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiConfigServiceImpl.java new file mode 100644 index 0000000..617d3c7 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiConfigServiceImpl.java @@ -0,0 +1,374 @@ +package apelet.common.openapi.service.impl; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.object.TokenData; +import apelet.common.generator.model.OnlFormField; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormFieldService; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.online.model.OnlineForm; +import apelet.common.online.model.OnlinePage; +import apelet.common.online.model.OnlineTable; +import apelet.common.online.service.OnlineFormService; +import apelet.common.online.service.OnlinePageService; +import apelet.common.online.service.OnlineTableService; +import apelet.common.openapi.config.OpenApiProperties; +import apelet.common.openapi.constant.OpenApiFieldSource; +import apelet.common.openapi.constant.OpenApiParamMode; +import apelet.common.openapi.dao.OpenApiConfigMapper; +import apelet.common.openapi.dao.OpenAppApiRelationMapper; +import apelet.common.openapi.dto.*; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.model.OpenApiConfig; +import apelet.common.openapi.model.OpenAppApiRelation; +import apelet.common.openapi.service.OpenApiAppService; +import apelet.common.openapi.service.OpenApiConfigService; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * OpenAPI 开放接口配置数据操作服务类。 + * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Slf4j +@Service("openApiConfigService") +public class OpenApiConfigServiceImpl extends BaseService implements OpenApiConfigService { + + /** 查询操作的固定 operation_code。 */ + private static final String QUERY_CODE = "query"; + + @Autowired + private OpenApiConfigMapper openApiConfigMapper; + @Autowired + private OpenAppApiRelationMapper openAppApiRelationMapper; + @Autowired + private OpenApiAppService openApiAppService; + @Autowired + private OpenApiProperties openApiProperties; + @Autowired + private OnlinePageService onlinePageService; + @Autowired + private OnlineFormService onlineFormService; + @Autowired + private OnlineTableService onlineTableService; + @Autowired + private IOnlFormHeadService onlFormHeadService; + @Autowired + private IOnlFormFieldService onlFormFieldService; + + @Override + protected BaseDaoMapper mapper() { + return openApiConfigMapper; + } + + @Override + public List getOpenApiConfigList(OpenApiConfig filter, String orderBy) { + if (filter == null) { + filter = new OpenApiConfig(); + } + return openApiConfigMapper.getOpenApiConfigList(filter, orderBy); + } + + @Override + public List getOpenApiConfigListByFilter(OpenApiConfigFilterDto filter, String orderBy) { + if (filter == null || filter.getAppId() == null) { + OpenApiConfig configFilter = new OpenApiConfig(); + configFilter.setStatus(filter == null ? null : filter.getStatus()); + return openApiConfigMapper.getOpenApiConfigList(configFilter, orderBy); + } + return openApiConfigMapper.getOpenApiConfigListByAppId(filter.getAppId(), filter.getStatus(), orderBy); + } + + @Override + public List getPageOptionList(String keyword) { + // 按页面名称模糊搜索,空关键字返回全部;结果按创建时间倒序 + OnlinePage filter = null; + if (StrUtil.isNotBlank(keyword)) { + filter = new OnlinePage(); + filter.setPageName(keyword); + } + List pageList = onlinePageService.getOnlinePageList(filter, "create_time DESC"); + List result = new ArrayList<>(); + if (pageList != null) { + for (OnlinePage page : pageList) { + OpenApiPageOptionDto dto = new OpenApiPageOptionDto(); + dto.setPageId(page.getPageId()); + dto.setPageCode(page.getPageCode()); + dto.setPageName(page.getPageName()); + result.add(dto); + } + } + return result; + } + + @Override + public List getFormOptionList(Long pageId) { + List result = new ArrayList<>(); + if (pageId == null) { + return result; + } + List formList = onlineFormService.list( + new LambdaQueryWrapper().eq(OnlineForm::getPageId, pageId)); + if (formList != null) { + for (OnlineForm form : formList) { + OpenApiFormOptionDto dto = new OpenApiFormOptionDto(); + dto.setFormId(form.getFormId()); + dto.setFormCode(form.getFormCode()); + dto.setFormName(form.getFormName()); + result.add(dto); + } + } + return result; + } + + @Override + public List getOperationList(Long formId) { + List result = new ArrayList<>(); + // 合成「查询」选项 + OpenApiOperationDto query = new OpenApiOperationDto(); + query.setCode(QUERY_CODE); + query.setName("查询"); + query.setEnabled(true); + result.add(query); + OnlineForm form = formId == null ? null : onlineFormService.getById(formId); + if (form == null || StrUtil.isBlank(form.getWidgetJson())) { + return result; + } + JSONObject widgetJson = JSON.parseObject(form.getWidgetJson()); + // 兼容 pc/mobile,取第一个存在 operationList 的设备 + for (String device : new String[]{"pc", "mobile"}) { + JSONObject deviceObj = widgetJson.getJSONObject(device); + if (deviceObj == null) { + continue; + } + JSONArray opList = deviceObj.getJSONArray("operationList"); + if (opList == null || opList.isEmpty()) { + continue; + } + for (int i = 0; i < opList.size(); i++) { + JSONObject op = opList.getJSONObject(i); + if (op == null) { + continue; + } + OpenApiOperationDto dto = new OpenApiOperationDto(); + dto.setCode(op.getString("code")); + dto.setName(op.getString("name")); + dto.setEnabled(op.getBoolean("enabled")); + result.add(dto); + } + break; + } + return result; + } + + @Override + public List getFieldTree(Long formId) { + List result = new ArrayList<>(); + OnlineForm form = formId == null ? null : onlineFormService.getById(formId); + if (form == null || form.getMasterTableId() == null) { + return result; + } + OnlineTable masterTable = onlineTableService.getById(form.getMasterTableId()); + if (masterTable == null) { + return result; + } + OnlFormHead head = onlFormHeadService.getOne(new LambdaQueryWrapper() + .eq(OnlFormHead::getTableName, masterTable.getTableName())); + if (head == null) { + return result; + } + // 主表字段:普通字段 FORM_FIELD,关联字段(refPropert 非空)RELATION_FIELD(按 id/编码/名称 三个属性展开) + List masterFields = onlFormFieldService.list( + new LambdaQueryWrapper().eq(OnlFormField::getHeadId, head.getId())); + if (masterFields != null) { + for (OnlFormField field : masterFields) { + if (field.getRefPropert() != null) { + for (String attr : new String[]{"id", "number", "name"}) { + OpenApiFieldDto dto = new OpenApiFieldDto(); + dto.setSource(OpenApiFieldSource.RELATION_FIELD); + dto.setField(field.getFieldName()); + dto.setAttr(attr); + dto.setAlias(field.getFieldName()); + dto.setLabel(field.getFieldRemark()); + result.add(dto); + } + } else { + OpenApiFieldDto dto = new OpenApiFieldDto(); + dto.setSource(OpenApiFieldSource.FORM_FIELD); + dto.setField(field.getFieldName()); + dto.setAlias(field.getFieldName()); + dto.setLabel(field.getFieldRemark()); + result.add(dto); + } + } + } + // 一对多分录附表(childName 逗号分隔) + if (StrUtil.isNotBlank(head.getChildName())) { + for (String subTableName : head.getChildName().split(",")) { + subTableName = subTableName.trim(); + OnlFormHead subHead = onlFormHeadService.getOne(new LambdaQueryWrapper() + .eq(OnlFormHead::getTableName, subTableName)); + if (subHead == null) { + continue; + } + OpenApiFieldDto entryArray = new OpenApiFieldDto(); + entryArray.setSource(OpenApiFieldSource.ENTRY_ARRAY); + entryArray.setTable(subTableName); + entryArray.setAlias(subTableName); + entryArray.setLabel(subTableName); + List children = new ArrayList<>(); + List subFields = onlFormFieldService.list( + new LambdaQueryWrapper().eq(OnlFormField::getHeadId, subHead.getId())); + if (subFields != null) { + for (OnlFormField subField : subFields) { + OpenApiFieldDto dto = new OpenApiFieldDto(); + dto.setSource(OpenApiFieldSource.ENTRY_FIELD); + dto.setTable(subTableName); + dto.setField(subField.getFieldName()); + dto.setAlias(subField.getFieldName()); + dto.setLabel(subField.getFieldRemark()); + children.add(dto); + } + } + entryArray.setChildren(children); + result.add(entryArray); + } + } + return result; + } + + @Override + public OpenApiConfig addNew(OpenApiConfig config) { + config.setId(IdUtil.getSnowflakeNextId()); + config.setParamMode(this.resolveParamMode(config)); + config.setRequestPath(this.buildRequestPath(config)); + Date now = new Date(); + TokenData tokenData = TokenData.takeFromRequest(); + Long currentUserId = tokenData == null ? null : tokenData.getUserId(); + config.setCreateTime(now); + config.setCreateUserId(currentUserId); + config.setUpdateTime(now); + config.setUpdateUserId(currentUserId); + if (config.getStatus() == null) { + config.setStatus(1); + } + if (StrUtil.isBlank(config.getRequestMethod())) { + config.setRequestMethod("POST"); + } + this.save(config); + return config; + } + + @Override + public OpenApiConfig updateConfig(OpenApiConfig config) { + OpenApiConfig original = this.getById(config.getId()); + if (original == null) { + return null; + } + config.setParamMode(this.resolveParamMode(config)); + config.setRequestPath(this.buildRequestPath(config)); + config.setCreateTime(original.getCreateTime()); + config.setUpdateTime(new Date()); + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData != null) { + config.setUpdateUserId(tokenData.getUserId()); + } + this.updateById(config); + return config; + } + + @Override + public OpenApiConfig getByRequestPath(String requestPath) { + if (StrUtil.isBlank(requestPath)) { + return null; + } + return openApiConfigMapper.selectOne(new LambdaQueryWrapper().eq(OpenApiConfig::getRequestPath, requestPath)); + } + + @Override + public List assignApps(Long configId, List appIds) { + OpenApiConfig config = this.getById(configId); + if (config == null) { + throw new IllegalArgumentException("数据验证失败,开放接口配置不存在!"); + } + Set appIdSet = appIds == null ? new HashSet<>() : new LinkedHashSet<>(appIds); + // 全量替换:先清空该配置的授权,再按入参重插;空集合即清空授权 + openAppApiRelationMapper.deleteByApiId(configId); + if (!appIdSet.isEmpty()) { + List existApps = openApiAppService.list(new LambdaQueryWrapper().in(OpenApiApp::getId, appIdSet)); + if (existApps.size() < appIdSet.size()) { + throw new IllegalArgumentException("数据验证失败,存在无效的应用Id!"); + } + List relList = new ArrayList<>(appIdSet.size()); + for (Long appId : appIdSet) { + OpenAppApiRelation rel = new OpenAppApiRelation(); + rel.setAppId(appId); + rel.setApiId(configId); + relList.add(rel); + } + openAppApiRelationMapper.batchInsert(relList); + } + return new ArrayList<>(appIdSet); + } + + @Override + public List getAppCodesByConfigId(Long configId) { + if (configId == null) { + return new ArrayList<>(); + } + List relationList = this.getAppApiRelationListByConfigIdList(Collections.singletonList(configId)); + if (relationList.isEmpty()) { + return new ArrayList<>(); + } + List appIds = relationList.stream().map(OpenAppApiRelation::getAppId).collect(Collectors.toList()); + List appList = openApiAppService.list(new LambdaQueryWrapper().in(OpenApiApp::getId, appIds)); + return appList.stream().map(OpenApiApp::getAppCode).collect(Collectors.toList()); + } + + @Override + public List getAppApiRelationListByConfigIdList(List configIdList) { + if (configIdList == null || configIdList.isEmpty()) { + return new ArrayList<>(); + } + return openAppApiRelationMapper.selectByApiIdList(configIdList); + } + + /** + * 推导参数模式:query→QUERY;保存/提交→FIELD_DIRECT;其他→CONDITION_MATCH。 + */ + private String resolveParamMode(OpenApiConfig config) { + if (QUERY_CODE.equals(config.getOperationCode())) { + return OpenApiParamMode.QUERY; + } + if (StrUtil.equalsAny(config.getOperationName(), "保存", "提交")) { + return OpenApiParamMode.FIELD_DIRECT; + } + return OpenApiParamMode.CONDITION_MATCH; + } + + /** + * 组装开放路径:{urlPrefix}/v1/{pageCode}/{formCode}/{operationCode};pageCode 为空时按 pageId 反查。 + */ + private String buildRequestPath(OpenApiConfig config) { + if (StrUtil.isBlank(config.getPageCode()) && config.getPageId() != null) { + OnlinePage page = onlinePageService.getById(config.getPageId()); + if (page != null) { + config.setPageCode(page.getPageCode()); + } + } + return openApiProperties.getUrlPrefix() + "/v1/" + config.getPageCode() + "/" + config.getFormCode() + "/" + config.getOperationCode(); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiExecServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiExecServiceImpl.java new file mode 100644 index 0000000..480dd4d --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiExecServiceImpl.java @@ -0,0 +1,587 @@ +package apelet.common.openapi.service.impl; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.online.abstractplugin.model.GridData; +import apelet.common.online.dto.OnlineFilterDto; +import apelet.common.online.dto.OnlinePluginExecuteDto; +import apelet.common.online.model.OnlineDatasource; +import apelet.common.online.model.OnlineForm; +import apelet.common.online.model.OnlineTable; +import apelet.common.online.model.constant.FieldFilterType; +import apelet.common.online.service.OnlineDatasourceService; +import apelet.common.online.service.OnlineFormService; +import apelet.common.online.service.OnlineOperationService; +import apelet.common.online.service.OnlineTableService; +import apelet.common.online.util.OnlineOperationHelper; +import apelet.common.openapi.constant.OpenApiFieldSource; +import apelet.common.openapi.constant.OpenApiParamMode; +import apelet.common.openapi.dao.OpenAppApiRelationMapper; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.model.OpenApiConfig; +import apelet.common.openapi.service.OpenApiAppService; +import apelet.common.openapi.service.OpenApiConfigService; +import apelet.common.openapi.service.OpenApiExecService; +import apelet.common.openapi.service.OpenApiOnlineQueryService; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; + +/** + * OpenAPI 开放接口执行服务实现。 + *

按 param_mode 分派三种执行路径:QUERY 走列表插件、FIELD_DIRECT 走 executePlugin(字段直传)、 + * CONDITION_MATCH 先按条件匹配单据集合再逐条 executePlugin。

+ * + * @author chenchuchuan + * @date 2026-09-02 + */ +@Slf4j +@Service("openApiExecService") +public class OpenApiExecServiceImpl implements OpenApiExecService { + + @Autowired + private OpenApiConfigService openApiConfigService; + @Autowired + private OpenApiAppService openApiAppService; + @Autowired + private OpenAppApiRelationMapper openAppApiRelationMapper; + @Autowired + private OnlineFormService onlineFormService; + @Autowired + private OnlineTableService onlineTableService; + @Autowired + private IOnlFormHeadService onlFormHeadService; + @Autowired + private OnlineDatasourceService onlineDatasourceService; + @Autowired + private OnlineOperationService onlineOperationService; + @Autowired + private OnlineOperationHelper onlineOperationHelper; + @Autowired + private OpenApiOnlineQueryService openApiOnlineQueryService; + + @Override + public ResponseResult execute(String requestPath, Map requestParams) { + OpenApiConfig config = openApiConfigService.getByRequestPath(requestPath); + if (config == null || config.getStatus() == null || config.getStatus() != 1) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "开放接口不存在或已停用!"); + } + OnlineForm form = this.findForm(config); + if (form == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "在线表单不存在!"); + } + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null || StrUtil.isBlank(tokenData.getAppCode())) { + return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION, "当前应用无权调用该开放接口!"); + } + // 授权校验走多对多关联表:token.appCode → 应用 → junction (app_id, api_id) 存在才放行(一条接口配置可授权多个应用) + if (!this.hasAssignRelation(tokenData.getAppCode(), config.getId())) { + return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION, "当前应用无权调用该开放接口!"); + } + // 列表类操作(QUERY / CONDITION_MATCH)依赖 pc.tableWidget 走列表引擎,编辑页未配置列表时给出明确提示而非底层空指针。 + if (OpenApiParamMode.QUERY.equals(config.getParamMode()) + || OpenApiParamMode.CONDITION_MATCH.equals(config.getParamMode())) { + if (!this.hasListTableWidget(form)) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "该表单未配置列表(pc.tableWidget),无法执行查询/条件匹配操作!"); + } + } + // 写类操作(FIELD_DIRECT / CONDITION_MATCH)需目标按钮真实存在且启用并绑定插件,避免误配时 executePlugin 空跑却返回"成功"。 + if (OpenApiParamMode.FIELD_DIRECT.equals(config.getParamMode()) + || OpenApiParamMode.CONDITION_MATCH.equals(config.getParamMode())) { + if (!this.hasEnabledOperation(form, config.getOperationName())) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, + "操作按钮[" + config.getOperationName() + "]不存在、未启用或未绑定插件,无法执行!"); + } + } + // 在线引擎按 TokenData.appCode 校验数据源归属(空值表示非第三方应用/租户公共数据)。 + // 令牌 appCode 是开放应用编码,与在线资源归属编码不是一回事,执行期间先切到该表单数据源真正归属的编码,结束后还原。 + // 注意:归属编码用"不带归属校验"的直查获取——verifyAndGetDatasource 的归属校验在切换前必然失败,不能拿它取 appCode。 + String savedAppCode = tokenData.getAppCode(); + try { + OnlineDatasource datasource = form.getMasterTableId() == null ? null + : onlineDatasourceService.getOnlineDatasourceByMasterTableId(form.getMasterTableId()); + tokenData.setAppCode(datasource == null ? null : datasource.getAppCode()); + if (OpenApiParamMode.QUERY.equals(config.getParamMode())) { + return this.executeQuery(config, form, requestParams); + } + if (OpenApiParamMode.FIELD_DIRECT.equals(config.getParamMode())) { + return this.executeFieldDirect(config, form, requestParams); + } + return this.executeConditionMatch(config, form, requestParams); + } finally { + tokenData.setAppCode(savedAppCode); + } + } + + // ============ QUERY:列表插件查询 ============ + + private ResponseResult executeQuery(OpenApiConfig config, OnlineForm form, Map requestParams) { + JSONObject paramConfig = JSON.parseObject(config.getParamConfig()); + int pageNo = this.toIntParam(requestParams.get("pageNo"), 1, 1, Integer.MAX_VALUE); + int pageSize = this.toIntParam(requestParams.get("pageSize"), 10, 1, 1000); + MyPageParam pageParam = new MyPageParam(); + pageParam.setPageNum(pageNo); + pageParam.setPageSize(pageSize); + ResponseResult> filterResult = this.buildFilterDtoList(paramConfig, requestParams, form); + if (!filterResult.isSuccess()) { + return ResponseResult.errorFrom(filterResult); + } + List filterDtoList = filterResult.getData(); + ResponseResult datasourceResult = this.getDatasourceResult(form); + if (!datasourceResult.isSuccess()) { + return ResponseResult.errorFrom(datasourceResult); + } + OnlFormHead onlFormHead = this.getFormHead(datasourceResult); + if (onlFormHead == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "表单表头不存在!"); + } + GridData gridData = onlineOperationService.exeListPlugin(form.getFormId(), filterDtoList, null, pageParam, null, + onlFormHead, datasourceResult, null); + if (gridData == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_ACCESS_FAILED, "列表查询失败!"); + } + JSONObject data = new JSONObject(true); + data.put("total", gridData.getTotalCount()); + data.put("pageNo", pageNo); + data.put("pageSize", pageSize); + data.put("dataList", this.assembleDataList(gridData.getDataList(), config.getOutputConfig(), form)); + return ResponseResult.success(data); + } + + // ============ FIELD_DIRECT:字段直传 executePlugin ============ + + private ResponseResult executeFieldDirect(OpenApiConfig config, OnlineForm form, Map requestParams) { + JSONObject paramConfig = JSON.parseObject(config.getParamConfig()); + JSONArray fields = paramConfig == null ? null : paramConfig.getJSONArray("fields"); + if (fields == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "未配置字段直传参数!"); + } + JSONObject masterData = new JSONObject(true); + JSONObject slaveData = new JSONObject(true); + for (int i = 0; i < fields.size(); i++) { + JSONObject field = fields.getJSONObject(i); + String source = field.getString("source"); + String fieldName = field.getString("field"); + String table = field.getString("table"); + String alias = field.getString("alias"); + Object value = requestParams.get(alias); + if (OpenApiFieldSource.FORM_FIELD.equals(source)) { + masterData.put(fieldName, value); + } else if (OpenApiFieldSource.RELATION_FIELD.equals(source)) { + // attr = 关联表要匹配的列名;attr=id 直存,其他列先按关联表反查主键再写入主表存储列 + String attr = field.getString("attr"); + Object writeValue = value; + if (value != null && StrUtil.isNotBlank(attr) && !"id".equals(attr)) { + Object relatedId = openApiOnlineQueryService.resolveRelatedId(form, fieldName, attr, value); + if (relatedId != null) { + writeValue = relatedId; + } else { + log.warn("OpenAPI 关联字段 [{}] 按列 [{}] 未匹配到关联记录,值 [{}] 原样写入", fieldName, attr, value); + } + } + masterData.put(fieldName, writeValue); + } else if (OpenApiFieldSource.ENTRY_ARRAY.equals(source)) { + JSONArray entryArray = new JSONArray(); + if (value instanceof Collection) { + entryArray.addAll((Collection) value); + } + slaveData.put(table, entryArray); + } + // ENTRY_FIELD 作为 ENTRY_ARRAY 的子节点,值挂在分录数组元素上,无需单独处理 + } + OnlinePluginExecuteDto pluginDto = new OnlinePluginExecuteDto(); + pluginDto.setButtonName(config.getOperationName()); + pluginDto.setFormId(form.getFormId()); + // billId 由系统生成/定位,不作为接口入参 + Object result = onlineOperationService.executePlugin(pluginDto, masterData, slaveData); + return ResponseResult.success(result); + } + + // ============ CONDITION_MATCH:条件匹配单据后逐条 executePlugin ============ + + private ResponseResult executeConditionMatch(OpenApiConfig config, OnlineForm form, Map requestParams) { + JSONObject paramConfig = JSON.parseObject(config.getParamConfig()); + ResponseResult> filterResult = this.buildFilterDtoList(paramConfig, requestParams, form); + if (!filterResult.isSuccess()) { + return ResponseResult.errorFrom(filterResult); + } + List filterDtoList = filterResult.getData(); + ResponseResult datasourceResult = this.getDatasourceResult(form); + if (!datasourceResult.isSuccess()) { + return ResponseResult.errorFrom(datasourceResult); + } + OnlFormHead onlFormHead = this.getFormHead(datasourceResult); + if (onlFormHead == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "表单表头不存在!"); + } + MyPageParam pageParam = new MyPageParam(); + pageParam.setPageNum(1); + pageParam.setPageSize(2000); + GridData gridData = onlineOperationService.exeListPlugin(form.getFormId(), filterDtoList, null, pageParam, null, + onlFormHead, datasourceResult, null); + if (gridData == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_ACCESS_FAILED, "单据匹配失败!"); + } + OnlinePluginExecuteDto pluginDto = new OnlinePluginExecuteDto(); + pluginDto.setButtonName(config.getOperationName()); + pluginDto.setFormId(form.getFormId()); + JSONArray resultList = new JSONArray(); + for (Map row : gridData.getDataList()) { + Object id = row.get("id"); + if (id == null) { + continue; + } + pluginDto.setBillId(String.valueOf(id)); + resultList.add(onlineOperationService.executePlugin(pluginDto, null, null)); + } + return ResponseResult.success(resultList); + } + + // ============ 公共辅助 ============ + + /** + * 校验应用与接口配置的授权关系:appCode → xy_sys_open_app.id → xy_sys_open_app_api (app_id, api_id)。 + */ + private boolean hasAssignRelation(String appCode, Long configId) { + OpenApiApp app = openApiAppService.getOne(new LambdaQueryWrapper().eq(OpenApiApp::getAppCode, appCode)); + if (app == null) { + return false; + } + return openAppApiRelationMapper.existByAppIdAndApiId(app.getId(), configId) > 0; + } + + /** + * 定位在线表单:优先按 formId,未配置时回退 pageId + formCode。 + */ + private OnlineForm findForm(OpenApiConfig config) { + if (config.getFormId() != null) { + OnlineForm form = onlineFormService.getById(config.getFormId()); + if (form != null) { + return form; + } + } + return onlineFormService.getOne(new LambdaQueryWrapper() + .eq(OnlineForm::getPageId, config.getPageId()) + .eq(OnlineForm::getFormCode, config.getFormCode()), false); + } + + /** + * 取表单主表数据源(含 masterTable,供列表插件回退使用)。 + */ + private ResponseResult getDatasourceResult(OnlineForm form) { + if (form.getMasterTableId() == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "表单主表不存在!"); + } + OnlineDatasource datasource = onlineDatasourceService.getOnlineDatasourceByMasterTableId(form.getMasterTableId()); + if (datasource == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "表单数据源不存在!"); + } + return onlineOperationHelper.verifyAndGetDatasource(datasource.getDatasourceId()); + } + + /** + * 取列表查询用的在线表单表头。onl_form_head 以 table_name 为业务键(与 zz_online_form.form_id 不是同一 id), + * 必须按主表表名定位,而非按 OnlineForm.formId。 + */ + private OnlFormHead getFormHead(ResponseResult datasourceResult) { + if (datasourceResult == null || !datasourceResult.isSuccess() || datasourceResult.getData() == null + || datasourceResult.getData().getMasterTable() == null) { + return null; + } + return onlFormHeadService.getHeadTableNameCache(datasourceResult.getData().getMasterTable().getTableName()); + } + + /** + * 判断表单 widget 是否配置了 pc.tableWidget(列表查询/条件匹配的 ORM 回退路径依赖它,缺失会空指针)。 + */ + private boolean hasListTableWidget(OnlineForm form) { + if (form == null || form.getFormId() == null) { + return false; + } + OnlineForm cached = onlineFormService.getOnlineFormFromCache(form.getFormId()); + if (cached == null || StrUtil.isBlank(cached.getWidgetJson())) { + return false; + } + JSONObject widget = JSON.parseObject(cached.getWidgetJson()); + JSONObject pc = widget == null ? null : widget.getJSONObject("pc"); + return pc != null && pc.containsKey("tableWidget"); + } + + /** + * 判断表单 widget 是否存在「启用且绑定插件」的同名操作按钮(与 executePlugin 的按钮解析一致,取 pc.operationList)。 + */ + private boolean hasEnabledOperation(OnlineForm form, String buttonName) { + if (form == null || form.getFormId() == null || StrUtil.isBlank(buttonName)) { + return false; + } + OnlineForm cached = onlineFormService.getOnlineFormFromCache(form.getFormId()); + if (cached == null || StrUtil.isBlank(cached.getWidgetJson())) { + return false; + } + JSONObject pc = JSON.parseObject(cached.getWidgetJson()).getJSONObject("pc"); + JSONArray ops = pc == null ? null : pc.getJSONArray("operationList"); + if (ops == null) { + return false; + } + for (int i = 0; i < ops.size(); i++) { + JSONObject op = ops.getJSONObject(i); + if (op == null || !buttonName.equals(op.getString("name"))) { + continue; + } + JSONArray pluginList = op.getJSONArray("pluginList"); + if (Boolean.TRUE.equals(op.getBoolean("enabled")) && pluginList != null && !pluginList.isEmpty()) { + return true; + } + } + return false; + } + + /** + * 安全地把接口入参解析为整数(兼容 1.0 等数值形态),非法/缺省回退默认值,并夹到 [min, max]。 + */ + private int toIntParam(Object value, int defaultValue, int min, int max) { + if (value == null || StrUtil.isBlank(String.valueOf(value))) { + return defaultValue; + } + try { + long longValue = new BigDecimal(String.valueOf(value).trim()).longValue(); + if (longValue < min) { + return min; + } + if (longValue > max) { + return max; + } + return (int) longValue; + } catch (Exception e) { + return defaultValue; + } + } + + /** + * 按 param_config.conditions 与请求参数构造过滤条件列表(未传参数的条件跳过)。 + * 仅放行在线列表引擎真正支持的条件 op(eq/like/in/between),其余直接拒绝,避免条件被静默丢弃导致 CONDITION_MATCH 放大执行。 + */ + private ResponseResult> buildFilterDtoList(JSONObject paramConfig, Map requestParams, OnlineForm form) { + List result = new ArrayList<>(); + if (paramConfig == null) { + return ResponseResult.success(result); + } + JSONArray conditions = paramConfig.getJSONArray("conditions"); + if (conditions == null) { + return ResponseResult.success(result); + } + String masterTableName = this.getMasterTableName(form); + for (int i = 0; i < conditions.size(); i++) { + JSONObject cond = conditions.getJSONObject(i); + String param = cond.getString("param"); + Object value = requestParams.get(param); + String target = cond.getString("target"); + String field = cond.getString("field"); + String table = cond.getString("table"); + String attr = cond.getString("attr"); + String op = cond.getString("op"); + // between 无单一入参值(靠 columnValueStart/End 两个入参名取上下界),不走单值跳过 + boolean isBetween = "between".equalsIgnoreCase(op == null ? "" : op.trim()); + if (!isBetween && (value == null || (value instanceof String && StrUtil.isBlank((String) value)))) { + continue; + } + // RELATION_FIELD 条件:attr 值先反查关联主键集合,再用 主表存储列 IN 过滤 + if (OpenApiFieldSource.RELATION_FIELD.equals(target) && attr != null && !"id".equals(attr)) { + result.add(this.buildRelationFilter(form, field, attr, value, masterTableName)); + continue; + } + Integer filterType = this.mapOp(op); + if (filterType == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, + "暂不支持的条件 op=[" + op + "],仅支持 eq/like/in/between!"); + } + OnlineFilterDto filter = new OnlineFilterDto(); + filter.setTableName(OpenApiFieldSource.ENTRY_FIELD.equals(target) ? table : masterTableName); + filter.setColumnName(field); + filter.setFilterType(filterType); + if (filterType == FieldFilterType.RANGE_FILTER) { + // between 按 columnValueStart/End 取值(引擎 RANGE 分支读取的就是这两个字段,不是 columnValue) + Object startVal = this.rangeValue(requestParams, cond.getString("columnValueStart")); + Object endVal = this.rangeValue(requestParams, cond.getString("columnValueEnd")); + if (startVal == null && endVal == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, + "between 条件需在 conditions 中配置 columnValueStart/columnValueEnd(入参名),且请求需携带对应值!"); + } + filter.setColumnValueStart(startVal); + filter.setColumnValueEnd(endVal); + } else if (filterType == FieldFilterType.IN_LIST_FILTER) { + // 引擎 IN_LIST 分支按 getColumnValue() 单值(逗号串)读取 + filter.setColumnValue(this.joinListValue(value)); + } else { + filter.setColumnValue(value); + } + result.add(filter); + } + return ResponseResult.success(result); + } + + /** + * 将入参列表拼成逗号串(在线列表引擎的 IN_LIST_FILTER 按 getColumnValue() 单值读取,见 ListDataOrmService#getFilter)。 + */ + private String joinListValue(Object value) { + if (value instanceof Collection) { + return StrUtil.join(",", (Collection) value); + } + return String.valueOf(value); + } + + /** + * 取 between 的上/下界值:conditions 里的 columnValueStart/columnValueEnd 记录的是请求体入参名。 + */ + private Object rangeValue(Map requestParams, String paramName) { + return StrUtil.isBlank(paramName) ? null : requestParams.get(paramName); + } + + /** + * 关联字段条件过滤:按 attr 列反查关联主键集合,返回 主表存储列 IN (ids) 过滤;无匹配时用占位值保证结果为空。 + */ + private OnlineFilterDto buildRelationFilter(OnlineForm form, String masterColumn, String attr, Object value, String masterTableName) { + Set relatedIds = new HashSet<>(); + if (value instanceof Collection) { + for (Object item : (Collection) value) { + relatedIds.addAll(openApiOnlineQueryService.resolveRelatedIds(form, masterColumn, attr, item)); + } + } else { + relatedIds.addAll(openApiOnlineQueryService.resolveRelatedIds(form, masterColumn, attr, value)); + } + OnlineFilterDto filter = new OnlineFilterDto(); + filter.setTableName(masterTableName); + filter.setColumnName(masterColumn); + filter.setFilterType(FieldFilterType.IN_LIST_FILTER); + if (relatedIds.isEmpty()) { + // 无匹配关联 → 用不可能命中的占位值,保证查询结果为空 + relatedIds.add("-1"); + } + // 在线列表引擎按 getColumnValue() 单值读取 IN 条件(逗号串),非 columnValueList + filter.setColumnValue(StrUtil.join(",", relatedIds)); + return filter; + } + + /** + * 按输出配置裁剪列表行数据(含一对多分录 ENTRY_ARRAY 的逐行组装)。 + */ + private List assembleDataList(List> dataList, String outputConfigJson, OnlineForm form) { + List result = new ArrayList<>(); + if (dataList == null || StrUtil.isBlank(outputConfigJson)) { + return result; + } + JSONObject outputConfig = JSON.parseObject(outputConfigJson); + JSONArray dataFields = outputConfig.getJSONArray("dataFields"); + if (dataFields == null) { + return result; + } + for (Map row : dataList) { + JSONObject item = new JSONObject(true); + this.mapRow(item, row, dataFields, form); + result.add(item); + } + return result; + } + + private void mapRow(JSONObject target, Map row, JSONArray dataFields, OnlineForm form) { + for (int i = 0; i < dataFields.size(); i++) { + JSONObject df = dataFields.getJSONObject(i); + String alias = df.getString("alias"); + String field = df.getString("field"); + String targetType = df.getString("target"); + if (OpenApiFieldSource.ENTRY_ARRAY.equals(targetType)) { + // 一对多分录:按 master 行主键查子表关联行,按 children 裁剪字段 + Object masterId = row.get("id"); + String subTable = df.getString("table"); + target.put(alias, this.assembleEntryList(form, masterId, subTable, df.getJSONArray("children"))); + continue; + } + Object value = row.get(field); + if (value == null) { + value = row.get(alias); + } + if (value == null) { + value = row.get(StrUtil.toUnderlineCase(field)); + } + target.put(alias, value); + } + } + + /** + * 组装一对多分录行:查子表数据并仅保留 children 中配置的字段。 + */ + private JSONArray assembleEntryList(OnlineForm form, Object masterId, String subTable, JSONArray children) { + JSONArray out = new JSONArray(); + if (masterId == null || StrUtil.isBlank(subTable) || children == null) { + return out; + } + List> subRows = openApiOnlineQueryService.querySubTableRows(form, masterId, subTable); + if (subRows == null) { + return out; + } + for (Map subRow : subRows) { + JSONObject item = new JSONObject(true); + for (int c = 0; c < children.size(); c++) { + JSONObject child = children.getJSONObject(c); + String childAlias = child.getString("alias"); + String childField = child.getString("field"); + if (StrUtil.isBlank(childAlias)) { + continue; + } + Object v = subRow.get(childField); + if (v == null) { + v = subRow.get(childAlias); + } + if (v == null) { + v = subRow.get(StrUtil.toUnderlineCase(childField)); + } + item.put(childAlias, v); + } + out.add(item); + } + return out; + } + + /** + * 映射条件 op 到 FieldFilterType(common-online 版常量),op 忽略大小写与首尾空白。 + * 仅映射在线列表引擎真正支持的 op;不支持(not_in/is_null/not_null 等)返回 null,由调用方拒绝。 + */ + private Integer mapOp(String op) { + if (StrUtil.isBlank(op)) { + return FieldFilterType.EQUAL_FILTER; + } + switch (op.trim().toLowerCase()) { + case "eq": + return FieldFilterType.EQUAL_FILTER; + case "like": + return FieldFilterType.LIKE_FILTER; + case "in": + return FieldFilterType.IN_LIST_FILTER; + case "between": + return FieldFilterType.RANGE_FILTER; + default: + log.warn("OpenAPI 不支持的在线列表条件 op=[{}],拒绝执行", op); + return null; + } + } + + private String getMasterTableName(OnlineForm form) { + if (form.getMasterTableId() == null) { + return null; + } + OnlineTable table = onlineTableService.getById(form.getMasterTableId()); + return table == null ? null : table.getTableName(); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiOnlineQueryServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiOnlineQueryServiceImpl.java new file mode 100644 index 0000000..b81aa2a --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiOnlineQueryServiceImpl.java @@ -0,0 +1,210 @@ +package apelet.common.openapi.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.ObjectCollection; +import apelet.common.core.object.ObjectValue; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.generator.model.OnlFormField; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormFieldService; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.generator.utils.OrmGenDataSourceUtil; +import apelet.common.online.dto.OnlineFilterDto; +import apelet.common.online.model.OnlineColumn; +import apelet.common.online.model.OnlineDatasource; +import apelet.common.online.model.OnlineDatasourceRelation; +import apelet.common.online.model.OnlineForm; +import apelet.common.online.model.OnlineTable; +import apelet.common.online.model.constant.FieldFilterType; +import apelet.common.online.model.constant.RelationType; +import apelet.common.online.service.OnlineDatasourceService; +import apelet.common.online.service.OnlineOperationService; +import apelet.common.online.service.OnlineTableService; +import apelet.common.online.util.OnlineOperationHelper; +import apelet.common.openapi.service.OpenApiOnlineQueryService; +import apelet.common.orm.impl.Filter; +import apelet.common.orm.impl.FilterItem; +import apelet.common.orm.impl.Selector; +import apelet.common.orm.impl.SelectorItem; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * OpenAPI 在线表数据查询辅助实现。 + *

标注在线数据源(COMMON_FLOW_AND_ONLINE),内部 ormGenDataSourceUtil 查询落在在线库。

+ * + * @author chenchuchuan + * @date 2026-09-03 + */ +@Slf4j +@Service("openApiOnlineQueryService") +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +public class OpenApiOnlineQueryServiceImpl implements OpenApiOnlineQueryService { + + @Autowired + private IOnlFormHeadService onlFormHeadService; + @Autowired + private IOnlFormFieldService onlFormFieldService; + @Autowired + private OnlineTableService onlineTableService; + @Autowired + private OnlineDatasourceService onlineDatasourceService; + @Autowired + private OnlineOperationService onlineOperationService; + @Autowired + private OnlineOperationHelper onlineOperationHelper; + @Autowired + private OrmGenDataSourceUtil ormGenDataSourceUtil; + + @Override + public Object resolveRelatedId(OnlineForm masterForm, String masterColumnName, String attrColumn, Object attrValue) { + if (masterForm == null || masterForm.getMasterTableId() == null || StrUtil.isBlank(masterColumnName) + || StrUtil.isBlank(attrColumn) || attrValue == null) { + return null; + } + try { + OnlineTable relTable = this.findRelTable(masterForm, masterColumnName); + if (relTable == null || relTable.getPrimaryKeyColumn() == null) { + return null; + } + OnlineColumn pkColumn = relTable.getPrimaryKeyColumn(); + ObjectCollection collection = this.queryRelTable(relTable, attrColumn, attrValue, pkColumn); + if (collection == null || collection.isEmpty()) { + return null; + } + ObjectValue obj = collection.getObject(0); + return obj == null ? null : obj.getString(pkColumn.getColumnName()); + } catch (Exception e) { + log.error("OpenAPI 关联字段反查失败:masterColumn={}, attrColumn={}, value={}", masterColumnName, attrColumn, attrValue, e); + return null; + } + } + + @Override + public Set resolveRelatedIds(OnlineForm masterForm, String masterColumnName, String attrColumn, Object attrValue) { + Set result = new HashSet<>(); + if (masterForm == null || masterForm.getMasterTableId() == null || StrUtil.isBlank(masterColumnName) + || StrUtil.isBlank(attrColumn) || attrValue == null) { + return result; + } + try { + OnlineTable relTable = this.findRelTable(masterForm, masterColumnName); + if (relTable == null || relTable.getPrimaryKeyColumn() == null) { + return result; + } + OnlineColumn pkColumn = relTable.getPrimaryKeyColumn(); + ObjectCollection collection = this.queryRelTable(relTable, attrColumn, attrValue, pkColumn); + if (collection != null) { + for (int i = 0; i < collection.size(); i++) { + ObjectValue obj = collection.getObject(i); + if (obj != null && obj.getString(pkColumn.getColumnName()) != null) { + result.add(obj.getString(pkColumn.getColumnName())); + } + } + } + } catch (Exception e) { + log.error("OpenAPI 关联字段批量反查失败:masterColumn={}, attrColumn={}, value={}", masterColumnName, attrColumn, attrValue, e); + } + return result; + } + + @Override + public List> querySubTableRows(OnlineForm masterForm, Object masterId, String subTableName) { + List> result = new ArrayList<>(); + if (masterForm == null || masterForm.getMasterTableId() == null || masterId == null || StrUtil.isBlank(subTableName)) { + return result; + } + try { + OnlineDatasource datasource = onlineDatasourceService.getOnlineDatasourceByMasterTableId(masterForm.getMasterTableId()); + if (datasource == null) { + return result; + } + ResponseResult> relResult = + onlineOperationHelper.verifyAndGetRelationList(datasource.getDatasourceId(), null); + if (!relResult.isSuccess() || relResult.getData() == null) { + return result; + } + OnlineDatasourceRelation relation = relResult.getData().stream() + .filter(r -> r.getRelationType() != null && r.getRelationType() == RelationType.ONE_TO_MANY) + .filter(r -> subTableName.equals(this.slaveTableName(r))).findFirst().orElse(null); + if (relation == null || relation.getSlaveColumn() == null) { + return result; + } + // 子表过滤:slaveColumn(附表外键) = master 行主键 + List filterList = new ArrayList<>(); + OnlineFilterDto filter = new OnlineFilterDto(); + filter.setTableName(subTableName); + filter.setColumnName(relation.getSlaveColumn().getColumnName()); + filter.setColumnValue(masterId); + filter.setFilterType(FieldFilterType.EQUAL_FILTER); + filterList.add(filter); + MyPageData> pageData = + onlineOperationService.getSlaveDataList(relation, filterList, null, null); + return pageData == null ? result : pageData.getDataList(); + } catch (Exception e) { + log.error("OpenAPI 分录数据查询失败:subTable={}, masterId={}", subTableName, masterId, e); + return result; + } + } + + /** + * 取关系从表名(slaveTable 未加载时按 slaveTableId 反查)。 + */ + private String slaveTableName(OnlineDatasourceRelation relation) { + if (relation.getSlaveTable() != null) { + return relation.getSlaveTable().getTableName(); + } + OnlineTable table = onlineTableService.getById(relation.getSlaveTableId()); + return table == null ? null : table.getTableName(); + } + + /** + * 定位 master 表单中关联字段(field_name=masterColumnName 且 refPropert 非空)对应的关联表。 + */ + private OnlineTable findRelTable(OnlineForm masterForm, String masterColumnName) { OnlineTable masterTable = onlineTableService.getById(masterForm.getMasterTableId()); + if (masterTable == null) { + return null; + } + OnlFormHead masterHead = onlFormHeadService.getOne(new LambdaQueryWrapper() + .eq(OnlFormHead::getTableName, masterTable.getTableName())); + if (masterHead == null) { + return null; + } + List relFields = onlFormFieldService.list(new LambdaQueryWrapper() + .eq(OnlFormField::getHeadId, masterHead.getId()) + .eq(OnlFormField::getFieldName, masterColumnName) + .isNotNull(OnlFormField::getRefPropert)); + if (relFields == null || relFields.isEmpty()) { + return null; + } + OnlFormHead relHead = onlFormHeadService.getById(relFields.get(0).getRefPropert()); + if (relHead == null) { + return null; + } + return onlineTableService.getOne(new LambdaQueryWrapper() + .eq(OnlineTable::getTableName, relHead.getTableName())); + } + + /** + * 查询关联表:SELECT pk FROM 关联表 WHERE attrColumn = attrValue(sorter 传 null)。 + */ + private ObjectCollection queryRelTable(OnlineTable relTable, String attrColumn, Object attrValue, OnlineColumn pkColumn) { + Filter filter = new Filter(); + filter.add(new FilterItem(attrColumn, FilterItem.equals, attrValue)); + Selector selector = new Selector(); + selector.getList().add(new SelectorItem(pkColumn.getColumnName())); + return ormGenDataSourceUtil.query(relTable.getTableName(), filter, selector, null); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/vo/OpenApiConfigVo.java b/common/common-openapi/src/main/java/apelet/common/openapi/vo/OpenApiConfigVo.java new file mode 100644 index 0000000..aa1354d --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/vo/OpenApiConfigVo.java @@ -0,0 +1,104 @@ +package apelet.common.openapi.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * OpenAPI 开放接口配置VO对象(详情接口返回,含关联应用编码集合)。 + * + * @author chenchuchuan + * @date 2026-09-08 + */ +@Schema(description = "OpenAPI开放接口配置VO对象") +@Data +public class OpenApiConfigVo { + + /** + * 主键Id。 + */ + @Schema(description = "主键Id") + private Long id; + /** + * 在线页面主键(OnlinePage.pageId)。 + */ + @Schema(description = "在线页面主键Id") + private Long pageId; + /** + * 页面编码(冗余存储,用于组装开放路径)。 + */ + @Schema(description = "页面编码") + private String pageCode; + /** + * 该页面下关联的在线表单编码。 + */ + @Schema(description = "在线表单编码") + private String formCode; + /** + * 关联的在线表单主键Id。 + */ + @Schema(description = "在线表单主键Id") + private Long formId; + /** + * 操作编码:query 或 operationList 中操作的 code。 + */ + @Schema(description = "操作编码") + private String operationCode; + /** + * 操作名称。 + */ + @Schema(description = "操作名称") + private String operationName; + /** + * 参数模式(后端自动推导):FIELD_DIRECT / CONDITION_MATCH / QUERY。 + */ + @Schema(description = "参数模式") + private String paramMode; + /** + * 开放路径:/openapi/v1/{pageCode}/{formCode}/{operationCode},全局唯一。 + */ + @Schema(description = "开放路径") + private String requestPath; + /** + * 请求方法 GET / POST。 + */ + @Schema(description = "请求方法") + private String requestMethod; + /** + * JSON:输入参数+条件配置。 + */ + @Schema(description = "参数/条件配置JSON") + private String paramConfig; + /** + * JSON:输出层级结构。 + */ + @Schema(description = "输出层级配置JSON") + private String outputConfig; + /** + * 状态(0: 停用 1: 启用)。 + */ + @Schema(description = "状态(0: 停用 1: 启用)") + private Integer status; + /** + * 创建时间。 + */ + @Schema(description = "创建时间") + private Date createTime; + /** + * 更新时间。 + */ + @Schema(description = "更新时间") + private Date updateTime; + /** + * 引用编码集合:该配置已授权应用的应用编码(xy_sys_open_app.app_code,经 xy_sys_open_app_api 反查)。 + */ + @Schema(description = "引用编码集合:已授权应用的应用编码集合") + private List appCodes; + /** + * 引用Id集合:该配置已授权应用的应用Id(xy_sys_open_app.app_id,经 xy_sys_open_app_api 反查)。 + */ + @Schema(description = "引用Id集合:已授权应用的应用Id集合") + private List appIdList; +}