diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..47d5780c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,23 @@ +--- +name: 提交Bug +about: 提交bug,帮助我们更好完善项目. +title: "[BUG]" +labels: bug +assignees: zhou-hao + +--- + +# BUG 说明 +简要说明bug情况 + +# 运行环境 +java: 1.8.0_131 +maven: 3.3.9 +hsweb: 3.0.5 + +# 复现步骤 + +# 期望结果 +此功能期望的执行结果 + +# 截图说明 diff --git a/.github/ISSUE_TEMPLATE/future.md b/.github/ISSUE_TEMPLATE/future.md new file mode 100644 index 000000000..e879ce203 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/future.md @@ -0,0 +1,12 @@ +--- +name: 需求 特性 +about: 提出你想要的,帮助完善hsweb +title: "[需求]" +labels: 需求 +assignees: zhou-hao + +--- + +# 场景 + +# 需求说明 diff --git a/.github/ISSUE_TEMPLATE/qa.md b/.github/ISSUE_TEMPLATE/qa.md new file mode 100644 index 000000000..483f7ac4b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/qa.md @@ -0,0 +1,14 @@ +--- +name: 疑问 帮助 +about: 有任何疑问尽管提 +title: "[疑问]" +labels: 帮助 +assignees: zhou-hao + +--- + +# 环境 +java: 1.8.0_131 +hsweb: 3.0.5 + +# 问题说明 diff --git a/.gitignore b/.gitignore index 82d387bc6..359a49dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ *.log # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* -/hsweb-web-service/hsweb-web-service-simple/data/ +**/transaction-logs/ +pom.xml.versionsBackup +build/ +!maven-wrapper.jar \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 000000000..c6feb8bb6 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..966184dca --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://archive.apache.org/dist/maven/maven-3/3.9.3/binaries/apache-maven-3.9.3-bin.zip \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 6767c275e..d91e45e57 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,17 @@ language: java -jdk: oraclejdk8 sudo: false -install: true -script: mvn install -DskipTests \ No newline at end of file +jdk: + - openjdk8 +service: + - redis-server +before_script: + - sudo redis-server /etc/redis/redis.conf --port 6379 +before_install: + - chmod +x mvnw +script: + - ./mvnw -q test +after_success: + - bash <(curl -s https://codecov.io/bash) +cache: + directories: + - '$HOME/.m2/repository' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..5a6decc56 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at admin@hsweb.me. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..237cb31cf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# 贡献你的代码 +1. fork 本仓库 +2. 修改,增加代码 +3. 执行`mvn test`通过 +4. 提交`pull request` +5. 坐等审查 +6. 合并 + +# BUG +如果知道导致bug的位置,你可以直接修改后`pull request`,也可以提交[issues](https://github.com/hs-web/hsweb-framework/issues/new).我们会尽快解决. + +# 需求&优化 +你可以通过issues提交你希望`hsweb`增加的特性以及功能优化,并可以在 [projects](https://github.com/hs-web/hsweb-framework/projects)中查看`hsweb`的开发进展以及计划. + +# 社区&交流 +你可以通过提交`issues`或者加入官方QQ群:[515649185](http://shang.qq.com/wpa/qunwpa?idkey=3d66b5dd14991d7645af694e7649b373f3a9ce1216131094c78cb2348d542c41) +以及发送邮件和我们取得联系. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..1d2ee69cb --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +1. 问题描述: +2. 复现步骤: +3. 日志内容: \ No newline at end of file diff --git a/LICENSE b/LICENSE index de9ec918e..340e7cbed 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2016 http://hsweb.me + Copyright 2020 http://hsweb.me Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,3 +199,4 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + diff --git a/README.md b/README.md index 1ca5975e3..ad5f79f33 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,136 @@ -## 后台管理基础框架 +# hsweb4 基于spring-boot2,全响应式的后台管理框架 -[![Build Status](https://travis-ci.org/hs-web/hsweb-framework.svg?branch=master)](https://travis-ci.org/hs-web/hsweb-framework) +[![Codecov](https://codecov.io/gh/hs-web/hsweb-framework/branch/4.0.x/graph/badge.svg)](https://codecov.io/gh/hs-web/hsweb-framework/branch/master) +[![Build Status](https://api.travis-ci.com/hs-web/hsweb-framework.svg?branch=4.0.x)](https://travis-ci.com/hs-web/hsweb-framework) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) -### 业务功能 -现在: - -1. 权限管理: 权限资源-角色-用户. -2. 配置管理: kv结构,自定义配置.可通过此功能配置数据字典. -3. 脚本管理: 动态脚本,支持javascript,groovy,java动态编译执行. -4. 表单管理: 动态表单,可视化设计表单,自动生成数据库以及系统权限.无需重启直接生效. -5. 模块设置: 配合动态表达没实现表格页,查询条件自定义. -6. 数据库维护: 在线维护数据库,修改表结构,执行sql. -7. 数据源管理: 配置多数据源. -8. 代码生成器: 在线生成代码,打包下载.可自定义模板. -9. 定时任务: 配置定时任务,使用动态脚本编写任务内容. -10. 系统监控: 监控系统资源使用情况. -11. 缓存监控: 监控缓存情况. -12. 访问日志: 记录用户每次操作情况 - -未来 - -1. 组织架构管理: 地区-机构-部门-职务-人员. -2. 工作流管理: activiti工作流,在线配置流程,配合动态表单实现自定义流程. -3. 邮件代收: 代收指定邮箱的邮件 - - -### 框架功能 -0. 全局restful+json,前后分离. -1. 通用dao,service,controller类,增删改查直接继承即可. -2. 通用mybatis配置文件,支持多种条件查询自动生成,支持自动生成insert,update,delete语句,支持和查询相同的各种条件. -3. 实现用户,权限管理;基于aop,注解,精确到按钮的权限控制. -4. 动态表单功能,可在前端设计表单,动态生成数据库表,提供统一的增删改查接口. -5. 在线代码生成器,可自定义模板. -6. 动态多数据源,支持数据源热加载,热切换,支持分布式事务. -7. 数据库支持 mysql,oracle,h2. -8. websocket支持. -9. 定时调度支持,可在页面配置定时任务,编写任务脚本执行。 - -### 演示 -1. 示例:[demo.hsweb.me](http://demo.hsweb.me) -2. 测试用户:test (test2,test3,test4....) 密码:123456 -3. 演示项目源码:[hsweb-platform](https://github.com/hs-web/hsweb-platform) - -### 文档 -1. [安装使用](doc/1.安装使用.md) -2. [API](doc/2.API.md) - -### 此版本待完善功能 -1. 单元测试编写 -2. 项目文档编写 -3. ~~增加定时调度,支持集群,任务采用脚本方式编写.~~ -4. 完善数据库持续集成,版本更新时自动更新数据库结构. -5. 完善动态表单发布,表单发生变化后,自动重新发布(解决集群下,表单配置不一致). - -### 技术选型 -第三方: - -1. MVC:[spring-boot](https://github.com/spring-projects/spring-boot). 开箱即用,学习成本低,部署方便(main方法运行). -2. ORM:[mybatis](https://github.com/mybatis/mybatis-3). 配置灵活,简单方便. -3. JTA:[atomikos](https://www.atomikos.com/). 分布式事务,多数据源事务全靠他. -4. Cache:[spring-cache](https://github.com/spring-projects/spring-framework/tree/master/spring-context/src/main/java/org/springframework/cache). 统一接口,注解使用,simple,redis... 自动切换. -5. Scheduler:[quartz](https://github.com/quartz-scheduler/quartz). 开源稳定,支持集群. - -自家: - -0. [hsweb-commons](https://github.com/hs-web/hsweb-commons) :通用工具类 -1. [hsweb-easy-orm](https://github.com/hs-web/hsweb-easy-orm) :为动态表单设计的orm框架 -2. [hsweb-expands-compress](https://github.com/hs-web/hsweb-expands/tree/master/hsweb-expands-compress) :文件压缩,解压操作 -3. [hsweb-expands-office](https://github.com/hs-web/hsweb-expands/tree/master/hsweb-expands-office) :office文档操作( excel读写,模板导出,word模板导出) -4. [hsweb-expands-request](https://github.com/hs-web/hsweb-expands/tree/master/hsweb-expands-request): 请求模拟(http,ftp) -5. [hsweb-expands-script](https://github.com/hs-web/hsweb-expands/tree/master/hsweb-expands-script):动态脚本,动态编译执行java,groovy,javascript,spel,ognl.... -6. [hsweb-expands-shell](https://github.com/hs-web/hsweb-expands/tree/master/hsweb-expands-shell):shell执行 -7. [hsweb-expands-template](https://github.com/hs-web/hsweb-expands/tree/master/hsweb-expands-template):各种模板引擎 +# 功能,特性 + +- [x] 基于[r2dbc](https://github.com/r2dbc) ,[easy-orm](https://github.com/hs-web/hsweb-easy-orm/tree/4.0.x) 的通用响应式CRUD + - [x] H2,Mysql,SqlServer,PostgreSQL +- [x] 响应式r2dbc事务控制 +- [x] 响应式权限控制,以及权限信息获取 + - [x] RBAC权限控制 + - [x] 数据权限控制 + - [ ] 双因子验证 +- [x] 多维度权限管理功能 +- [x] 响应式缓存 +- [ ] 非响应式支持(mvc,jdbc) +- [ ] 内置业务功能 + - [x] 权限管理 + - [x] 用户管理 + - [x] 权限设置 + - [x] 权限分配 + - [ ] 文件上传 + - [x] 静态文件上传 + - [ ] 文件秒传 + - [x] 数据字典 + +# 示例 + +https://github.com/zhou-hao/hsweb4-examples + +## 应用场景 + +1. 完全开源的后台管理系统. +2. 模块化的后台管理系统. +3. 功能可拓展的后台管理系统. +4. 集成各种常用功能的后台管理系统. +5. 前后分离的后台管理系统. + +注意: +项目主要基于`spring-boot`,`spring-webflux`. 在使用`hsweb`之前,你应该对 [project-reactor](https://projectreactor.io/) , +[spring-boot](https://github.com/spring-projects/spring-boot) 有一定的了解. + +项目模块太多?不要被吓到.我们不推荐将本项目直接`clone`后修改,运行.而是使用maven依赖的方式使用`hsweb`. 选择自己需要的模块进行依赖,正式版发布后,所有模块都将发布到maven中央仓库. + +## 文档 + +各个模块的使用方式查看对应模块下的 `README.md`,在使用之前, 你可以先粗略浏览一下各个模块,对每个模块的作用有大致的了解. + +## 核心技术选型 + +1. Java 8 +2. Maven3 +3. Spring Boot 2.x +4. Project Reactor 响应式编程框架 +5. hsweb easy orm 对r2dbc的orm封装 + +## 模块简介 + +| 模块 | 说明 | +| ------------- |:----------:| +|[hsweb-authorization](hsweb-authorization)| 权限控制 | +|[hsweb-commons](hsweb-commons) | 基础通用功能 | +|[hsweb-concurrent](hsweb-concurrent)| 并发包,缓存,等 | +|[hsweb-core](hsweb-core)| 框架核心,基础工具类 | +|[hsweb-datasource](hsweb-datasource)| 数据源 | +|[hsweb-logging](hsweb-logging)| 日志 | +|[hsweb-starter](hsweb-starter)| 模块启动器 | +|[hsweb-system](hsweb-system)| **系统常用功能** | + +## 核心特性 + +1. 响应式,首个基于spring-webflux,r2dbc,从头到位的响应式. +2. DSL风格,可拓展的通用curd,支持前端直接传参数,无需担心任何sql注入. + +```java + //where name = #{name} + createQuery() + .where("name",name) + .fetch(); + + //update s_user set name = #{user.name} where id = #{user.id} + createUpdate() + .set(user::getName) + .where(user::getId) + .execute(); + +``` + +3. 类JPA增删改 + +```java + +@Table(name = "s_entity") +public class MyEntity { + + @Id + private String id; + + @Column + private String name; + + @Column + private Long createTime; +} + +``` + +直接注入即可实现增删改查 + +```java + +@Autowire +private ReactiveRepository repository; + +``` + +2. 灵活的权限控制 + +```java + +@PostMapping("/account") +@SaveAction +public Mono addAccount(@RequestBody Mono account){ + return accountService.doSave(account); +} + +``` + +## License + +[Apache 2.0](https://github.com/spring-projects/spring-boot/blob/main/LICENSE.txt) + + +[![Stargazers over time](https://starchart.cc/hs-web/hsweb-framework.svg?variant=adaptive)](https://starchart.cc/hs-web/hsweb-framework) diff --git a/build.sh b/build.sh new file mode 100644 index 000000000..21805a0d7 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +./mvnw install -Dgit.commit.hash=$(git rev-parse HEAD) -DskipTests=true \ No newline at end of file diff --git "a/doc/1.\345\256\211\350\243\205\344\275\277\347\224\250.md" "b/doc/1.\345\256\211\350\243\205\344\275\277\347\224\250.md" deleted file mode 100644 index d43f4165c..000000000 --- "a/doc/1.\345\256\211\350\243\205\344\275\277\347\224\250.md" +++ /dev/null @@ -1,49 +0,0 @@ -# 使用hsweb -项目java8开发,使用maven进行管理. - -## 使用 -配置pom.xml -```xml - - - - - org.hsweb - hsweb-framework - ${hsweb.version} - pom - import - - - - - - - hsweb-nexus - Nexus Release Repository - http://nexus.hsweb.me/content/groups/public/ - - true - - - -``` - -引入依赖 -```xml - - - org.hsweb - hsweb-web-controller - - - - org.hsweb - hsweb-web-service-simple - - - - org.hsweb - hsweb-web-dao-mybatis - -``` diff --git a/hsweb-authorization/README.md b/hsweb-authorization/README.md new file mode 100644 index 000000000..1cbf728f9 --- /dev/null +++ b/hsweb-authorization/README.md @@ -0,0 +1,7 @@ +# 授权认证模块 +用于整个系统的授权认证管理 + +# 目录介绍 +1. [hsweb-authorization-api](hsweb-authorization-api):权限控制API +3. [hsweb-authorization-basic](hsweb-authorization-basic):权限控制基础实现 + diff --git a/hsweb-authorization/hsweb-authorization-api/README.md b/hsweb-authorization/hsweb-authorization-api/README.md new file mode 100644 index 000000000..2683a5b65 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/README.md @@ -0,0 +1,60 @@ +# 权限控制API +用于权限控制的API接口,支持RBAC权限控制,支持数据级(控制到行,列)权限控制. + +[用户令牌管理](token.md) + +[权限控制配置](define.md) + +# 介绍 + +以下讲到的类都是基于包:org.hswebframework.web.authorization + +### 常用注解: +_点击名称,查看源代码注释获得使用说明_ + +| 注解名称 | 说明 | +| ------------- |:-------------:| +| [`@Authorize`](src/main/java/org/hswebframework/web/authorization/annotation/Authorize.java) | RBAC方式权限控制注解 | +| [`@RequiresExpression`](src/main/java/org/hswebframework/web/authorization/annotation/RequiresExpression.java) | 表达式方式验证 | +| [`@RequiresDataAccess`](src/main/java/org/hswebframework/web/authorization/annotation/RequiresDataAccess.java) | 数据权限控制 | + +[自定义数据权限控制](custom-data-access.md) + +### 常用类 +_点击名称,查看源代码注释获得使用说明_ + + +| 类名 | 说明 | +| ------------- |:-------------:| +| [`Authentication`](src/main/java/org/hswebframework/web/authorization/Authentication.java) | 用户的认证信息 | +| [`AuthenticationHolder`](src/main/java/org/hswebframework/web/authorization/AuthenticationHolder.java) | 用于获取当前登录用户的认证信息 | + + +### Listener +api提供[AuthorizationListener](src/main/java/org/hswebframework/web/authorization/listener/AuthorizationListener.java) +来进行授权逻辑拓展,在授权前后执行可自定义的操作.如rsa解密帐号密码,验证码判断等。 + +默认事件列表(): + +| 类名 | 说明 | +| ------------- |:-------------:| +| [`AuthorizationDecodeEvent`](src/main/java/org/hswebframework/web/authorization/listener/event/AuthorizationDecodeEvent.java) | 接收到请求参数时 | +| [`AuthorizationBeforeEvent`](src/main/java/org/hswebframework/web/authorization/listener/event/AuthorizationBeforeEvent.java) | 验证密码前触发 | +| [`AuthorizationFailedEvent`](src/main/java/org/hswebframework/web/authorization/listener/event/AuthorizationFailedEvent.java) | 授权验证失败时触发 | +| [`AuthorizationSuccessEvent`](src/main/java/org/hswebframework/web/authorization/listener/event/AuthorizationSuccessEvent.java) | 授权成功时触发 | +| [`AuthorizationExitEvent`](src/main/java/org/hswebframework/web/authorization/listener/event/AuthorizationExitEvent.java) | 用户注销时触发 | + +例子: + +```java +@Component +public class CustomAuthorizationSuccessListener implements AuthorizationListener{ + @Override + public void on(AuthorizationSuccessEvent event) { + Authentication authentication=event.getAuthentication(); + //.... + System.out.println(authentication.getUser().getName()+"登录啦"); + } +} +``` + diff --git a/hsweb-authorization/hsweb-authorization-api/custom-data-access.md b/hsweb-authorization/hsweb-authorization-api/custom-data-access.md new file mode 100644 index 000000000..1258315f1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/custom-data-access.md @@ -0,0 +1,49 @@ +# 自定义拓展数据权限控制 + +1. 编写配置转换器,将在前端配置的内容转换为api需要的配置信息 + +实现 ``DataAccessConfigConvert``接口 +```java +@org.springframework.stereotype.Component +public class MyDataAccessConfigConvert implements DataAccessConfigConvert { + + @Override + public boolean isSupport(String type, String action, String config) { + return "custom_type".equals(type); + } + + @Override + public DataAccessConfig convert(String type, String action, String config) { + MyDataAccessConfig accessConfig = JSON.parseObject(config, MyDataAccessConfig.class); + accessConfig.setAction(action); + accessConfig.setType(type); + return accessConfig; + } +} +``` + + +2. 实现 ``DataAccessHandler``接口 +```java +@org.springframework.stereotype.Component //提供给Spring才会生效 +public class MyDataAccessHandler implements org.hswebframework.web.authorization.access.DataAccessHandler{ + + @Override + public boolean isSupport(DataAccessConfig access) { + //DataAccessConfig 在用户登录的时候,初始化 + //DataAccessConfig 由 + //支持的配置类型 + return "custom_type".equals(access.getType()); + } + + //处理请求,返回true表示授权通过 + @Override + public boolean handle(DataAccessConfig access, MethodInterceptorParamContext context) { + //被拦截的方法参数 + Map param= context.getNamedArguments(); + // 判断逻辑 + //... + return true; + } +} +``` \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/define.md b/hsweb-authorization/hsweb-authorization-api/define.md new file mode 100644 index 000000000..f55da45f1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/define.md @@ -0,0 +1,3 @@ +# 权限配置定义 + +用于告诉权限框架哪些请求需要进行权限控制,怎么控制. \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/pom.xml b/hsweb-authorization/hsweb-authorization-api/pom.xml new file mode 100644 index 000000000..195d652e3 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/pom.xml @@ -0,0 +1,55 @@ + + + + hsweb-authorization + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + 授权,权限管理API + hsweb-authorization-api + + + org.hswebframework.web + hsweb-core + ${project.version} + + + + org.springframework.data + spring-data-redis + true + + + + io.lettuce + lettuce-core + test + + + + com.alibaba + fastjson + + + + org.springframework.boot + spring-boot-starter + true + + + + io.swagger.core.v3 + swagger-annotations + + + + javax.servlet + javax.servlet-api + true + + + \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Authentication.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Authentication.java new file mode 100644 index 000000000..7e1d7f3c0 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Authentication.java @@ -0,0 +1,242 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization; + +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import java.io.Serializable; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * 用户授权信息,当前登录用户的权限信息,包括用户的基本信息,角色,权限集合等常用信息
+ * 获取方式: + *
    + *
  • springmvc 入参方式: ResponseMessage myTest(Authorization auth){}
  • + *
  • 静态方法方式:AuthorizationHolder.get();
  • + *
  • 响应式方式: return Authentication.currentReactive().map(auth->....)
  • + *
+ * + * @author zhouhao + * @see ReactiveAuthenticationHolder + * @see AuthenticationManager + * @since 3.0 + */ +public interface Authentication extends Serializable { + + /** + * 获取当前登录的用户权限信息 + *
+     *     public Mono<User> getUser(){
+     *         return Authentication.currentReactive()
+     *                 .switchIfEmpty(Mono.error(new UnAuthorizedException()))
+     *                 .flatMap(autz->findUserByUserId(autz.getUser().getId()));
+     *     }
+     * 
+ * + * @return 当前用户权限信息 + * @see ReactiveAuthenticationHolder + * @since 4.0 + */ + static Mono currentReactive() { + return ReactiveAuthenticationHolder.get(); + } + + /** + * 非响应式环境适用 + *
+     *
+     *   Authentication auth= Authentication.current().get();
+     *   //如果权限信息不存在将抛出{@link NoSuchElementException}建议使用下面的方式获取
+     *   Authentication auth=Authentication.current().orElse(null);
+     *   //或者
+     *   Authentication auth=Authentication.current().orElseThrow(UnAuthorizedException::new);
+     * 
+ * + * @return 当前用户权限信息 + * @see Optional + */ + static Optional current() { + return AuthenticationHolder.get(); + } + + /** + * @return 用户信息 + */ + User getUser(); + + /** + * @return 用户所有维度 + * @since 4.0 + */ + List getDimensions(); + + /** + * @return 用户持有的权限集合 + */ + List getPermissions(); + + default boolean hasDimension(String type, String... id) { + return hasDimension(type, Arrays.asList(id)); + } + + default boolean hasDimension(String type, Collection id) { + if (id.isEmpty()) { + return !getDimensions(type).isEmpty(); + } + return getDimensions(type) + .stream() + .anyMatch(p -> id.contains(p.getId())); + } + + default boolean hasDimension(DimensionType type, String id) { + return getDimension(type, id).isPresent(); + } + + default Optional getDimension(String type, String id) { + if (!StringUtils.hasText(type)) { + return Optional.empty(); + } + return getDimensions() + .stream() + .filter(dimension -> dimension.getId().equals(id) && type.equalsIgnoreCase(dimension.getType().getId())) + .findFirst(); + } + + default Optional getDimension(DimensionType type, String id) { + if (type == null) { + return Optional.empty(); + } + return getDimensions() + .stream() + .filter(dimension -> dimension.getId().equals(id) && type.isSameType(dimension.getType())) + .findFirst(); + } + + + default List getDimensions(String type) { + if (!StringUtils.hasText(type)) { + return Collections.emptyList(); + } + return getDimensions() + .stream() + .filter(dimension -> dimension.getType().isSameType(type)) + .collect(Collectors.toList()); + } + + default List getDimensions(DimensionType type) { + if (type == null) { + return Collections.emptyList(); + } + return getDimensions() + .stream() + .filter(dimension -> dimension.getType().isSameType(type)) + .collect(Collectors.toList()); + } + + + /** + * 根据权限id获取权限信息,权限不存在则返回null + * + * @param id 权限id + * @return 权限信息 + */ + default Optional getPermission(String id) { + if (null == id) { + return Optional.empty(); + } + return getPermissions() + .stream() + .filter(permission -> permission.getId().equals(id)) + .findAny(); + } + + /** + * 判断是否持有某权限以及对权限的可操作事件 + * + * @param permissionId 权限id {@link Permission#getId()} + * @param actions 可操作动作 {@link Permission#getActions()} 如果为空,则不判断action,只判断permissionId + * @return 是否持有权限 + */ + default boolean hasPermission(String permissionId, String... actions) { + return hasPermission(permissionId, + actions.length == 0 + ? Collections.emptyList() + : Arrays.asList(actions)); + } + + default boolean hasPermission(String permissionId, Collection actions) { + for (Permission permission : getPermissions()) { + if (Objects.equals(permission.getId(), "*")) { + return true; + } + if (Objects.equals(permissionId, permission.getId())) { + return actions.isEmpty() + || permission.getActions().containsAll(actions) + || permission.getActions().contains("*"); + } + } + return false; + } + + /** + * 根据属性名获取属性值,返回一个{@link Optional}对象。
+ * 此方法可用于获取自定义的属性信息 + * + * @param name 属性名 + * @param 属性值类型 + * @return Optional属性值 + */ + Optional getAttribute(String name); + + /** + * @return 全部属性集合 + */ + Map getAttributes(); + + /** + * 设置属性,注意: 此属性可能并不会被持久化,仅用于临时传递信息. + * + * @param key key + * @param value value + */ + default void setAttribute(String key, Serializable value) { + getAttributes().put(key, value); + } + + /** + * 合并权限 + * + * @param source 源权限信息 + * @return 合并后的信息 + */ + Authentication merge(Authentication source); + + /** + * copy为新的权限信息 + * + * @param permissionFilter 权限过滤 + * @param dimension 维度过滤 + * @return 新的权限信息 + */ + Authentication copy(BiPredicate permissionFilter, + Predicate dimension); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationHolder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationHolder.java new file mode 100644 index 000000000..c917ef15a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationHolder.java @@ -0,0 +1,103 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 权限获取器,用于静态方式获取当前登录用户的权限信息. + * 例如: + *
+ *     @RequestMapping("/example")
+ *     public ResponseMessage example(){
+ *         Authorization auth = AuthorizationHolder.get();
+ *         return ResponseMessage.ok();
+ *     }
+ * 
+ * + * @author zhouhao + * @see AuthenticationSupplier + * @since 3.0 + */ +public final class AuthenticationHolder { + private static final List suppliers = new ArrayList<>(); + + private static final ReadWriteLock lock = new ReentrantReadWriteLock(); + + private static Optional get(Function> function) { + int size = suppliers.size(); + if (size == 0) { + return Optional.empty(); + } + if (size == 1) { + return function.apply(suppliers.get(0)); + } + SimpleAuthentication merge = new SimpleAuthentication(); + for (AuthenticationSupplier supplier : suppliers) { + function.apply(supplier).ifPresent(merge::merge); + } + if (merge.getUser() == null) { + return Optional.empty(); + } + return Optional.of(merge); + } + + /** + * @return 当前登录的用户权限信息 + */ + public static Optional get() { + + return get(AuthenticationSupplier::get); + } + + /** + * 获取指定用户的权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + public static Optional get(String userId) { + return get(supplier -> supplier.get(userId)); + } + + /** + * 初始化 {@link AuthenticationSupplier} + * + * @param supplier + */ + public static void addSupplier(AuthenticationSupplier supplier) { + lock.writeLock().lock(); + try { + suppliers.add(supplier); + } finally { + lock.writeLock().unlock(); + } + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationManager.java new file mode 100644 index 000000000..485fe7ec3 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationManager.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization; + +import java.util.Optional; + +/** + * 授权信息管理器,用于获取用户授权和同步授权信息 + * + * @author zhouhao + * @see 3.0 + */ +public interface AuthenticationManager { + + /** + * 进行授权操作 + * + * @param request 授权请求 + * @return 授权成功则返回用户权限信息 + */ + Authentication authenticate(AuthenticationRequest request); + + /** + * 根据用户ID获取权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + Optional getByUserId(String userId); + + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationPredicate.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationPredicate.java new file mode 100644 index 000000000..6073ae5a1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationPredicate.java @@ -0,0 +1,55 @@ +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.exception.AccessDenyException; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * @author zhouhao + * @since 3.0 + */ +@FunctionalInterface +public interface AuthenticationPredicate extends Predicate { + + static AuthenticationPredicate has(String permissionString) { + return AuthenticationUtils.createPredicate(permissionString); + } + + static AuthenticationPredicate dimension(String dimension, String... id) { + return autz -> autz.hasDimension(dimension, Arrays.asList(id)); + } + + static AuthenticationPredicate permission(String permissionId, String... actions) { + return autz -> autz.hasPermission(permissionId, actions); + } + + default AuthenticationPredicate and(String permissionString) { + return and(has(permissionString)); + } + + default AuthenticationPredicate or(String permissionString) { + return or(has(permissionString)); + } + + @Override + default AuthenticationPredicate and(Predicate other) { + Objects.requireNonNull(other); + return (t) -> test(t) && other.test(t); + } + + @Override + default AuthenticationPredicate or(Predicate other) { + Objects.requireNonNull(other); + return (t) -> test(t) || other.test(t); + } + + + default void assertHas(Authentication authentication) { + if (!test(authentication)) { + throw new AccessDenyException(); + } + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationRequest.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationRequest.java new file mode 100644 index 000000000..78db8bfd8 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationRequest.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.authorization; + +import java.io.Serializable; + +/** + * @author zhouhao + * @since 3.0.0-RC + */ +public interface AuthenticationRequest extends Serializable { +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationSupplier.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationSupplier.java new file mode 100644 index 000000000..859ea81a6 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationSupplier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization; + +import reactor.core.publisher.Mono; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * @author zhouhao + * @see Supplier + * @see Authentication + * @see ReactiveAuthenticationHolder + */ +public interface AuthenticationSupplier extends Supplier> { + + Optional get(String userId); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationUtils.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationUtils.java new file mode 100644 index 000000000..758dc374f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/AuthenticationUtils.java @@ -0,0 +1,50 @@ +package org.hswebframework.web.authorization; + +import org.springframework.util.StringUtils; + +/** + * @author zhouhao + * @since 3.0 + */ +public class AuthenticationUtils { + + public static AuthenticationPredicate createPredicate(String expression) { + if (StringUtils.isEmpty(expression)) { + return (authentication -> false); + } + AuthenticationPredicate main = null; + // resource:user:add or update + AuthenticationPredicate temp = null; + boolean lastAnd = true; + for (String conf : expression.split("[ ]")) { + if (conf.startsWith("resource:")||conf.startsWith("permission:")) { + String[] permissionAndActions = conf.split("[:]", 2); + if (permissionAndActions.length < 2) { + temp = authentication -> !authentication.getPermissions().isEmpty(); + } else { + String[] real = permissionAndActions[1].split("[:]"); + temp = real.length > 1 ? + AuthenticationPredicate.permission(real[0], real[1].split("[,]")) + : AuthenticationPredicate.permission(real[0]); + } + } else if (main != null && conf.equalsIgnoreCase("and")) { + lastAnd = true; + main = main.and(temp); + } else if (main != null && conf.equalsIgnoreCase("or")) { + main = main.or(temp); + lastAnd = false; + } else { + String[] real = conf.split("[:]", 2); + if (real.length < 2) { + temp = AuthenticationPredicate.dimension(real[0]); + } else { + temp = AuthenticationPredicate.dimension(real[0], real[1].split(",")); + } + } + if (main == null) { + main = temp; + } + } + return main == null ? a -> false : (lastAnd ? main.and(temp) : main.or(temp)); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DefaultDimensionType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DefaultDimensionType.java new file mode 100644 index 000000000..613966f38 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DefaultDimensionType.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.authorization; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum DefaultDimensionType implements DimensionType { + user("用户"), + role("角色"); + + private String name; + + @Override + public String getId() { + return name(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Dimension.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Dimension.java new file mode 100644 index 000000000..f40f294a3 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Dimension.java @@ -0,0 +1,39 @@ +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.simple.SimpleDimension; + +import java.io.Serializable; +import java.util.Map; +import java.util.Optional; + +public interface Dimension extends Serializable { + String getId(); + + String getName(); + + DimensionType getType(); + + Map getOptions(); + + default Optional getOption(String key) { + return Optional.ofNullable(getOptions()) + .map(ops -> ops.get(key)) + .map(o -> (T) o); + } + + default boolean typeIs(DimensionType type) { + return this.getType() == type || this.getType().getId().equals(type.getId()); + } + + default boolean typeIs(String type) { + return this.getType().getId().equals(type); + } + + static Dimension of(String id, String name, DimensionType type) { + return of(id, name, type, null); + } + + static Dimension of(String id, String name, DimensionType type, Map options) { + return SimpleDimension.of(id, name, type, options); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DimensionProvider.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DimensionProvider.java new file mode 100644 index 000000000..41b6ea01f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DimensionProvider.java @@ -0,0 +1,59 @@ +package org.hswebframework.web.authorization; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collection; + +/** + * 维度提供商,用户管理维度信息 + * + * @author zhouhao + * @since 4.0 + */ +public interface DimensionProvider { + + /** + * 获取全部支持的维度 + * + * @return 全部支持的维度 + */ + Flux getAllType(); + + /** + * 获取用户获取维度信息 + * + * @param userId 用户ID + * @return 维度列表 + */ + Flux getDimensionByUserId(String userId); + + /** + * 根据维度类型和ID获取维度信息 + * + * @param type 类型 + * @param id ID + * @return 维度信息 + */ + Mono getDimensionById(DimensionType type, String id); + + /** + * 根据维度类型和Id获取多个维度 + * @param type 类型 + * @param idList ID + * @return 维度信息 + */ + default Flux getDimensionsById(DimensionType type, Collection idList){ + return Flux + .fromIterable(idList) + .flatMap(id->this.getDimensionById(type,id)); + } + + /** + * 根据维度ID获取用户ID + * + * @param dimensionId 维度ID + * @return 用户ID + */ + Flux getUserIdByDimensionId(String dimensionId); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DimensionType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DimensionType.java new file mode 100644 index 000000000..8eb9a4a04 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/DimensionType.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.authorization; + +public interface DimensionType { + String getId(); + + String getName(); + + default boolean isSameType(DimensionType another) { + return this == another || isSameType(another.getId()); + } + + default boolean isSameType(String anotherId) { + return this.getId().equals(anotherId); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Permission.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Permission.java new file mode 100644 index 000000000..d08b71f90 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Permission.java @@ -0,0 +1,236 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.access.DataAccessConfig; +import org.hswebframework.web.authorization.access.FieldFilterDataAccessConfig; +import org.hswebframework.web.authorization.access.ScopeDataAccessConfig; + +import java.io.Serializable; +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static org.hswebframework.web.authorization.access.DataAccessConfig.DefaultType.DENY_FIELDS; + +/** + * 用户持有的权限信息,包含了权限基本信息、可操作范围(action)、行,列级权限控制规则。 + * 是用户权限的重要接口。 + * + * @author zhouhao + * @see Authentication + * @since 3.0 + */ +public interface Permission extends Serializable { + /** + * 查询 + */ + String ACTION_QUERY = "query"; + /** + * 获取明细 + */ + String ACTION_GET = "get"; + /** + * 新增 + */ + String ACTION_ADD = "add"; + /** + * 保存 + */ + String ACTION_SAVE = "save"; + /** + * 更新 + */ + String ACTION_UPDATE = "update"; + + /** + * 删除 + */ + String ACTION_DELETE = "delete"; + /** + * 导入 + */ + String ACTION_IMPORT = "import"; + /** + * 导出 + */ + String ACTION_EXPORT = "export"; + + /** + * 禁用 + */ + String ACTION_DISABLE = "disable"; + + /** + * 启用 + */ + String ACTION_ENABLE = "enable"; + + /** + * @return 权限ID,权限的唯一标识 + */ + String getId(); + + /** + * @return 权限名称 + */ + String getName(); + + /** + * @return 其他拓展字段 + */ + Map getOptions(); + + default Optional getOption(String key) { + return Optional.ofNullable(getOptions()) + .map(map -> map.get(key)); + } + + /** + * 用户对此权限的可操作事件(按钮) + *

+ * ⚠️:任何时候都不应该对返回的Set进行写操作 + * + * @return 如果没有配置返回空{@link Collections#emptySet()},不会返回null. + */ + Set getActions(); + + /** + * 用户对此权限持有的数据权限信息, 用于数据级别的控制 + *

+ * ⚠️:任何时候都不应该对返回的Set进行写操作 + * + * @return 如果没有配置返回空{@link Collections#emptySet()},不会返回null. + * @see DataAccessConfig + * @see org.hswebframework.web.authorization.access.DataAccessController + */ + @Deprecated + Set getDataAccesses(); + + + default Set getDataAccesses(String action) { + return getDataAccesses() + .stream() + .filter(conf -> conf.getAction().equals(action)) + .collect(Collectors.toSet()); + } + + /** + * 查找数据权限配置 + * + * @param configPredicate 数据权限配置匹配规则 + * @param 数据权限配置类型 + * @return {@link Optional} + * @see this#scope(String, String, String) + */ + @SuppressWarnings("all") + default Optional findDataAccess(DataAccessPredicate configPredicate) { + return (Optional) getDataAccesses().stream() + .filter(configPredicate) + .findFirst(); + } + + /** + * 查找字段过滤的数据权限配置(列级数据权限),比如:不查询某些字段 + * + * @param action 权限操作类型 {@link Permission#ACTION_QUERY} + * @return {@link Optional} + * @see FieldFilterDataAccessConfig + * @see FieldFilterDataAccessConfig#getFields() + */ + default Optional findFieldFilter(String action) { + return findDataAccess(conf -> conf instanceof FieldFilterDataAccessConfig && conf.getAction().equals(action)); + } + + /** + * 获取不能执行操作的字段 + * + * @param action 权限操作 + * @return 未配置时返回空set, 不会返回null + */ + default Set findDenyFields(String action) { + return findFieldFilter(action) + .filter(conf -> DENY_FIELDS.equals(conf.getType().getId())) + .map(FieldFilterDataAccessConfig::getFields) + .orElseGet(Collections::emptySet); + } + + + /** + * 查找数据范围权限控制配置(行级数据权限),比如: 只能查询本机构的数据 + * + * @param type 范围类型标识,由具体的实现定义,如: 机构范围 + * @param scopeType 范围类型,由具体的实现定义,如: 只能查看自己所在机构 + * @param action 权限操作 {@link Permission#ACTION_QUERY} + * @return 未配置时返回空set, 不会返回null + */ + default Set findScope(String action, String type, String scopeType) { + return findScope(scope(action, type, scopeType)); + } + + default Set findScope(Permission.DataAccessPredicate predicate) { + return findDataAccess(predicate) + .map(ScopeDataAccessConfig::getScope) + .orElseGet(Collections::emptySet); + } + + /** + * 构造一个数据范围权限控制配置查找逻辑 + * + * @param type 范围类型标识,由具体的实现定义,如: 机构范围 + * @param scopeType 范围类型,由具体的实现定义,如: 只能查看自己所在机构 + * @param action 权限操作 {@link Permission#ACTION_QUERY} + * @return {@link DataAccessPredicate} + */ + static Permission.DataAccessPredicate scope(String action, String type, String scopeType) { + Objects.requireNonNull(action, "action can not be null"); + Objects.requireNonNull(type, "type can not be null"); + Objects.requireNonNull(scopeType, "scopeType can not be null"); + + return config -> + config instanceof ScopeDataAccessConfig + && action.equals(config.getAction()) + && type.equals(config.getType()) + && scopeType.equals(((ScopeDataAccessConfig) config).getScopeType()); + } + + Permission copy(); + + Permission copy(Predicate actionFilter,Predicate dataAccessFilter); + + /** + * 数据权限查找判断逻辑接口 + * + * @param + */ + interface DataAccessPredicate extends Predicate { + boolean test(DataAccessConfig config); + + + @Override + default DataAccessPredicate and(Predicate other) { + return (t) -> test(t) && other.test(t); + } + + @Override + default DataAccessPredicate or(Predicate other) { + return (t) -> test(t) || other.test(t); + } + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationHolder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationHolder.java new file mode 100644 index 000000000..e660a4c7e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationHolder.java @@ -0,0 +1,111 @@ +/* + * Copyright 2019 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization; + +import com.google.common.collect.Lists; +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +/** + * 响应式权限保持器,用于响应式方式获取当前登录用户的权限信息. + * 例如: + *
{@code
+ *     @RequestMapping("/example")
+ *     public Mono example(){
+ *         return ReactiveAuthenticationHolder.get();
+ *     }
+ *     }
+ * 
+ * + * @author zhouhao + * @see ReactiveAuthenticationSupplier + * @since 4.0 + */ +public final class ReactiveAuthenticationHolder { + private static final List suppliers = new CopyOnWriteArrayList<>(); + + private static Mono get(Function> function) { + return Flux + .merge(Lists.transform(suppliers, function::apply)) + .collect(AuthenticationMerging::new, AuthenticationMerging::merge) + .mapNotNull(AuthenticationMerging::get); + } + + /** + * @return 当前登录的用户权限信息 + */ + public static Mono get() { + + return get(ReactiveAuthenticationSupplier::get); + } + + /** + * 获取指定用户的权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + public static Mono get(String userId) { + return get(supplier -> supplier.get(userId)); + } + + /** + * 初始化 {@link ReactiveAuthenticationSupplier} + * + * @param supplier + */ + public static void addSupplier(ReactiveAuthenticationSupplier supplier) { + suppliers.add(supplier); + } + + public static void setSupplier(ReactiveAuthenticationSupplier supplier) { + suppliers.clear(); + suppliers.add(supplier); + } + + + static class AuthenticationMerging { + + private Authentication auth; + private int count; + + public synchronized void merge(Authentication auth) { + if (this.auth == null || this.auth == auth) { + this.auth = auth; + } else { + if (count++ == 0) { + SimpleAuthentication newAuth = new SimpleAuthentication(); + newAuth.merge(this.auth); + this.auth = newAuth; + } + this.auth.merge(auth); + } + } + + private Authentication get() { + return auth; + } + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationInitializeService.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationInitializeService.java new file mode 100644 index 000000000..ef76a503a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationInitializeService.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.events.AuthorizationInitializeEvent; +import reactor.core.publisher.Mono; + +/** + * 授权信息初始化服务接口,使用该接口初始化用的权限信息 + * + * @author zhouhao + * @since 4.0 + */ +public interface ReactiveAuthenticationInitializeService { + /** + * 根据用户ID初始化权限信息 + * + * @param userId 用户ID + * @return 权限信息 + * @see AuthorizationInitializeEvent + */ + Mono initUserAuthorization(String userId); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationManager.java new file mode 100644 index 000000000..4ea23d29e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationManager.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization; + +import reactor.core.publisher.Mono; + +/** + * 授权信息管理器,用于获取用户授权和同步授权信息 + * + * @author zhouhao + * @see 3.0 + */ +public interface ReactiveAuthenticationManager { + + /** + * 进行授权操作 + * + * @param request 授权请求 + * @return 授权成功则返回用户权限信息 + */ + Mono authenticate(Mono request); + + /** + * 根据用户ID获取权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + Mono getByUserId(String userId); + + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationManagerProvider.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationManagerProvider.java new file mode 100644 index 000000000..79b59ba21 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationManagerProvider.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.authorization; + +import reactor.core.publisher.Mono; + +public interface ReactiveAuthenticationManagerProvider { + /** + * 进行授权操作 + * + * @param request 授权请求 + * @return 授权成功则返回用户权限信息 + */ + Mono authenticate(Mono request); + + /** + * 根据用户ID获取权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + Mono getByUserId(String userId); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationSupplier.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationSupplier.java new file mode 100644 index 000000000..3992466be --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/ReactiveAuthenticationSupplier.java @@ -0,0 +1,33 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization; + +import reactor.core.publisher.Mono; + +import java.util.function.Supplier; + +/** + * @author zhouhao + * @see Supplier + * @see Authentication + * @see ReactiveAuthenticationHolder + * @since 4.0 + */ +public interface ReactiveAuthenticationSupplier extends Supplier> { + Mono get(String userId); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Role.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Role.java new file mode 100644 index 000000000..12f5127ab --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/Role.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization; + + +import org.hswebframework.web.authorization.simple.SimpleRole; + +/** + * 角色信息 + * + * @author zhouhao + * @since 3.0 + */ +public interface Role extends Dimension { + + /** + * @return 角色ID + */ + String getId(); + + /** + * @return 角色名 + */ + String getName(); + + @Override + default DimensionType getType() { + return DefaultDimensionType.role; + } + + static Role fromDimension(Dimension dimension){ + return SimpleRole.of(dimension); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/User.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/User.java new file mode 100644 index 000000000..0dcb8995a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/User.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization; + +/** + * 用户信息 + * + * @author zhouhao + * @since 3.0 + */ +public interface User extends Dimension { + /** + * @return 用户ID + */ + String getId(); + + /** + * @return 用户名 + */ + String getUsername(); + + /** + * @return 姓名 + */ + String getName(); + + /** + * @return 用户类型 + */ + String getUserType(); + + @Override + default DimensionType getType() { + return DefaultDimensionType.user; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessConfig.java new file mode 100644 index 000000000..30feaeea2 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessConfig.java @@ -0,0 +1,82 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.access; + + +import org.hswebframework.web.authorization.Permission; + +import java.io.Serializable; + +/** + * 数据级的权限控制,此接口为控制方式配置 + * 具体的控制逻辑由控制器{@link DataAccessController}实现 + * + * @author zhouhao + * @see OwnCreatedDataAccessConfig + */ +public interface DataAccessConfig extends Serializable { + + /** + * 对数据的操作事件 + * + * @return 操作时间 + * @see Permission#ACTION_ADD + * @see Permission#ACTION_DELETE + * @see Permission#ACTION_GET + * @see Permission#ACTION_QUERY + * @see Permission#ACTION_UPDATE + */ + String getAction(); + + /** + * 控制方式标识 + * + * @return 控制方式 + * @see DefaultType + */ + DataAccessType getType(); + + /** + * 内置的控制方式 + */ + interface DefaultType { + /** + * 自己创建的数据 + * + * @see OwnCreatedDataAccessConfig#getType() + */ + String OWN_CREATED = "OWN_CREATED"; + + /** + * 禁止操作字段 + * + * @see FieldFilterDataAccessConfig#getType() + */ + String DENY_FIELDS = "DENY_FIELDS"; + + /** + * 禁止操作字段 + * + * @see org.hswebframework.web.authorization.simple.DimensionDataAccessConfig#getType() + */ + String DIMENSION_SCOPE = "DIMENSION_SCOPE"; + + + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessConfiguration.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessConfiguration.java new file mode 100644 index 000000000..992b0f9a0 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessConfiguration.java @@ -0,0 +1,4 @@ +package org.hswebframework.web.authorization.access; + +public interface DataAccessConfiguration { +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessController.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessController.java new file mode 100644 index 000000000..1b66090a7 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessController.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.authorization.access; + +import org.hswebframework.web.authorization.define.AuthorizingContext; + +/** + * 数据级别权限控制器,通过此控制器对当前登录用户进行的操作进行数据级别的权限控制。 + * 如:A用户只能查询自己创建的B数据,A用户只能修改自己创建的B数据 + * + * @author zhouhao + * @since 3.0 + */ +public interface DataAccessController { + /** + * 执行权限控制 + * @param access 控制方式以及配置 + * @param context 权限验证上下文,用于传递验证过程用到的参数 + * @return 授权是否通过 + */ + boolean doAccess(DataAccessConfig access, AuthorizingContext context); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessHandler.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessHandler.java new file mode 100644 index 000000000..608163f6b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessHandler.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.authorization.access; + +import org.hswebframework.web.authorization.define.AuthorizingContext; + +/** + * 数据级别权限控制处理器接口,负责处理支持的权限控制配置 + * + * @author zhouhao + */ +public interface DataAccessHandler { + + /** + * 是否支持处理此配置 + * + * @param access 控制配置 + * @return 是否支持 + */ + boolean isSupport(DataAccessConfig access); + + /** + * 执行处理,返回处理结果 + * + * @param access 控制配置 + * @param context 参数上下文 + * @return 处理结果 + */ + boolean handle(DataAccessConfig access, AuthorizingContext context); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessType.java new file mode 100644 index 000000000..5678ccf88 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DataAccessType.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.authorization.access; + +public interface DataAccessType { + + String getId(); + + String getName(); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DefaultDataAccessType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DefaultDataAccessType.java new file mode 100644 index 000000000..aea6a7f19 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DefaultDataAccessType.java @@ -0,0 +1,32 @@ +package org.hswebframework.web.authorization.access; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.dict.Dict; +import org.hswebframework.web.dict.EnumDict; + +@Getter +@AllArgsConstructor +public enum DefaultDataAccessType implements DataAccessType, EnumDict { + USER_OWN_DATA("自己的数据"), + FIELD_DENY("禁止操作字段"), + DIMENSION_SCOPE("维度范围"); + + private final String name; + + @Override + public String getText() { + return name; + } + + @Override + public String getValue() { + return getId(); + } + + @Override + public String getId() { + return name().toLowerCase(); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DimensionHelper.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DimensionHelper.java new file mode 100644 index 000000000..9f3693295 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/DimensionHelper.java @@ -0,0 +1,67 @@ +package org.hswebframework.web.authorization.access; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.simple.DimensionDataAccessConfig; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public abstract class DimensionHelper { + + + public static Set getDimensionDataAccessScope(Authentication atz, + Permission permission, + String action, + String dimensionType) { + return permission + .getDataAccesses(action) + .stream() + .filter(DimensionDataAccessConfig.class::isInstance) + .map(DimensionDataAccessConfig.class::cast) + .filter(conf -> dimensionType.equals(conf.getScopeType())) + .flatMap(conf -> { + if (CollectionUtils.isEmpty(conf.getScope())) { + return atz.getDimensions(dimensionType) + .stream() + .map(Dimension::getId); + } + return conf.getScope().stream(); + }).collect(Collectors.toSet()); + } + + public static Set getDimensionDataAccessScope(Authentication atz, + Permission permission, + String action, + DimensionType dimensionType) { + return getDimensionDataAccessScope(atz, permission, action, dimensionType.getId()); + } + + + public static Set getDimensionDataAccessScope(Authentication atz, + String permission, + String action, + String dimensionType) { + return atz + .getPermission(permission) + .map(per -> getDimensionDataAccessScope(atz, per, action, dimensionType)).orElseGet(Collections::emptySet); + } + + public static Set getDimensionDataAccessScope(Authentication atz, + String permission, + String action, + DimensionType dimensionType) { + return atz + .getPermission(permission) + .map(per -> getDimensionDataAccessScope(atz, per, action, dimensionType)) + .orElseGet(Collections::emptySet); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/FieldFilterDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/FieldFilterDataAccessConfig.java new file mode 100644 index 000000000..8df9506d6 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/FieldFilterDataAccessConfig.java @@ -0,0 +1,14 @@ +package org.hswebframework.web.authorization.access; + +import java.util.Set; + +/** + * 对字段进行过滤操作配置,实现字段级别的权限控制 + * + * @author zhouhao + * @see DataAccessConfig + * @see org.hswebframework.web.authorization.simple.SimpleFieldFilterDataAccessConfig + */ +public interface FieldFilterDataAccessConfig extends DataAccessConfig { + Set getFields(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/OwnCreatedDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/OwnCreatedDataAccessConfig.java new file mode 100644 index 000000000..a295c8954 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/OwnCreatedDataAccessConfig.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.access; + +/** + * 只能操作由自己创建的数据 + * + * @author zhouhao + */ +public interface OwnCreatedDataAccessConfig extends DataAccessConfig { + @Override + default DataAccessType getType() { + return DefaultDataAccessType.USER_OWN_DATA; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/ScopeDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/ScopeDataAccessConfig.java new file mode 100644 index 000000000..d616ac255 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/ScopeDataAccessConfig.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.authorization.access; + +import java.util.Set; + +/** + * 范围数据权限控制配置 + * + * @author zhouhao + * @see DataAccessConfig + * @since 3.0 + */ +public interface ScopeDataAccessConfig extends DataAccessConfig { + + /** + * @return 范围类型 + */ + String getScopeType(); + + /** + * @return 自定义的控制范围 + */ + Set getScope(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/UserAttachEntity.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/UserAttachEntity.java new file mode 100644 index 000000000..c3e3f4b65 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/access/UserAttachEntity.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.authorization.access; + + +/** + * 和user关联的实体 + * + * @author zhouhao + * @since 3.0.6 + */ +public interface UserAttachEntity { + String userId = "userId"; + + String getUserId(); + + void setUserId(String userId); + + default String getUserIdProperty() { + return userId; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Authorize.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Authorize.java new file mode 100644 index 000000000..c8e647c08 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Authorize.java @@ -0,0 +1,88 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.define.Phased; + +import java.lang.annotation.*; + +/** + * 基础权限控制注解,提供基本的控制配置 + * + * @author zhouhao + * @see org.hswebframework.web.authorization.Authentication + * @see org.hswebframework.web.authorization.define.AuthorizeDefinition + * @see Resource + * @see ResourceAction + * @see Dimension + * @see DataAccess + * @since 3.0 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Authorize { + + Resource[] resources() default {}; + + Dimension[] dimension() default {}; + + /** + * 是否运行匿名访问,匿名访问时,直接允许执行,否则将进行权限验证. + * + * @return 是否允许匿名访问 + * @since 4.0.19 + */ + boolean anonymous() default false; + + /** + * 验证失败时返回的消息 + * + * @return 验证失败提示的消息 + */ + String message() default "无访问权限"; + + /** + * 是否合并类上的注解 + * + * @return 是否合并类上的注解 + */ + boolean merge() default true; + + /** + * 验证模式,在使用多个验证条件时有效 + * + * @return logical + */ + Logical logical() default Logical.DEFAULT; + + /** + * @return 验证时机,在方法调用前还是调用后 + */ + Phased phased() default Phased.before; + + /** + * @return 是否忽略, 忽略后将不进行权限控制 + */ + boolean ignore() default false; + + + String[] description() default {}; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/CreateAction.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/CreateAction.java new file mode 100644 index 000000000..3bc6b8a22 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/CreateAction.java @@ -0,0 +1,17 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.Permission; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@ResourceAction(id = Permission.ACTION_ADD, name = "新增") +public @interface CreateAction { + + @AliasFor(annotation = ResourceAction.class,attribute = "dataAccess") + DataAccess dataAccess() default @DataAccess(ignore = true); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DataAccess.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DataAccess.java new file mode 100644 index 000000000..ba0b4852f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DataAccess.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.access.DataAccessController; + +import java.lang.annotation.*; + +/** + * 数据级权限控制注解,用于进行需要数据级别权限控制的声明. + *

+ * 此注解仅用于声明此方法需要进行数据级权限控制,具体权限控制方式由控制器实{@link DataAccessController}现 + *

+ * + * @author zhouhao + * @see DataAccessController + * @see ResourceAction#dataAccess() + * @since 3.0 + * @deprecated 已弃用, 4.1中移除 + */ +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Deprecated +public @interface DataAccess { + + DataAccessType[] type() default {}; + + /** + * @return logical + */ + Logical logical() default Logical.AND; + + /** + * @return 是否忽略, 忽略后将不进行权限控制 + */ + boolean ignore() default false; + + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DataAccessType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DataAccessType.java new file mode 100644 index 000000000..675e3d911 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DataAccessType.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.access.DataAccessConfiguration; +import org.hswebframework.web.authorization.access.DataAccessController; + +import java.lang.annotation.*; + +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface DataAccessType { + + String id(); //标识 + + String name(); //名称 + + String[] description() default {}; + + /** + * @see DataAccessController + */ + Class controller() default DataAccessController.class; + + Class configuration() default DataAccessConfiguration.class; + + boolean ignore() default false; +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DeleteAction.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DeleteAction.java new file mode 100644 index 000000000..6df434c78 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DeleteAction.java @@ -0,0 +1,17 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.Permission; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@ResourceAction(id = Permission.ACTION_DELETE, name = "删除") +public @interface DeleteAction { + + @AliasFor(annotation = ResourceAction.class,attribute = "dataAccess") + DataAccess dataAccess() default @DataAccess(ignore = true); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Dimension.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Dimension.java new file mode 100644 index 000000000..87595228e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Dimension.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.authorization.annotation; + +import java.lang.annotation.*; + +@Target({ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Dimension { + + String type(); + + String[] id() default {}; + + Logical logical() default Logical.DEFAULT; + + String[] description() default {}; + + boolean ignore() default false; +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DimensionDataAccess.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DimensionDataAccess.java new file mode 100644 index 000000000..6a90247b2 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/DimensionDataAccess.java @@ -0,0 +1,34 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.define.Phased; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@DataAccessType(id = "dimension", name = "维度数据权限") +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Authorize +public @interface DimensionDataAccess { + + Mapping[] mapping() default {}; + + @AliasFor(annotation = Authorize.class) + Phased phased() default Phased.before; + + @AliasFor(annotation = DataAccessType.class) + boolean ignore() default false; + + @Retention(RetentionPolicy.RUNTIME) + @Documented + @Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) + @interface Mapping { + String dimensionType(); + + String property(); + + int idParamIndex() default -1; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/FieldDataAccess.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/FieldDataAccess.java new file mode 100644 index 000000000..70b129ebf --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/FieldDataAccess.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.annotation; + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +/** + * @deprecated 已弃用 + */ +@DataAccessType(id = "FIELD_DENY", name = "字段权限") +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Deprecated +public @interface FieldDataAccess { + + @AliasFor(annotation = DataAccessType.class) + boolean ignore() default false; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Logical.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Logical.java new file mode 100644 index 000000000..05637caaa --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Logical.java @@ -0,0 +1,22 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.annotation; + +public enum Logical { + AND, OR, DEFAULT +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/QueryAction.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/QueryAction.java new file mode 100644 index 000000000..6ea8aa44d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/QueryAction.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.Permission; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@ResourceAction(id = Permission.ACTION_QUERY, name = "查询") +public @interface QueryAction { + + @AliasFor(annotation = ResourceAction.class,attribute = "dataAccess") + DataAccess dataAccess() default @DataAccess(ignore = true); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/RequiresRoles.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/RequiresRoles.java new file mode 100644 index 000000000..8d16368e1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/RequiresRoles.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.authorization.annotation; + + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@Dimension(type = "role", description = "控制角色") +public @interface RequiresRoles { + + @AliasFor(annotation = Dimension.class, attribute = "id") + String[] value() default {}; + + @AliasFor(annotation = Dimension.class, attribute = "logical") + Logical logical() default Logical.DEFAULT; + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Resource.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Resource.java new file mode 100644 index 000000000..6b0efbe24 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/Resource.java @@ -0,0 +1,101 @@ +package org.hswebframework.web.authorization.annotation; + + +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.define.Phased; + +import java.lang.annotation.*; + +/** + * 接口资源声明注解,声明Controller的资源相关信息,用于进行权限控制。 + *
+ * 在Controller进行注解,表示此接口需要有对应的权限{@link Permission#getId()}才能进行访问. + * 具体的操作权限控制,需要在方法上注解{@link ResourceAction}. + *
+ * + * + *
{@code
+ * @RestController
+ * //声明资源
+ * @Resource(id = "test", name = "测试功能")
+ * public class TestController implements ReactiveCrudController {
+ *
+ *     //声明操作,需要有 test:query 权限才能访问此接口
+ *     @QueryAction
+ *     public Mono getUser() {
+ *         return Authentication.currentReactive()
+ *                 .switchIfEmpty(Mono.error(new UnAuthorizedException()))
+ *                 .map(Authentication::getUser);
+ *     }
+ *
+ * }
+ * }
+ * 
+ * 如果接口不需要进行权限控制,可注解{@link Authorize#ignore()}来标识此接口不需要权限控制. + * 或者通过监听 {@link org.hswebframework.web.authorization.events.AuthorizingHandleBeforeEvent}来进行自定义处理 + *
{@code
+ *   @EventListener
+ *   public void handleAuthEvent(AuthorizingHandleBeforeEvent e) {
+ *      //admin用户可以访问全部操作
+ *      if ("admin".equals(e.getContext().getAuthentication().getUser().getUsername())) {
+ *         e.setAllow(true);
+ *       }
+ *    }
+ * }
+ * + * @author zhouhao + * @see ResourceAction + * @see Authorize + * @see org.hswebframework.web.authorization.events.AuthorizingHandleBeforeEvent + * @since 4.0 + */ +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Resource { + + /** + * 资源ID + * + * @return 资源ID + */ + String id(); + + /** + * @return 资源名称 + */ + String name(); + + /** + * @return 资源操作定义 + */ + ResourceAction[] actions() default {}; + + /** + * @return 多个操作控制逻辑 + */ + Logical logical() default Logical.DEFAULT; + + /** + * @return 权限控制阶段 + */ + Phased phased() default Phased.before; + + /** + * @return 资源描述 + */ + String[] description() default {}; + + /** + * @return 资源分组 + */ + String[] group() default {}; + + /** + * 如果在方法上设置此属性,表示是否合并类上注解的属性 + * + * @return 是否合并 + */ + boolean merge() default true; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/ResourceAction.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/ResourceAction.java new file mode 100644 index 000000000..e8993be5d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/ResourceAction.java @@ -0,0 +1,65 @@ +package org.hswebframework.web.authorization.annotation; + + +import org.hswebframework.web.authorization.Permission; + +import java.lang.annotation.*; + +/** + * 对资源操作的描述,通常用来进行权限控制. + *

+ * 在Controller方法上添加此注解,来声明根据权限操作{@link Permission#getActions()}进行权限控制. + *

+ * 可以使用注解继承的方式来统一定义操作: + *

{@code
+ * @Target(ElementType.METHOD)
+ * @Retention(RetentionPolicy.RUNTIME)
+ * @Inherited
+ * @Documented
+ * @ResourceAction(id = "create", name = "新增")
+ * public @interface CreateAction {
+ *
+ * }
+ * }
+ * 
+ * + * @see CreateAction + * @see DeleteAction + * @see SaveAction + * @see org.hswebframework.web.authorization.Authentication + * @see Permission#getActions() + */ +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface ResourceAction { + /** + * 操作标识 + * + * @return 操作标识 + * @see Permission#getActions() + */ + String id(); + + /** + * @return 操作名称 + */ + String name(); + + /** + * @return 操作说明 + */ + String[] description() default {}; + + /** + * @return 多个操作时的判断逻辑 + */ + Logical logical() default Logical.DEFAULT; + + /** + * @deprecated 已弃用, 4.1中移除 + */ + @Deprecated + DataAccess[] dataAccess() default @DataAccess(ignore = true); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/SaveAction.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/SaveAction.java new file mode 100644 index 000000000..d878c52ae --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/SaveAction.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.Permission; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +/** + * 继承{@link ResourceAction},提供统一的id定义 + * + * @author zhouhao + * @since 4.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@ResourceAction(id = Permission.ACTION_SAVE, name = "保存") +public @interface SaveAction { + + @Deprecated + @AliasFor(annotation = ResourceAction.class, attribute = "dataAccess") + DataAccess dataAccess() default @DataAccess(ignore = true); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java new file mode 100644 index 000000000..7805bb966 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java @@ -0,0 +1,59 @@ +package org.hswebframework.web.authorization.annotation; + +import org.hswebframework.web.authorization.twofactor.TwoFactorValidator; + +import java.lang.annotation.*; + +/** + * 开启2FA双重验证 + * + * @see org.hswebframework.web.authorization.twofactor.TwoFactorValidatorManager + * @see org.hswebframework.web.authorization.twofactor.TwoFactorValidatorProvider + * @see org.hswebframework.web.authorization.twofactor.TwoFactorValidator + * @since 3.0.4 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface TwoFactor { + + /** + * @return 接口的标识, 用于实现不同的操作, 可能会配置不同的验证规则 + */ + String value(); + + /** + * @return 验证有效期, 超过有效期后需要重新进行验证 + */ + long timeout() default 10 * 60 * 1000L; + + /** + * 验证器供应商,如: totp,sms,email,由{@link org.hswebframework.web.authorization.twofactor.TwoFactorValidatorProvider}进行自定义. + *

+ * 可通过配置项: hsweb.authorize.two-factor.default-provider 来修改默认配置 + * + * @return provider + * @see TwoFactorValidator#getProvider() + */ + String provider() default "default"; + + /** + * 验证码的http参数名,在进行验证的时候需要传入此参数 + * + * @return 验证码的参数名 + */ + String parameter() default "verifyCode"; + + /** + * @return 关闭验证 + */ + boolean ignore() default false; + + /** + * + * @return 错误提示 + * @since 3.0.6 + */ + String message() default "validation.verify_code_error"; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/UserOwnData.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/UserOwnData.java new file mode 100644 index 000000000..74dbb12f7 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/UserOwnData.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.authorization.annotation; + +import java.lang.annotation.*; + +/** + * 声明某个操作支持用户查看自己的数据 + * + * @deprecated 已弃用 + */ +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@DataAccessType(id = "user_own_data", name = "用户自己的数据") +@Deprecated +public @interface UserOwnData { + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/AuthenticationBuilder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/AuthenticationBuilder.java new file mode 100644 index 000000000..aa51e1845 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/AuthenticationBuilder.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.builder; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.Role; +import org.hswebframework.web.authorization.User; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +public interface AuthenticationBuilder extends Serializable { + + AuthenticationBuilder user(User user); + + AuthenticationBuilder user(String user); + + AuthenticationBuilder user(Map user); + + + AuthenticationBuilder role(List role); + + AuthenticationBuilder role(String role); + + + AuthenticationBuilder permission(List permission); + + AuthenticationBuilder permission(String permission); + + AuthenticationBuilder attributes(String attributes); + + AuthenticationBuilder attributes(Map permission); + + AuthenticationBuilder json(String json); + + Authentication build(); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/AuthenticationBuilderFactory.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/AuthenticationBuilderFactory.java new file mode 100644 index 000000000..9477c96b9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/AuthenticationBuilderFactory.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.builder; + +/** + * 权限构造器工厂 + * + * @author zhouhao + */ +public interface AuthenticationBuilderFactory { + /** + * @return 新建一个权限构造器 + */ + AuthenticationBuilder create(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/DataAccessConfigBuilder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/DataAccessConfigBuilder.java new file mode 100644 index 000000000..154d78d66 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/DataAccessConfigBuilder.java @@ -0,0 +1,17 @@ +package org.hswebframework.web.authorization.builder; + +import org.hswebframework.web.authorization.access.DataAccessConfig; + +import java.util.Map; + +/** + * + * @author zhouhao + */ +public interface DataAccessConfigBuilder { + DataAccessConfigBuilder fromJson(String json); + + DataAccessConfigBuilder fromMap(Map json); + + DataAccessConfig build(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/DataAccessConfigBuilderFactory.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/DataAccessConfigBuilderFactory.java new file mode 100644 index 000000000..f364dc471 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/builder/DataAccessConfigBuilderFactory.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.builder; + +/** + * 数据权限配置构造器工厂 + * + * @author zhouhao + */ +public interface DataAccessConfigBuilderFactory { + /** + * @return 新建一个数据权限配置构造器工厂 + */ + DataAccessConfigBuilder create(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AopAuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AopAuthorizeDefinition.java new file mode 100644 index 000000000..a53ca7ea1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AopAuthorizeDefinition.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.define; + +import java.lang.reflect.Method; + +/** + * @author zhouhao + * @since 1.0 + */ +public interface AopAuthorizeDefinition extends AuthorizeDefinition { + Class getTargetClass(); + + Method getTargetMethod(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinition.java new file mode 100644 index 000000000..8ab805bd1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinition.java @@ -0,0 +1,36 @@ +package org.hswebframework.web.authorization.define; + + +import java.util.StringJoiner; + +/** + * 权限控制定义,定义权限控制的方式 + * + * @author zhouhao + * @since 3.0 + */ +public interface AuthorizeDefinition { + + ResourcesDefinition getResources(); + + DimensionsDefinition getDimensions(); + + String getMessage(); + + Phased getPhased(); + + boolean isEmpty(); + + default boolean allowAnonymous() { + return false; + } + + default String getDescription() { + ResourcesDefinition res = getResources(); + StringJoiner joiner = new StringJoiner(";"); + for (ResourceDefinition resource : res.getResources()) { + joiner.add(resource.getId() + ":" + String.join(",", resource.getActionIds())); + } + return joiner.toString(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionContext.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionContext.java new file mode 100644 index 000000000..8b69af611 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionContext.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.authorization.define; + +public interface AuthorizeDefinitionContext { + + void addResource(ResourceDefinition def); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionCustomizer.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionCustomizer.java new file mode 100644 index 000000000..b0fe8d320 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionCustomizer.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.authorization.define; + +public interface AuthorizeDefinitionCustomizer { + + void custom(AuthorizeDefinitionContext context); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionInitializedEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionInitializedEvent.java new file mode 100644 index 000000000..2c3b14ccc --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizeDefinitionInitializedEvent.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.define; + +import org.hswebframework.web.authorization.events.AuthorizationEvent; +import org.springframework.context.ApplicationEvent; + +import java.util.List; + +public class AuthorizeDefinitionInitializedEvent extends ApplicationEvent implements AuthorizationEvent { + private static final long serialVersionUID = -8185138454949381441L; + + public AuthorizeDefinitionInitializedEvent(List all) { + super(all); + } + + @SuppressWarnings("unchecked") + public List getAllDefinition() { + return ((List) getSource()); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizingContext.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizingContext.java new file mode 100644 index 000000000..bef19d1ac --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/AuthorizingContext.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.authorization.define; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.aop.MethodInterceptorContext; +import org.hswebframework.web.authorization.Authentication; + +/** + * 权限控制上下文 + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AuthorizingContext { + private AuthorizeDefinition definition; + + private Authentication authentication; + + private MethodInterceptorContext paramContext; + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/CompositeAuthorizeDefinitionCustomizer.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/CompositeAuthorizeDefinitionCustomizer.java new file mode 100644 index 000000000..54af14835 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/CompositeAuthorizeDefinitionCustomizer.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.authorization.define; + +import lombok.AllArgsConstructor; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +@AllArgsConstructor +public class CompositeAuthorizeDefinitionCustomizer implements AuthorizeDefinitionCustomizer{ + + private final List customizers; + + public CompositeAuthorizeDefinitionCustomizer(Iterable customizers){ + this(StreamSupport.stream(customizers.spliterator(),false).collect(Collectors.toList())); + } + + @Override + public void custom(AuthorizeDefinitionContext context) { + for (AuthorizeDefinitionCustomizer customizer : customizers) { + customizer.custom(context); + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DataAccessDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DataAccessDefinition.java new file mode 100644 index 000000000..f30135276 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DataAccessDefinition.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.authorization.define; + +import lombok.Getter; +import lombok.Setter; + +import java.util.*; + +@Getter +@Setter +public class DataAccessDefinition { + + Set dataAccessTypes = new HashSet<>(); + + public Optional getType(String typeId) { + return dataAccessTypes + .stream() + .filter(type -> type.getId() != null && type.getId().equalsIgnoreCase(typeId)) + .findAny(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DataAccessTypeDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DataAccessTypeDefinition.java new file mode 100644 index 000000000..abdce6057 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DataAccessTypeDefinition.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.authorization.define; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.access.DataAccessController; +import org.hswebframework.web.authorization.access.DataAccessType; +import org.hswebframework.web.authorization.access.DataAccessConfiguration; +import org.hswebframework.web.bean.FastBeanCopier; + +@Getter +@Setter +@EqualsAndHashCode(of = "id") +public class DataAccessTypeDefinition implements DataAccessType { + private String id; + + private String name; + + private String description; + + private Class controller; + + private Class configuration; + + public DataAccessTypeDefinition copy(){ + return FastBeanCopier.copy(this,DataAccessTypeDefinition::new); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DimensionDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DimensionDefinition.java new file mode 100644 index 000000000..e61a69cb4 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DimensionDefinition.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.authorization.define; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.annotation.Logical; +import org.hswebframework.web.bean.FastBeanCopier; + +import java.util.HashSet; +import java.util.Set; + +@Getter +@Setter +@EqualsAndHashCode(of = "typeId") +public class DimensionDefinition { + + private String typeId; + + private String typeName; + + private Set dimensionId = new HashSet<>(); + + private Logical logical = Logical.DEFAULT; + + public boolean hasDimension(String id) { + return dimensionId.contains(id); + } + + public DimensionDefinition copy() { + return FastBeanCopier.copy(this, DimensionDefinition::new); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DimensionsDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DimensionsDefinition.java new file mode 100644 index 000000000..ed8b15b7d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/DimensionsDefinition.java @@ -0,0 +1,46 @@ +package org.hswebframework.web.authorization.define; + +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.annotation.Logical; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Getter +@Setter +public class DimensionsDefinition { + + private Set dimensions = new HashSet<>(); + + private Logical logical = Logical.DEFAULT; + + public void addDimension(DimensionDefinition definition) { + dimensions.add(definition); + } + + public boolean isEmpty(){ + return CollectionUtils.isEmpty(this.dimensions); + } + + public boolean hasDimension(Dimension dimension) { + return dimensions + .stream() + .anyMatch(def -> + def.getTypeId().equals(dimension.getType().getId()) + && def.hasDimension(dimension.getId())); + } + + public boolean hasDimension(List dimensions) { + + if (logical == Logical.AND) { + return dimensions.stream().allMatch(this::hasDimension); + } + + return dimensions.stream().anyMatch(this::hasDimension); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/HandleType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/HandleType.java new file mode 100644 index 000000000..d921eb54a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/HandleType.java @@ -0,0 +1,5 @@ +package org.hswebframework.web.authorization.define; + +public enum HandleType{ + RBAC,DATA + } \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/MergedAuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/MergedAuthorizeDefinition.java new file mode 100644 index 000000000..b247c7a0c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/MergedAuthorizeDefinition.java @@ -0,0 +1,35 @@ +package org.hswebframework.web.authorization.define; + +import java.util.List; +import java.util.Set; + + +public class MergedAuthorizeDefinition implements AuthorizeDefinitionContext { + + private final ResourcesDefinition resources = new ResourcesDefinition(); + + private final DimensionsDefinition dimensions = new DimensionsDefinition(); + + public Set getResources() { + return resources.getResources(); + } + + public Set getDimensions() { + return dimensions.getDimensions(); + } + + public void addResource(ResourceDefinition resource) { + resources.addResource(resource, true); + } + + public void addDimension(DimensionDefinition resource) { + dimensions.addDimension(resource); + } + + public void merge(List definitions) { + for (AuthorizeDefinition definition : definitions) { + definition.getResources().getResources().forEach(this::addResource); + definition.getDimensions().getDimensions().forEach(this::addDimension); + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/Phased.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/Phased.java new file mode 100644 index 000000000..7675f361f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/Phased.java @@ -0,0 +1,5 @@ +package org.hswebframework.web.authorization.define; + +public enum Phased { + before, after +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourceActionDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourceActionDefinition.java new file mode 100644 index 000000000..b09339916 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourceActionDefinition.java @@ -0,0 +1,47 @@ +package org.hswebframework.web.authorization.define; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.i18n.I18nSupportUtils; +import org.hswebframework.web.i18n.MultipleI18nSupportEntity; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import static org.hswebframework.web.authorization.define.ResourceDefinition.supportLocale; + +@Getter +@Setter +@EqualsAndHashCode(of = "id") +public class ResourceActionDefinition implements MultipleI18nSupportEntity { + private String id; + + private String name; + + private String description; + + private Map> i18nMessages; + + private DataAccessDefinition dataAccess = new DataAccessDefinition(); + + + private final static String resolveActionPrefix = "hswebframework.web.system.action."; + + public ResourceActionDefinition copy() { + return FastBeanCopier.copy(this, ResourceActionDefinition::new); + } + + public Map> getI18nMessages() { + if (org.springframework.util.CollectionUtils.isEmpty(i18nMessages)) { + this.i18nMessages = I18nSupportUtils + .putI18nMessages( + resolveActionPrefix + this.id, "name", supportLocale, null, this.i18nMessages + ); + } + return i18nMessages; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourceDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourceDefinition.java new file mode 100644 index 000000000..815116669 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourceDefinition.java @@ -0,0 +1,134 @@ +package org.hswebframework.web.authorization.define; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.web.authorization.annotation.Logical; +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.i18n.I18nSupportUtils; +import org.hswebframework.web.i18n.MultipleI18nSupportEntity; + +import java.util.*; +import java.util.stream.Collectors; + +@Getter +@Setter +@EqualsAndHashCode(of = "id") +public class ResourceDefinition implements MultipleI18nSupportEntity { + private String id; + + private String name; + + private String description; + + private Set actions = new HashSet<>(); + + private List group; + + private Map> i18nMessages; + + @Setter(value = AccessLevel.PRIVATE) + @JsonIgnore + private volatile Set actionIds; + + private Logical logical = Logical.DEFAULT; + + private Phased phased = Phased.before; + + public final static List supportLocale = new ArrayList<>(); + + static { + supportLocale.add(Locale.CHINESE); + supportLocale.add(Locale.ENGLISH); + } + + + private final static String resolvePermissionPrefix = "hswebframework.web.system.permission."; + + public static ResourceDefinition of(String id, String name) { + ResourceDefinition definition = new ResourceDefinition(); + definition.setId(id); + definition.setName(name); + return definition; + } + + public Map> getI18nMessages() { + if (org.springframework.util.CollectionUtils.isEmpty(i18nMessages)) { + this.i18nMessages = I18nSupportUtils + .putI18nMessages( + resolvePermissionPrefix + this.id, "name", supportLocale, null, this.i18nMessages + ); + } + return i18nMessages; + } + + public ResourceDefinition copy() { + ResourceDefinition definition = FastBeanCopier.copy(this, ResourceDefinition::new); + definition.setActions(actions.stream().map(ResourceActionDefinition::copy).collect(Collectors.toSet())); + return definition; + } + + public ResourceDefinition addAction(String id, String name) { + ResourceActionDefinition action = new ResourceActionDefinition(); + action.setId(id); + action.setName(name); + return addAction(action); + } + + public synchronized ResourceDefinition addAction(ResourceActionDefinition action) { + actionIds = null; + ResourceActionDefinition old = getAction(action.getId()).orElse(null); + if (old != null) { + old.getDataAccess().getDataAccessTypes() + .addAll(action.getDataAccess().getDataAccessTypes()); + } + actions.add(action); + return this; + } + + public Optional getAction(String action) { + return actions.stream() + .filter(act -> act.getId().equalsIgnoreCase(action)) + .findAny(); + } + + public Set getActionIds() { + if (actionIds == null) { + actionIds = this.actions + .stream() + .map(ResourceActionDefinition::getId) + .collect(Collectors.toSet()); + } + return actionIds; + } + + @JsonIgnore + public List getDataAccessAction() { + return actions.stream() + .filter(act -> CollectionUtils.isNotEmpty(act.getDataAccess().getDataAccessTypes())) + .collect(Collectors.toList()); + } + + public boolean hasDataAccessAction() { + return actions.stream() + .anyMatch(act -> CollectionUtils.isNotEmpty(act.getDataAccess().getDataAccessTypes())); + } + + public boolean hasAction(Collection actions) { + if (CollectionUtils.isEmpty(this.actions)) { + return true; + } + + if (CollectionUtils.isEmpty(actions)) { + return false; + } + + if (logical == Logical.AND) { + return getActionIds().containsAll(actions); + } + return getActionIds().stream().anyMatch(actions::contains); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourcesDefinition.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourcesDefinition.java new file mode 100644 index 000000000..51a57a18a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/define/ResourcesDefinition.java @@ -0,0 +1,86 @@ +package org.hswebframework.web.authorization.define; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.annotation.Logical; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Getter +@Setter +public class ResourcesDefinition { + + private Set resources = new HashSet<>(); + + private Logical logical = Logical.DEFAULT; + + private Phased phased = Phased.before; + + public void addResource(ResourceDefinition resource, boolean merge) { + ResourceDefinition definition = getResource(resource.getId()).orElse(null); + if (definition != null) { + if (merge) { + resource.getActions() + .stream() + .map(ResourceActionDefinition::copy) + .forEach(definition::addAction); + } else { + resources.remove(definition); + } + } + resources.add(resource.copy()); + + } + + + public Optional getResource(String id) { + return resources + .stream() + .filter(resource -> resource.getId().equals(id)) + .findAny(); + } + + @JsonIgnore + public List getDataAccessResources() { + return resources + .stream() + .filter(ResourceDefinition::hasDataAccessAction) + .collect(Collectors.toList()); + } + + public boolean hasPermission(Permission permission) { + if (CollectionUtils.isEmpty(resources)) { + return true; + } + return getResource(permission.getId()) + .filter(resource -> resource.hasAction(permission.getActions())) + .isPresent(); + } + + public boolean isEmpty() { + return resources.isEmpty(); + } + + public boolean hasPermission(Authentication authentication) { + + if (CollectionUtils.isEmpty(resources)) { + return true; + } + + if (logical == Logical.AND) { + return resources + .stream() + .allMatch(resource -> authentication.hasPermission(resource.getId(), resource.getActionIds())); + } + + return resources + .stream() + .anyMatch(resource -> authentication.hasPermission(resource.getId(), resource.getActionIds())); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionManager.java new file mode 100644 index 000000000..ccb56a2e5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionManager.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.authorization.dimension; + +import reactor.core.publisher.Flux; + +import java.util.Collection; + +/** + * 维度管理器 + * + * @author zhouhao + * @since 4.0.12 + */ +public interface DimensionManager { + + /** + * 获取用户维度 + * + * @param userId 用户ID + * @return 用户维度信息 + */ + Flux getUserDimension(Collection userId); + + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserBind.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserBind.java new file mode 100644 index 000000000..fba4d9c7f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserBind.java @@ -0,0 +1,39 @@ +package org.hswebframework.web.authorization.dimension; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Externalizable; +import java.io.IOException; +import java.io.ObjectInput; +import java.io.ObjectOutput; + +@Getter +@Setter +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +public class DimensionUserBind implements Externalizable { + private static final long serialVersionUID = -6849794470754667710L; + + private String userId; + + private String dimensionType; + + private String dimensionId; + + @Override + public void writeExternal(ObjectOutput out) throws IOException { + out.writeUTF(userId); + out.writeUTF(dimensionType); + out.writeUTF(dimensionId); + } + + @Override + public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { + userId = in.readUTF(); + dimensionType = in.readUTF(); + dimensionId = in.readUTF(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserBindProvider.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserBindProvider.java new file mode 100644 index 000000000..eb0602673 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserBindProvider.java @@ -0,0 +1,11 @@ +package org.hswebframework.web.authorization.dimension; + +import reactor.core.publisher.Flux; + +import java.util.Collection; + +public interface DimensionUserBindProvider { + + Flux getDimensionBindInfo(Collection userIdList); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserDetail.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserDetail.java new file mode 100644 index 000000000..0bd69e80c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/dimension/DimensionUserDetail.java @@ -0,0 +1,37 @@ +package org.hswebframework.web.authorization.dimension; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.authorization.Dimension; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +public class DimensionUserDetail implements Serializable { + private static final long serialVersionUID = -6849794470754667710L; + + + private String userId; + + private List dimensions; + + public DimensionUserDetail merge(DimensionUserDetail detail) { + DimensionUserDetail newDetail = new DimensionUserDetail(); + newDetail.setUserId(userId); + newDetail.setDimensions(new ArrayList<>()); + if (null != dimensions) { + newDetail.dimensions.addAll(dimensions); + } + if (null != detail.getDimensions()) { + newDetail.dimensions.addAll(detail.getDimensions()); + } + return newDetail; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AbstractAuthorizationEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AbstractAuthorizationEvent.java new file mode 100644 index 000000000..2afbe39e8 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AbstractAuthorizationEvent.java @@ -0,0 +1,70 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + + +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.util.Optional; +import java.util.function.Function; + +/** + * 抽象授权事件,保存事件常用的数据 + * + * @author zhouhao + * @since 3.0 + */ +public abstract class AbstractAuthorizationEvent extends DefaultAsyncEvent implements AuthorizationEvent { + private static final long serialVersionUID = -3027505108916079214L; + + protected String username; + + protected String password; + + private final transient Function parameterGetter; + + /** + * 所有参数不能为null + * + * @param username 用户名 + * @param password 密码 + * @param parameterGetter 参数获取函数,用户获取授权时传入的参数 + */ + public AbstractAuthorizationEvent(String username, String password, Function parameterGetter) { + if (username == null || password == null || parameterGetter == null) { + throw new NullPointerException(); + } + this.username = username; + this.password = password; + this.parameterGetter = parameterGetter; + } + + @SuppressWarnings("unchecked") + public Optional getParameter(String name) { + return Optional.ofNullable((T) parameterGetter.apply(name)); + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationBeforeEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationBeforeEvent.java new file mode 100644 index 000000000..eb3d4d5e4 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationBeforeEvent.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + +import lombok.Getter; +import org.hswebframework.web.authorization.Authentication; + +import java.util.function.Function; + +/** + * 授权前事件 + * + * @author zhouhao + * @since 3.0 + */ +@Getter +public class AuthorizationBeforeEvent extends AbstractAuthorizationEvent { + + private static final long serialVersionUID = 5948747533500518524L; + + private String userId; + + private Authentication authentication; + + public AuthorizationBeforeEvent(String username, String password, Function parameterGetter) { + super(username, password, parameterGetter); + } + + public void setAuthorized(String userId) { + this.userId = userId; + } + + public void setAuthorized(Authentication authentication) { + this.authentication = authentication; + } + + public boolean isAuthorized() { + return userId != null || authentication != null; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationDecodeEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationDecodeEvent.java new file mode 100644 index 000000000..80b105e23 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationDecodeEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + +import java.util.function.Function; + +/** + * 在进行授权时的最开始,触发此事件进行用户名密码解码,解码后请调用{@link #setUsername(String)} {@link #setPassword(String)}重新设置用户名密码 + * + * @author zhouhao + * @since 3.0 + */ +public class AuthorizationDecodeEvent extends AbstractAuthorizationEvent { + + private static final long serialVersionUID = 5418501934490174251L; + + public AuthorizationDecodeEvent(String username, String password, Function parameterGetter) { + super(username, password, parameterGetter); + } + + public void setUsername(String username) { + super.username = username; + } + + public void setPassword(String password) { + super.password = password; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationEvent.java new file mode 100644 index 000000000..3310bf22f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationEvent.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + +/** + * 授权事件 + * + * @author zhouhao + * @see AuthorizationSuccessEvent + * @see AuthorizationFailedEvent + * @see AuthorizationBeforeEvent + * @see AuthorizationDecodeEvent + * @see AuthorizationExitEvent + * @see org.springframework.context.ApplicationEvent + * @since 3.0 + */ +public interface AuthorizationEvent { +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationExitEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationExitEvent.java new file mode 100644 index 000000000..f96287802 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationExitEvent.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.event.DefaultAsyncEvent; +import org.springframework.context.ApplicationEvent; + +/** + * 退出登录事件 + * + * @author zhouhao + */ +public class AuthorizationExitEvent extends DefaultAsyncEvent implements AuthorizationEvent { + + private static final long serialVersionUID = -4590245933665047280L; + + private final Authentication authentication; + + public AuthorizationExitEvent(Authentication authentication) { + this.authentication = authentication; + } + + public Authentication getAuthentication() { + return authentication; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationFailedEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationFailedEvent.java new file mode 100644 index 000000000..96091172f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationFailedEvent.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + +import java.util.function.Function; + +/** + * 授权失败时触发 + * + * @author zhouhao + */ +public class AuthorizationFailedEvent extends AbstractAuthorizationEvent { + + private static final long serialVersionUID = -101792832265740828L; + + /** + * 异常信息 + */ + private Throwable exception; + + public AuthorizationFailedEvent(String username, + String password, + Function parameterGetter) { + super(username, password, parameterGetter); + } + + public Throwable getException() { + return exception; + } + + public void setException(Throwable exception) { + this.exception = exception; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationInitializeEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationInitializeEvent.java new file mode 100644 index 000000000..93f9bb261 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationInitializeEvent.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.authorization.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.event.DefaultAsyncEvent; + +@Getter +@Setter +@AllArgsConstructor +public class AuthorizationInitializeEvent extends DefaultAsyncEvent { + + private Authentication authentication; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationSuccessEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationSuccessEvent.java new file mode 100644 index 000000000..6537b63c4 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizationSuccessEvent.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.events; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * 授权成功事件,当授权成功时,触发此事件,并传入授权的信息 + * + * @author zhouhao + * @see Authentication + * @since 3.0 + */ +public class AuthorizationSuccessEvent extends DefaultAsyncEvent implements AuthorizationEvent { + private static final long serialVersionUID = -2452116314154155058L; + private final Authentication authentication; + + private final transient Function parameterGetter; + + private Map result = new HashMap<>(); + + public AuthorizationSuccessEvent(Authentication authentication, Function parameterGetter) { + this.authentication = authentication; + this.parameterGetter = parameterGetter; + } + + public Authentication getAuthentication() { + return authentication; + } + + @SuppressWarnings("unchecked") + public Optional getParameter(String name) { + return Optional.ofNullable((T) parameterGetter.apply(name)); + } + + public Map getResult() { + return result; + } + + public void setResult(Map result) { + this.result = result; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizingHandleBeforeEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizingHandleBeforeEvent.java new file mode 100644 index 000000000..680646cb5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/events/AuthorizingHandleBeforeEvent.java @@ -0,0 +1,85 @@ +package org.hswebframework.web.authorization.events; + +import org.hswebframework.web.authorization.define.AuthorizingContext; +import org.hswebframework.web.authorization.define.HandleType; +import org.hswebframework.web.event.DefaultAsyncEvent; +import org.springframework.context.ApplicationEvent; + +/** + * 权限控制事件,在进行权限控制之前会推送此事件,用于自定义权限控制结果: + *

{@code
+ *   @EventListener
+ *   public void handleAuthEvent(AuthorizingHandleBeforeEvent e) {
+ *      //admin用户可以访问全部操作
+ *      if ("admin".equals(e.getContext().getAuthentication().getUser().getUsername())) {
+ *         e.setAllow(true);
+ *       }
+ *    }
+ * }
+ * + * @author zhouhao + * @since 4.0 + */ +public class AuthorizingHandleBeforeEvent extends DefaultAsyncEvent implements AuthorizationEvent { + + private boolean allow = false; + + private boolean execute = true; + + private String message; + + private final AuthorizingContext context; + + /** + * @deprecated 数据权限控制已取消,4.1版本后移除 + */ + @Deprecated + private final HandleType handleType; + + public AuthorizingHandleBeforeEvent(AuthorizingContext context, HandleType handleType) { + this.context = context; + this.handleType = handleType; + } + + public AuthorizingContext getContext() { + return context; + } + + public boolean isExecute() { + return execute; + } + + public boolean isAllow() { + return allow; + } + + /** + * 设置通过当前请求 + * + * @param allow allow + */ + public void setAllow(boolean allow) { + execute = false; + this.allow = allow; + } + + public String getMessage() { + return message; + } + + /** + * 设置错误提示消息 + * + * @param message 消息 + */ + public void setMessage(String message) { + this.message = message; + } + + /** + * @return 权限控制类型 + */ + public HandleType getHandleType() { + return handleType; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java new file mode 100644 index 000000000..7461f4470 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java @@ -0,0 +1,83 @@ +package org.hswebframework.web.authorization.exception; + +import lombok.Getter; +import org.hswebframework.web.exception.I18nSupportException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.Set; + +/** + * 权限验证异常 + * + * @author zhouhao + * @since 3.0 + */ +@ResponseStatus(HttpStatus.FORBIDDEN) +@Getter +public class AccessDenyException extends I18nSupportException { + + private static final long serialVersionUID = -5135300127303801430L; + + private String code; + + public AccessDenyException() { + this("error.access_denied"); + } + + public AccessDenyException(String message) { + super(message); + } + + public AccessDenyException(String permission, Set actions) { + super("error.permission_denied", permission, actions); + } + + public AccessDenyException(String message, String code) { + this(message, code, null); + } + + public AccessDenyException(String message, Throwable cause) { + this(message, "access_denied", cause); + } + + public AccessDenyException(String message, String code, Throwable cause) { + super(message, cause, code); + this.code = code; + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends AccessDenyException { + public NoStackTrace() { + super(); + } + + public NoStackTrace(String message) { + super(message); + } + + public NoStackTrace(String permission, Set actions) { + super(permission, actions); + } + + public NoStackTrace(String message, String code) { + super(message, code); + } + + public NoStackTrace(String message, Throwable cause) { + super(message, cause); + } + + public NoStackTrace(String message, String code, Throwable cause) { + super(message, code, cause); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java new file mode 100644 index 000000000..f5079ccf9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java @@ -0,0 +1,52 @@ +package org.hswebframework.web.authorization.exception; + +import lombok.Getter; +import org.hswebframework.web.exception.I18nSupportException; + +@Getter +public class AuthenticationException extends I18nSupportException { + + + public static String ILLEGAL_PASSWORD = "illegal_password"; + + public static String USER_DISABLED = "user_disabled"; + + + private final String code; + + public AuthenticationException(String code) { + this(code, "error." + code); + } + + public AuthenticationException(String code, String message) { + super(message); + this.code = code; + } + + public AuthenticationException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends AuthenticationException { + public NoStackTrace(String code) { + super(code); + } + + public NoStackTrace(String code, String message) { + super(code, message); + } + + public NoStackTrace(String code, String message, Throwable cause) { + super(code, message, cause); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/NeedTwoFactorException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/NeedTwoFactorException.java new file mode 100644 index 000000000..cbe41885f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/NeedTwoFactorException.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.exception; + +import lombok.Getter; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@Getter +public class NeedTwoFactorException extends RuntimeException { + private static final long serialVersionUID = 3655980280834947633L; + private String provider; + + public NeedTwoFactorException(String message, String provider) { + super(message); + this.provider = provider; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java new file mode 100644 index 000000000..e2bf61fe5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java @@ -0,0 +1,83 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.exception; + +import lombok.Getter; +import org.hswebframework.web.authorization.token.TokenState; +import org.hswebframework.web.exception.I18nSupportException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * 未授权异常 + * + * @author zhouhao + * @since 3.0 + */ +@Getter +@ResponseStatus(HttpStatus.UNAUTHORIZED) +public class UnAuthorizedException extends I18nSupportException { + private static final long serialVersionUID = 2422918455013900645L; + + private final TokenState state; + + public UnAuthorizedException() { + this(TokenState.expired); + } + + public UnAuthorizedException(TokenState state) { + this(state.getText(), state); + } + + public UnAuthorizedException(String message, TokenState state) { + super(message); + this.state = state; + } + + public UnAuthorizedException(String message, TokenState state, Throwable cause) { + super(message, cause); + this.state = state; + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends UnAuthorizedException { + public NoStackTrace() { + super(); + } + + public NoStackTrace(TokenState state) { + super(state); + } + + public NoStackTrace(String message, TokenState state) { + super(message, state); + } + + public NoStackTrace(String message, TokenState state, Throwable cause) { + super(message, state, cause); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/SettingNullValueHolder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/SettingNullValueHolder.java new file mode 100644 index 000000000..05a445365 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/SettingNullValueHolder.java @@ -0,0 +1,56 @@ +package org.hswebframework.web.authorization.setting; + +import java.util.List; +import java.util.Optional; + +/** + * @author zhouhao + * @since 1.0.0 + */ +public class SettingNullValueHolder implements SettingValueHolder { + + public static final SettingNullValueHolder INSTANCE = new SettingNullValueHolder(); + + private SettingNullValueHolder() { + } + + @Override + public Optional> asList(Class t) { + return Optional.empty(); + } + + @Override + public Optional as(Class t) { + return Optional.empty(); + } + + @Override + public Optional asString() { + return Optional.empty(); + } + + @Override + public Optional asLong() { + return Optional.empty(); + } + + @Override + public Optional asInt() { + return Optional.empty(); + } + + @Override + public Optional asDouble() { + return Optional.empty(); + } + + @Override + public Optional getValue() { + return Optional.empty(); + } + + @Override + public UserSettingPermission getPermission() { + return UserSettingPermission.NONE; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/SettingValueHolder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/SettingValueHolder.java new file mode 100644 index 000000000..6564b1bfe --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/SettingValueHolder.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.authorization.setting; + +import java.util.List; +import java.util.Optional; + +public interface SettingValueHolder { + + SettingValueHolder NULL = SettingNullValueHolder.INSTANCE; + + Optional> asList(Class t); + + Optional as(Class t); + + Optional asString(); + + Optional asLong(); + + Optional asInt(); + + Optional asDouble(); + + Optional getValue(); + + UserSettingPermission getPermission(); + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/StringSourceSettingHolder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/StringSourceSettingHolder.java new file mode 100644 index 000000000..af2bbf0ab --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/StringSourceSettingHolder.java @@ -0,0 +1,98 @@ +package org.hswebframework.web.authorization.setting; + + +import com.alibaba.fastjson.JSON; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.utils.StringUtils; +import org.hswebframework.web.dict.EnumDict; + +import java.util.List; +import java.util.Optional; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@AllArgsConstructor +@Getter +public class StringSourceSettingHolder implements SettingValueHolder { + + private String value; + + private UserSettingPermission permission; + + public static SettingValueHolder of(String value, UserSettingPermission permission) { + if (value == null) { + return SettingValueHolder.NULL; + } + return new StringSourceSettingHolder(value, permission); + } + + @Override + public Optional> asList(Class t) { + return getNativeValue() + .map(v -> JSON.parseArray(v, t)); + } + + protected T convert(String value, Class t) { + if (t.isEnum()) { + if (EnumDict.class.isAssignableFrom(t)) { + T val = (T) EnumDict.find((Class) t, value).orElse(null); + if (null != val) { + return val; + } + } + for (T enumConstant : t.getEnumConstants()) { + if (((Enum) enumConstant).name().equalsIgnoreCase(value)) { + return enumConstant; + } + } + } + return JSON.parseObject(value, t); + } + + @Override + @SuppressWarnings("all") + public Optional as(Class t) { + if (t == String.class) { + return (Optional) asString(); + } else if (Long.class == t || long.class == t) { + return (Optional) asLong(); + } else if (Integer.class == t || int.class == t) { + return (Optional) asInt(); + } else if (Double.class == t || double.class == t) { + return (Optional) asDouble(); + } + return getNativeValue().map(v -> convert(v, t)); + } + + @Override + public Optional asString() { + return getNativeValue(); + } + + @Override + public Optional asLong() { + return getNativeValue().map(StringUtils::toLong); + } + + @Override + public Optional asInt() { + return getNativeValue().map(StringUtils::toInt); + } + + @Override + public Optional asDouble() { + return getNativeValue().map(StringUtils::toDouble); + } + + private Optional getNativeValue() { + return Optional.ofNullable(value); + } + + @Override + public Optional getValue() { + return Optional.ofNullable(value); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/UserSettingManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/UserSettingManager.java new file mode 100644 index 000000000..61bd689db --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/UserSettingManager.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.setting; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public interface UserSettingManager { + + SettingValueHolder getSetting(String userId, String key); + + void saveSetting(String userId, String key, String value, UserSettingPermission permission); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/UserSettingPermission.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/UserSettingPermission.java new file mode 100644 index 000000000..389003498 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/setting/UserSettingPermission.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.authorization.setting; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.dict.Dict; +import org.hswebframework.web.dict.EnumDict; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@AllArgsConstructor +@Getter +@Dict("user-setting-permission") +public enum UserSettingPermission implements EnumDict { + NONE("无"), + R("读"), + W("写"), + RW("读写"); + private String text; + + @Override + public String getValue() { + return name(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/AbstractDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/AbstractDataAccessConfig.java new file mode 100644 index 000000000..cf4fb3a76 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/AbstractDataAccessConfig.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.authorization.simple; + +import org.hswebframework.web.authorization.access.DataAccessConfig; + +/** + * @author zhouhao + * @see DataAccessConfig + * @since 3.0 + */ +public abstract class AbstractDataAccessConfig implements DataAccessConfig { + + private static final long serialVersionUID = -9025349704771557106L; + + private String action; + + @Override + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/CompositeReactiveAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/CompositeReactiveAuthenticationManager.java new file mode 100644 index 000000000..4a14a0128 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/CompositeReactiveAuthenticationManager.java @@ -0,0 +1,61 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.web.authorization.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +@AllArgsConstructor +@Slf4j +public class CompositeReactiveAuthenticationManager implements ReactiveAuthenticationManager { + + private final List providers; + + @Override + public Mono authenticate(Mono request) { + return Flux.concat(providers + .stream() + .map(manager -> manager + .authenticate(request) + .onErrorResume((err) -> { + log.warn("get user authenticate error", err); + return Mono.empty(); + })) + .collect(Collectors.toList())) + .take(1) + .next(); + } + + @Override + public Mono getByUserId(String userId) { + return Flux + .fromStream(providers + .stream() + .map(manager -> manager + .getByUserId(userId) + .onErrorResume((err) -> { + log.warn("get user [{}] authentication error", userId, err); + return Mono.empty(); + }) + )) + .flatMap(Function.identity()) + .collectList() + .filter(CollectionUtils::isNotEmpty) + .map(all -> { + if (all.size() == 1) { + return all.get(0); + } + SimpleAuthentication authentication = new SimpleAuthentication(); + for (Authentication auth : all) { + authentication.merge(auth); + } + return authentication; + }); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DefaultAuthorizationAutoConfiguration.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DefaultAuthorizationAutoConfiguration.java new file mode 100644 index 000000000..704980582 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DefaultAuthorizationAutoConfiguration.java @@ -0,0 +1,111 @@ +package org.hswebframework.web.authorization.simple; + +import org.hswebframework.web.authorization.*; +import org.hswebframework.web.authorization.builder.AuthenticationBuilderFactory; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.dimension.DimensionManager; +import org.hswebframework.web.authorization.dimension.DimensionUserBindProvider; +import org.hswebframework.web.authorization.simple.builder.DataAccessConfigConverter; +import org.hswebframework.web.authorization.simple.builder.SimpleAuthenticationBuilderFactory; +import org.hswebframework.web.authorization.simple.builder.SimpleDataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.token.*; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorManager; +import org.hswebframework.web.authorization.twofactor.defaults.DefaultTwoFactorValidatorManager; +import org.hswebframework.web.convert.CustomMessageConverter; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * @author zhouhao + */ +@AutoConfiguration +public class DefaultAuthorizationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(UserTokenManager.class) + @ConfigurationProperties(prefix = "hsweb.user-token") + public UserTokenManager userTokenManager() { + return new DefaultUserTokenManager(); + } + + @Bean + @ConditionalOnMissingBean +// @ConditionalOnBean(ReactiveAuthenticationManagerProvider.class) + public ReactiveAuthenticationManager reactiveAuthenticationManager(List providers) { + return new CompositeReactiveAuthenticationManager(providers); + } + + @Bean + @ConditionalOnBean(ReactiveAuthenticationManager.class) + public UserTokenReactiveAuthenticationSupplier userTokenReactiveAuthenticationSupplier(UserTokenManager userTokenManager, + ReactiveAuthenticationManager authenticationManager) { + UserTokenReactiveAuthenticationSupplier supplier = new UserTokenReactiveAuthenticationSupplier(userTokenManager, authenticationManager); + ReactiveAuthenticationHolder.addSupplier(supplier); + return supplier; + } + + @Bean + @ConditionalOnBean(AuthenticationManager.class) + public UserTokenAuthenticationSupplier userTokenAuthenticationSupplier(UserTokenManager userTokenManager, + AuthenticationManager authenticationManager) { + UserTokenAuthenticationSupplier supplier = new UserTokenAuthenticationSupplier(userTokenManager, authenticationManager); + AuthenticationHolder.addSupplier(supplier); + return supplier; + } + + @Bean + @ConditionalOnMissingBean(DataAccessConfigBuilderFactory.class) + @ConfigurationProperties(prefix = "hsweb.authorization.data-access", ignoreInvalidFields = true) + public SimpleDataAccessConfigBuilderFactory dataAccessConfigBuilderFactory() { + return new SimpleDataAccessConfigBuilderFactory(); + } + + @Bean + @ConditionalOnMissingBean(TwoFactorValidatorManager.class) + @ConfigurationProperties("hsweb.authorize.two-factor") + public DefaultTwoFactorValidatorManager defaultTwoFactorValidatorManager() { + return new DefaultTwoFactorValidatorManager(); + } + + @Bean + @ConditionalOnMissingBean(AuthenticationBuilderFactory.class) + public AuthenticationBuilderFactory authenticationBuilderFactory(DataAccessConfigBuilderFactory dataAccessConfigBuilderFactory) { + return new SimpleAuthenticationBuilderFactory(dataAccessConfigBuilderFactory); + } + + @Bean + public CustomMessageConverter authenticationCustomMessageConverter(AuthenticationBuilderFactory factory) { + return new CustomMessageConverter() { + @Override + public boolean support(Class clazz) { + return clazz == Authentication.class; + } + + @Override + public Object convert(Class clazz, byte[] message) { + String json = new String(message); + + return factory.create().json(json).build(); + } + }; + } + + @Bean + @ConditionalOnMissingBean(DimensionManager.class) + public DimensionManager defaultDimensionManager(ObjectProviderbindProviders, + ObjectProvider providers){ + DefaultDimensionManager manager = new DefaultDimensionManager(); + bindProviders.forEach(manager::addBindProvider); + providers.forEach(manager::addProvider); + + return manager; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DefaultDimensionManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DefaultDimensionManager.java new file mode 100644 index 000000000..9246b1223 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DefaultDimensionManager.java @@ -0,0 +1,90 @@ +package org.hswebframework.web.authorization.simple; + +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.DimensionProvider; +import org.hswebframework.web.authorization.dimension.DimensionManager; +import org.hswebframework.web.authorization.dimension.DimensionUserBind; +import org.hswebframework.web.authorization.dimension.DimensionUserBindProvider; +import org.hswebframework.web.authorization.dimension.DimensionUserDetail; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class DefaultDimensionManager implements DimensionManager { + + private final List dimensionProviders = new CopyOnWriteArrayList<>(); + private final List bindProviders = new CopyOnWriteArrayList<>(); + + private final Mono> providerMapping = Flux + .defer(() -> Flux.fromIterable(dimensionProviders)) + .flatMap(provider -> provider + .getAllType() + .map(type -> Tuples.of(type.getId(), provider))) + .collectMap(Tuple2::getT1, Tuple2::getT2); + + public DefaultDimensionManager() { + + } + + public void addProvider(DimensionProvider provider) { + dimensionProviders.add(provider); + } + + public void addBindProvider(DimensionUserBindProvider bindProvider) { + bindProviders.add(bindProvider); + } + + private Mono> providerMapping() { + return providerMapping; + } + + @Override + public Flux getUserDimension(Collection userId) { + return this + .providerMapping() + .flatMapMany(providerMapping -> Flux + .fromIterable(bindProviders) + //获取绑定信息 + .flatMap(provider -> provider.getDimensionBindInfo(userId)) + .groupBy(DimensionUserBind::getDimensionType) + .flatMap(group -> { + String type = group.key(); + Flux binds = group.cache(); + DimensionProvider provider = providerMapping.get(type); + if (null == provider) { + return Mono.empty(); + } + //获取维度信息 + return binds + .map(DimensionUserBind::getDimensionId) + .collect(Collectors.toSet()) + .flatMapMany(idList -> provider.getDimensionsById(SimpleDimensionType.of(type), idList)) + .collectMap(Dimension::getId, Function.identity()) + .flatMapMany(mapping -> binds + .groupBy(DimensionUserBind::getUserId) + .flatMap(userGroup -> Mono + .zip( + Mono.just(userGroup.key()), + userGroup + .handle((bind, sink) -> { + Dimension dimension = mapping.get(bind.getDimensionId()); + if (dimension != null) { + sink.next(dimension); + } + }) + .collectList(), + DimensionUserDetail::of + )) + ); + }) + ) + .groupBy(DimensionUserDetail::getUserId) + .flatMap(group->group.reduce(DimensionUserDetail::merge)); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DimensionDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DimensionDataAccessConfig.java new file mode 100644 index 000000000..3526c489f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/DimensionDataAccessConfig.java @@ -0,0 +1,32 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.access.DataAccessType; +import org.hswebframework.web.authorization.access.DefaultDataAccessType; +import org.hswebframework.web.authorization.access.ScopeDataAccessConfig; +import org.hswebframework.web.authorization.simple.AbstractDataAccessConfig; + +import java.util.Set; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +public class DimensionDataAccessConfig extends AbstractDataAccessConfig implements ScopeDataAccessConfig { + + private Set scope; + + private boolean children; + + /** + * @see DimensionType#getId() + */ + private String scopeType; + + @Override + public DefaultDataAccessType getType() { + return DefaultDataAccessType.DIMENSION_SCOPE; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/PlainTextUsernamePasswordAuthenticationRequest.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/PlainTextUsernamePasswordAuthenticationRequest.java new file mode 100644 index 000000000..7c857edf8 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/PlainTextUsernamePasswordAuthenticationRequest.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.authorization.AuthenticationRequest; + +/** + * @author zhouhao + * @since 3.0.0-RC + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PlainTextUsernamePasswordAuthenticationRequest implements AuthenticationRequest { + private String username; + + private String password; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleAuthentication.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleAuthentication.java new file mode 100644 index 000000000..da6c2dc98 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleAuthentication.java @@ -0,0 +1,129 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.simple; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.*; + +import java.io.Serializable; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +@Getter +@Setter +public class SimpleAuthentication implements Authentication { + + private static final long serialVersionUID = -2898863220255336528L; + + private User user; + + private List permissions = new ArrayList<>(); + + private List dimensions = new ArrayList<>(); + + private Map attributes = new HashMap<>(); + + public static Authentication of() { + return new SimpleAuthentication(); + } + + @Override + @SuppressWarnings("unchecked") + public Optional getAttribute(String name) { + return Optional.ofNullable((T) attributes.get(name)); + } + + @Override + public Map getAttributes() { + return attributes; + } + + public SimpleAuthentication merge(Authentication authentication) { + Map mePermissionGroup = permissions + .stream() + .collect(Collectors.toMap(Permission::getId, Function.identity())); + + if (authentication.getUser() != null) { + user = authentication.getUser(); + } + + attributes.putAll(authentication.getAttributes()); + + for (Permission permission : authentication.getPermissions()) { + Permission me = mePermissionGroup.get(permission.getId()); + if (me == null) { + permissions.add(permission.copy()); + continue; + } + me.getActions().addAll(permission.getActions()); + me.getDataAccesses().addAll(permission.getDataAccesses()); + } + + for (Dimension dimension : authentication.getDimensions()) { + if (!getDimension(dimension.getType(), dimension.getId()).isPresent()) { + dimensions.add(dimension); + } + } + return this; + } + + protected SimpleAuthentication newInstance() { + return new SimpleAuthentication(); + } + + @Override + public Authentication copy(BiPredicate permissionFilter, + Predicate dimension) { + SimpleAuthentication authentication = newInstance(); + authentication.setDimensions(dimensions.stream().filter(dimension).collect(Collectors.toList())); + authentication.setPermissions(permissions + .stream() + .map(permission -> permission.copy(action -> permissionFilter.test(permission, action), conf -> true)) + .filter(per -> !per.getActions().isEmpty()) + .collect(Collectors.toList()) + ); + authentication.setUser(user); + authentication.setAttributes(new HashMap<>(attributes)); + return authentication; + } + + public void setUser(User user) { + this.user = user; + dimensions.add(user); + } + + protected void setUser0(User user) { + this.user = user; + } + + public void setDimensions(List dimensions) { + this.dimensions.addAll(dimensions); + } + + public void setDimensions(Collection dimensions) { + this.dimensions.addAll(dimensions); + } + + public void addDimension(Dimension dimension) { + this.dimensions.add(dimension); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleDimension.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleDimension.java new file mode 100644 index 000000000..7a2f1a152 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleDimension.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.*; +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.DimensionType; + +import java.util.Map; + +@Getter +@Setter +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@EqualsAndHashCode +public class SimpleDimension implements Dimension { + + private String id; + + private String name; + + private DimensionType type; + + private Map options; + + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleDimensionType.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleDimensionType.java new file mode 100644 index 000000000..9546cb98c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleDimensionType.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.*; +import org.hswebframework.web.authorization.DimensionType; + +import java.io.Serializable; + +@Getter +@Setter +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@EqualsAndHashCode +public class SimpleDimensionType implements DimensionType, Serializable { + private static final long serialVersionUID = -6849794470754667710L; + + private String id; + + private String name; + + public static SimpleDimensionType of(String id) { + return of(id, id); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleFieldFilterDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleFieldFilterDataAccessConfig.java new file mode 100644 index 000000000..115c0cb5f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleFieldFilterDataAccessConfig.java @@ -0,0 +1,45 @@ +package org.hswebframework.web.authorization.simple; + +import org.hswebframework.web.authorization.access.DataAccessType; +import org.hswebframework.web.authorization.access.DefaultDataAccessType; +import org.hswebframework.web.authorization.access.FieldFilterDataAccessConfig; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.hswebframework.web.authorization.access.DataAccessConfig.DefaultType.DENY_FIELDS; + +/** + * 默认配置实现 + * + * @author zhouhao + * @see FieldFilterDataAccessConfig + * @since 3.0 + */ +public class SimpleFieldFilterDataAccessConfig extends AbstractDataAccessConfig implements FieldFilterDataAccessConfig { + private static final long serialVersionUID = 8080660575093151866L; + + private Set fields; + + public SimpleFieldFilterDataAccessConfig() { + } + + public SimpleFieldFilterDataAccessConfig(String... fields) { + this.fields = new HashSet<>(Arrays.asList(fields)); + } + + @Override + public Set getFields() { + return fields; + } + + public void setFields(Set fields) { + this.fields = fields; + } + + @Override + public DataAccessType getType() { + return DefaultDataAccessType.FIELD_DENY; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleOwnCreatedDataAccessConfig.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleOwnCreatedDataAccessConfig.java new file mode 100644 index 000000000..0e3f6c100 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleOwnCreatedDataAccessConfig.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.simple; + +import org.hswebframework.web.authorization.access.OwnCreatedDataAccessConfig; + +/** + * @author zhouhao + * @since 3.0 + */ +public class SimpleOwnCreatedDataAccessConfig extends AbstractDataAccessConfig implements OwnCreatedDataAccessConfig { + + private static final long serialVersionUID = -6059330812806119730L; + + public SimpleOwnCreatedDataAccessConfig() { + } + + public SimpleOwnCreatedDataAccessConfig(String action) { + setAction(action); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimplePermission.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimplePermission.java new file mode 100644 index 000000000..bba462060 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimplePermission.java @@ -0,0 +1,72 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.*; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.access.DataAccessConfig; + +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * @author zhouhao + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(exclude = "dataAccesses") +public class SimplePermission implements Permission { + + private static final long serialVersionUID = 7587266693680162184L; + + private String id; + + private String name; + + private Set actions; + + private Set dataAccesses; + + private Map options; + + public Set getActions() { + if (actions == null) { + actions = new java.util.HashSet<>(); + } + return actions; + } + + public Set getDataAccesses() { + if (dataAccesses == null) { + dataAccesses = new java.util.HashSet<>(); + } + return dataAccesses; + } + + @Override + public Permission copy(Predicate actionFilter, + Predicate dataAccessFilter) { + SimplePermission permission = new SimplePermission(); + + permission.setId(id); + permission.setName(name); + permission.setActions(getActions().stream().filter(actionFilter).collect(Collectors.toSet())); + permission.setDataAccesses(getDataAccesses().stream().filter(dataAccessFilter).collect(Collectors.toSet())); + if (options != null) { + permission.setOptions(new HashMap<>(options)); + } + return permission; + } + + public Permission copy() { + return copy(action -> true, conf -> true); + } + + @Override + public String toString() { + return id + (CollectionUtils.isNotEmpty(actions) ? ":" + String.join(",", actions) : ""); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleRole.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleRole.java new file mode 100644 index 000000000..e06280ea8 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleRole.java @@ -0,0 +1,36 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.*; +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.Role; + +import java.io.Serializable; +import java.util.Map; + +/** + * @author zhouhao + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class SimpleRole implements Role { + + private static final long serialVersionUID = 7460859165231311347L; + + private String id; + + private String name; + + private Map options; + + public static Role of(Dimension dimension) { + return SimpleRole.builder() + .name(dimension.getName()) + .id(dimension.getId()) + .options(dimension.getOptions()) + .build(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleUser.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleUser.java new file mode 100644 index 000000000..da1a555fe --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/SimpleUser.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.authorization.simple; + +import lombok.*; +import org.hswebframework.web.authorization.User; + +import java.io.Serializable; +import java.util.Map; + +/** + * @author zhouhao + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode +public class SimpleUser implements User { + + private static final long serialVersionUID = 2194541828191869091L; + + private String id; + + private String username; + + private String name; + + private String userType; + + private Map options; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/DataAccessConfigConverter.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/DataAccessConfigConverter.java new file mode 100644 index 000000000..cae56d363 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/DataAccessConfigConverter.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.simple.builder; + +import org.hswebframework.web.authorization.access.DataAccessConfig; + +/** + * @author zhouhao + */ +public interface DataAccessConfigConverter { + + boolean isSupport(String type, String action, String config); + + DataAccessConfig convert(String type, String action, String config); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleAuthenticationBuilder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleAuthenticationBuilder.java new file mode 100644 index 000000000..696738f3f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleAuthenticationBuilder.java @@ -0,0 +1,169 @@ +package org.hswebframework.web.authorization.simple.builder; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.google.common.collect.Maps; +import org.hswebframework.web.authorization.*; +import org.hswebframework.web.authorization.builder.AuthenticationBuilder; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.simple.*; + +import java.io.Serializable; +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author zhouhao + */ +public class SimpleAuthenticationBuilder implements AuthenticationBuilder { + private SimpleAuthentication authentication = new SimpleAuthentication(); + + private DataAccessConfigBuilderFactory dataBuilderFactory; + + public SimpleAuthenticationBuilder(DataAccessConfigBuilderFactory dataBuilderFactory) { + this.dataBuilderFactory = dataBuilderFactory; + } + + public void setDataBuilderFactory(DataAccessConfigBuilderFactory dataBuilderFactory) { + this.dataBuilderFactory = dataBuilderFactory; + } + + @Override + public AuthenticationBuilder user(User user) { + Objects.requireNonNull(user); + authentication.setUser(user); + return this; + } + + @Override + public AuthenticationBuilder user(String user) { + return user(JSON.parseObject(user, SimpleUser.class)); + } + + @Override + public AuthenticationBuilder user(Map user) { + Objects.requireNonNull(user.get("id")); + user(SimpleUser.builder() + .id(user.get("id")) + .username(user.get("username")) + .name(user.get("name")) + .userType(user.get("type")) + .build()); + return this; + } + + @Override + public AuthenticationBuilder role(List role) { + authentication.getDimensions().addAll(role); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public AuthenticationBuilder role(String role) { + return role((List) JSON.parseArray(role, SimpleRole.class)); + } + + @Override + public AuthenticationBuilder permission(List permission) { + authentication.setPermissions(permission); + return this; + } + + public AuthenticationBuilder permission(JSONArray jsonArray) { + List permissions = new ArrayList<>(); + for (int i = 0; i < jsonArray.size(); i++) { + JSONObject jsonObject = jsonArray.getJSONObject(i); + SimplePermission permission = new SimplePermission(); + permission.setId(jsonObject.getString("id")); + permission.setName(jsonObject.getString("name")); + permission.setOptions(jsonObject.getJSONObject("options")); + JSONArray actions = jsonObject.getJSONArray("actions"); + if (actions != null) { + permission.setActions(new HashSet<>(actions.toJavaList(String.class))); + } + JSONArray dataAccess = jsonObject.getJSONArray("dataAccesses"); + if (null != dataAccess) { + permission.setDataAccesses(dataAccess.stream().map(JSONObject.class::cast) + .map(dataJson -> dataBuilderFactory + .create() + .fromJson(dataJson.toJSONString()) + .build()) + .filter(Objects::nonNull) + .collect(Collectors.toSet())); + } + permissions.add(permission); + } + authentication.setPermissions(permissions); + return this; + } + + @Override + public AuthenticationBuilder permission(String permissionJson) { + return permission(JSON.parseArray(permissionJson)); + } + + @Override + public AuthenticationBuilder attributes(String attributes) { + authentication.getAttributes().putAll(JSON.>parseObject(attributes, Map.class)); + return this; + } + + @Override + public AuthenticationBuilder attributes(Map permission) { + authentication.getAttributes().putAll(permission); + return this; + } + + public AuthenticationBuilder dimension(JSONArray json) { + + if (json == null) { + return this; + } + List dimensions = new ArrayList<>(); + + for (int i = 0; i < json.size(); i++) { + JSONObject jsonObject = json.getJSONObject(i); + Object type = jsonObject.get("type"); + Map options = jsonObject.getJSONObject("options"); + + dimensions.add(SimpleDimension.of( + jsonObject.getString("id"), + jsonObject.getString("name"), + type instanceof String ? SimpleDimensionType.of(String.valueOf(type)) : jsonObject + .getJSONObject("type") + .toJavaObject(SimpleDimensionType.class), + options + )); + } + authentication.setDimensions(dimensions); + + return this; + + } + + @Override + public AuthenticationBuilder json(String json) { + JSONObject jsonObject = JSON.parseObject(json); + user(jsonObject.getObject("user", SimpleUser.class)); + if (jsonObject.containsKey("roles")) { + role((List) jsonObject.getJSONArray("roles").toJavaList(SimpleRole.class)); + } + if (jsonObject.containsKey("permissions")) { + permission(jsonObject.getJSONArray("permissions")); + } + if (jsonObject.containsKey("dimensions")) { + dimension(jsonObject.getJSONArray("dimensions")); + } + if (jsonObject.containsKey("attributes")) { + attributes(Maps.transformValues(jsonObject.getJSONObject("attributes"), Serializable.class::cast)); + } + return this; + } + + @Override + public Authentication build() { + return authentication; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleAuthenticationBuilderFactory.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleAuthenticationBuilderFactory.java new file mode 100644 index 000000000..74b8b529b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleAuthenticationBuilderFactory.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.authorization.simple.builder; + +import org.hswebframework.web.authorization.builder.AuthenticationBuilder; +import org.hswebframework.web.authorization.builder.AuthenticationBuilderFactory; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilderFactory; + +/** + * TODO 完成注释 + * + * @author zhouhao + */ +public class SimpleAuthenticationBuilderFactory implements AuthenticationBuilderFactory { + + private DataAccessConfigBuilderFactory dataBuilderFactory; + + public SimpleAuthenticationBuilderFactory(DataAccessConfigBuilderFactory dataBuilderFactory) { + this.dataBuilderFactory = dataBuilderFactory; + } + + @Override + public AuthenticationBuilder create() { + return new SimpleAuthenticationBuilder(dataBuilderFactory); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleDataAccessConfigBuilder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleDataAccessConfigBuilder.java new file mode 100644 index 000000000..2d3784a2c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleDataAccessConfigBuilder.java @@ -0,0 +1,63 @@ +package org.hswebframework.web.authorization.simple.builder; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import org.hswebframework.web.authorization.access.DataAccessConfig; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilder; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * @author zhouhao + */ +public class SimpleDataAccessConfigBuilder implements DataAccessConfigBuilder { + + private List converts; + + private Map config = new HashMap<>(); + + + public SimpleDataAccessConfigBuilder(List converts) { + Objects.requireNonNull(converts); + this.converts = converts; + } + + @Override + public DataAccessConfigBuilder fromJson(String json) { + config.putAll(JSON.parseObject(json)); + return this; + } + + @Override + public DataAccessConfigBuilder fromMap(Map map) { + config.putAll(map); + return this; + } + + @Override + public DataAccessConfig build() { + Objects.requireNonNull(config); + JSONObject jsonObject = new JSONObject(config); + + String type = jsonObject.getString("type"); + String action = jsonObject.getString("action"); + String config = jsonObject.getString("config"); + + Objects.requireNonNull(type); + Objects.requireNonNull(action); + + if (config == null) { + config = jsonObject.toJSONString(); + } + String finalConfig = config; + + return converts.stream() + .filter(convert -> convert.isSupport(type, action, finalConfig)) + .map(convert -> convert.convert(type, action, finalConfig)) + .findFirst() + .orElse(null); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleDataAccessConfigBuilderFactory.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleDataAccessConfigBuilderFactory.java new file mode 100644 index 000000000..670344235 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/simple/builder/SimpleDataAccessConfigBuilderFactory.java @@ -0,0 +1,90 @@ +package org.hswebframework.web.authorization.simple.builder; + +import com.alibaba.fastjson.JSON; +import org.hswebframework.web.authorization.access.DataAccessConfig; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilder; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.simple.*; + +import javax.annotation.PostConstruct; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.function.BiFunction; + +import static org.hswebframework.web.authorization.access.DataAccessConfig.DefaultType.*; +import static org.hswebframework.web.authorization.access.DataAccessConfig.DefaultType.OWN_CREATED; + +/** + * @author zhouhao + */ +public class SimpleDataAccessConfigBuilderFactory implements DataAccessConfigBuilderFactory { + + private List defaultSupportConvert = Arrays.asList( + OWN_CREATED, + DIMENSION_SCOPE, + DENY_FIELDS); + + private List converts = new LinkedList<>(); + + public SimpleDataAccessConfigBuilderFactory addConvert(DataAccessConfigConverter configBuilderConvert) { + Objects.requireNonNull(configBuilderConvert); + converts.add(configBuilderConvert); + return this; + } + + public void setDefaultSupportConvert(List defaultSupportConvert) { + this.defaultSupportConvert = defaultSupportConvert; + } + + public List getDefaultSupportConvert() { + return defaultSupportConvert; + } + + protected DataAccessConfigConverter createJsonConfig(String supportType, Class clazz) { + return createConfig(supportType, (action, config) -> JSON.parseObject(config, clazz)); + } + + + protected DataAccessConfigConverter createConfig(String supportType, BiFunction function) { + return new DataAccessConfigConverter() { + @Override + public boolean isSupport(String type, String action, String config) { + return supportType.equals(type); + } + + @Override + public DataAccessConfig convert(String type, String action, String config) { + DataAccessConfig conf = function.apply(action, config); + if (conf instanceof AbstractDataAccessConfig) { + ((AbstractDataAccessConfig) conf).setAction(action); + } + return conf; + } + }; + } + + @PostConstruct + public void init() { + + + if (defaultSupportConvert.contains(DENY_FIELDS)) { + converts.add(createJsonConfig(DENY_FIELDS, SimpleFieldFilterDataAccessConfig.class)); + } + + if (defaultSupportConvert.contains(DIMENSION_SCOPE)) { + converts.add(createJsonConfig(DIMENSION_SCOPE, DimensionDataAccessConfig.class)); + } + + if (defaultSupportConvert.contains(OWN_CREATED)) { + converts.add(createConfig(OWN_CREATED, (action, config) -> new SimpleOwnCreatedDataAccessConfig(action))); + } + + } + + @Override + public DataAccessConfigBuilder create() { + return new SimpleDataAccessConfigBuilder(converts); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/AllopatricLoginMode.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/AllopatricLoginMode.java new file mode 100644 index 000000000..78b00080c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/AllopatricLoginMode.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.token; + +/** + * 异地登录模式 + */ +public enum AllopatricLoginMode { + /** + * 如果用户已在其他地方登录,则拒绝登录 + */ + deny, + /** + * 可以登录,同一个用户可在不同的地点登录 + */ + allow, + /** + * 如果用户已在其他地方登录,则将已登录的用户踢下线 + */ + offlineOther +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/AuthenticationUserToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/AuthenticationUserToken.java new file mode 100644 index 000000000..1aef5e7ad --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/AuthenticationUserToken.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.Authentication; + +/** + * 包含认证信息的token + * + * @author zhouhao + * @since 4.0.12 + */ +public interface AuthenticationUserToken extends UserToken { + + /** + * 获取认证信息 + * + * @return auth + * @see Authentication + */ + Authentication getAuthentication(); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java new file mode 100644 index 000000000..350a73b13 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java @@ -0,0 +1,318 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.token; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.token.event.UserTokenChangedEvent; +import org.hswebframework.web.authorization.token.event.UserTokenCreatedEvent; +import org.hswebframework.web.authorization.token.event.UserTokenRemovedEvent; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Supplier; + +/** + * 默认到用户令牌管理器,使用ConcurrentMap来存储令牌信息 + * + * @author zhouhao + * @since 3.0 + */ +public class DefaultUserTokenManager implements UserTokenManager { + + protected final ConcurrentMap tokenStorage; + + protected final ConcurrentMap> userStorage; + + + @Getter + @Setter + private Map allopatricLoginModes = new HashMap<>(); + + + public DefaultUserTokenManager() { + this(new ConcurrentHashMap<>(256)); + + } + + public DefaultUserTokenManager(ConcurrentMap tokenStorage) { + this(tokenStorage, new ConcurrentHashMap<>()); + } + + public DefaultUserTokenManager(ConcurrentMap tokenStorage, ConcurrentMap> userStorage) { + this.tokenStorage = tokenStorage; + this.userStorage = userStorage; + } + + //异地登录模式,默认允许异地登录 + private AllopatricLoginMode allopatricLoginMode = AllopatricLoginMode.allow; + + //事件转发器 + private ApplicationEventPublisher eventPublisher; + + @Autowired(required = false) + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + public void setAllopatricLoginMode(AllopatricLoginMode allopatricLoginMode) { + this.allopatricLoginMode = allopatricLoginMode; + } + + public AllopatricLoginMode getAllopatricLoginMode() { + return allopatricLoginMode; + } + + protected Set getUserToken(String userId) { + return userStorage.computeIfAbsent(userId, key -> new HashSet<>()); + } + + private Mono checkTimeout(UserToken detail) { + if (null == detail) { + return Mono.empty(); + } + if (detail.getMaxInactiveInterval() <= 0) { + return Mono.just(detail); + } + if (System.currentTimeMillis() - detail.getLastRequestTime() > detail.getMaxInactiveInterval()) { + return changeTokenState(detail, TokenState.expired) + .thenReturn(detail); + } + return Mono.just(detail); + } + + @Override + public Mono getByToken(String token) { + if (token == null) { + return Mono.empty(); + } + return checkTimeout(tokenStorage.get(token)); + } + + @Override + public Flux getByUserId(String userId) { + if (userId == null) { + return Flux.empty(); + } + Set tokens = getUserToken(userId); + if (tokens.isEmpty()) { + userStorage.remove(userId); + return Flux.empty(); + } + return Flux.fromStream(tokens + .stream() + .map(tokenStorage::get) + .filter(Objects::nonNull)); + } + + @Override + public Mono userIsLoggedIn(String userId) { + if (userId == null) { + return Mono.just(false); + } + return getByUserId(userId) + .any(UserToken::isNormal); + } + + @Override + public Mono tokenIsLoggedIn(String token) { + if (token == null) { + return Mono.just(false); + } + return getByToken(token) + .map(UserToken::isNormal) + .defaultIfEmpty(false); + } + + @Override + public Mono totalUser() { + return Mono.just(userStorage.size()); + } + + @Override + public Mono totalToken() { + return Mono.just(tokenStorage.size()); + } + + @Override + public Flux allLoggedUser() { + return Flux.fromIterable(tokenStorage.values()); + } + + @Override + public Mono signOutByUserId(String userId) { + if (null == userId) { + return Mono.empty(); + } + return Mono.defer(() -> { + Set tokens = getUserToken(userId); + return Flux + .fromIterable(tokens) + .flatMap(token -> signOutByToken(token, false)) + .then(Mono.fromRunnable(() -> { + tokens.clear(); + userStorage.remove(userId); + })); + }); + } + + private Mono signOutByToken(String token, boolean removeUserToken) { + if (token != null) { + LocalUserToken tokenObject = tokenStorage.remove(token); + if (tokenObject != null) { + String userId = tokenObject.getUserId(); + if (removeUserToken) { + Set tokens = getUserToken(userId); + if (!tokens.isEmpty()) { + tokens.remove(token); + } + if (tokens.isEmpty()) { + userStorage.remove(tokenObject.getUserId()); + } + } + return new UserTokenRemovedEvent(tokenObject).publish(eventPublisher); + } + } + return Mono.empty(); + } + + @Override + public Mono signOutByToken(String token) { + return signOutByToken(token, true); + } + + public Mono changeTokenState(UserToken userToken, TokenState state) { + if (null != userToken) { + LocalUserToken token = ((LocalUserToken) userToken); + LocalUserToken copy = token.copy(); + + token.setState(state); + syncToken(userToken); + + return new UserTokenChangedEvent(copy, userToken).publish(eventPublisher); + } + return Mono.empty(); + } + + @Override + public Mono changeTokenState(String token, TokenState state) { + return getByToken(token) + .flatMap(t -> changeTokenState(t, state)); + } + + @Override + public Mono changeUserState(String user, TokenState state) { + return Mono.from(getByUserId(user) + .flatMap(token -> changeTokenState(token.getToken(), state))); + } + + @Override + public Mono signIn(String token, String type, String userId, long maxInactiveInterval) { + + return doSignIn(token, type, userId, maxInactiveInterval, LocalUserToken::new) + .cast(UserToken.class); + + } + + private Mono doSignIn(String token, String type, String userId, long maxInactiveInterval, Supplier tokenSupplier) { + + return Mono.defer(() -> { + T detail = tokenSupplier.get(); + detail.setUserId(userId); + detail.setToken(token); + detail.setType(type); + detail.setMaxInactiveInterval(maxInactiveInterval); + detail.setState(TokenState.normal); + Mono doSign = Mono.defer(() -> { + tokenStorage.put(token, detail); + + getUserToken(userId).add(token); + + return new UserTokenCreatedEvent(detail).publish(eventPublisher); + }); + AllopatricLoginMode mode = allopatricLoginModes.getOrDefault(type, allopatricLoginMode); + if (mode == AllopatricLoginMode.deny) { + return getByUserId(userId) + .filter(userToken -> type.equals(userToken.getType())) + .flatMap(this::checkTimeout) + .filterWhen(t -> { + if (t.isNormal()) { + return Mono.error(new AccessDenyException("error.logged_in_elsewhere")); + } + return Mono.empty(); + }) + .then(doSign) + .thenReturn(detail); + } else if (mode == AllopatricLoginMode.offlineOther) { + return getByUserId(userId) + .filter(userToken -> type.equals(userToken.getType())) + .flatMap(userToken -> changeTokenState(userToken, TokenState.offline)) + .then(doSign) + .thenReturn(detail); + } + return doSign.thenReturn(detail); + }); + + } + + @Override + public Mono signIn(String token, String type, String userId, long maxInactiveInterval, Authentication authentication) { + return this + .doSignIn(token, type, userId, maxInactiveInterval, () -> new LocalAuthenticationUserToken(authentication)) + .cast(AuthenticationUserToken.class); + } + + @Override + public Mono touch(String token) { + LocalUserToken userToken = tokenStorage.get(token); + if (null != userToken) { + userToken.touch(); + syncToken(userToken); + } + return Mono.empty(); + } + + @Override + public Mono checkExpiredToken() { + + return Flux + .fromIterable(tokenStorage.values()) + .doOnNext(this::checkTimeout) + .filter(UserToken::isExpired) + .map(UserToken::getToken) + .flatMap(this::signOutByToken) + .then(); + } + + /** + * 同步令牌信息,如果使用redisson等来存储token,应该重写此方法并调用{@link this#tokenStorage}.put + * + * @param userToken 令牌 + */ + protected void syncToken(UserToken userToken) { + //do noting + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/LocalAuthenticationUserToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/LocalAuthenticationUserToken.java new file mode 100644 index 000000000..0dff060c9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/LocalAuthenticationUserToken.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.authorization.token; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.Authentication; + + +/** + * 包含认证信息的用户令牌信息 + * + * @author zhouhao + * @since 4.0.12 + */ +@AllArgsConstructor +public class LocalAuthenticationUserToken extends LocalUserToken implements AuthenticationUserToken { + + private final Authentication authentication; + + @Override + public Authentication getAuthentication() { + return authentication; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/LocalUserToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/LocalUserToken.java new file mode 100644 index 000000000..a157ee7f9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/LocalUserToken.java @@ -0,0 +1,152 @@ +package org.hswebframework.web.authorization.token; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 用户令牌信息 + * + * @author zhouhao + * @since 3.0 + */ +public class LocalUserToken implements UserToken { + + private static final long serialVersionUID = 1L; + + private String userId; + + private String token; + + private String type = "default"; + + private volatile TokenState state; + + private AtomicLong requestTimesCounter = new AtomicLong(0); + + private volatile long lastRequestTime = System.currentTimeMillis(); + + private volatile long firstRequestTime = System.currentTimeMillis(); + + private volatile long requestTimes; + + private long maxInactiveInterval; + + @Override + public long getMaxInactiveInterval() { + return maxInactiveInterval; + } + + public void setMaxInactiveInterval(long maxInactiveInterval) { + this.maxInactiveInterval = maxInactiveInterval; + } + + public LocalUserToken(String userId, String token) { + this.userId = userId; + this.token = token; + } + + public LocalUserToken() { + } + + @Override + public String getUserId() { + return userId; + } + + @Override + public long getRequestTimes() { + return requestTimesCounter.get(); + } + + @Override + public long getLastRequestTime() { + return lastRequestTime; + } + + @Override + public long getSignInTime() { + return firstRequestTime; + } + + @Override + public String getToken() { + return token; + } + + @Override + public TokenState getState() { + if (state == TokenState.normal) { + checkExpired(); + } + return state; + } + + @Override + public boolean checkExpired() { + if (UserToken.super.checkExpired()) { + setState(TokenState.expired); + return true; + } + return false; + } + + public void setState(TokenState state) { + this.state = state; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public void setToken(String token) { + this.token = token; + } + + public void setFirstRequestTime(long firstRequestTime) { + this.firstRequestTime = firstRequestTime; + } + + public void setLastRequestTime(long lastRequestTime) { + this.lastRequestTime = lastRequestTime; + } + + public void setRequestTimes(long requestTimes) { + this.requestTimes = requestTimes; + requestTimesCounter.set(requestTimes); + } + + public void touch() { + requestTimesCounter.addAndGet(1); + lastRequestTime = System.currentTimeMillis(); + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public LocalUserToken copy() { + LocalUserToken userToken = new LocalUserToken(); + userToken.firstRequestTime = firstRequestTime; + userToken.lastRequestTime = lastRequestTime; + userToken.requestTimesCounter = new AtomicLong(requestTimesCounter.get()); + userToken.token = token; + userToken.userId = userId; + userToken.state = state; + userToken.maxInactiveInterval = maxInactiveInterval; + userToken.type = type; + return userToken; + } + + @Override + public int hashCode() { + return token.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj != null && hashCode() == obj.hashCode(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ParsedToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ParsedToken.java new file mode 100644 index 000000000..c8fa2458e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ParsedToken.java @@ -0,0 +1,44 @@ +package org.hswebframework.web.authorization.token; + +import org.springframework.http.HttpHeaders; + +import java.util.function.BiConsumer; + +/** + * 令牌解析结果 + * + * @author zhouhao + */ +public interface ParsedToken { + /** + * @return 令牌 + */ + String getToken(); + + /** + * @return 令牌类型 + */ + String getType(); + + /** + * 将token应用到Http Header + * + * @param headers headers + * @since 4.0.17 + */ + default void apply(HttpHeaders headers) { + throw new UnsupportedOperationException("unsupported apply "+getType()+" token to headers"); + } + + static ParsedToken ofBearer(String token) { + return SimpleParsedToken.of("bearer", token, HttpHeaders::setBearerAuth); + } + + static ParsedToken of(String type, String token) { + return of(type, token, (_header, _token) -> _header.set(HttpHeaders.AUTHORIZATION, type + " " + _token)); + } + + static ParsedToken of(String type, String token, BiConsumer headerSetter) { + return SimpleParsedToken.of(type, token, headerSetter); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ReactiveTokenAuthenticationSupplier.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ReactiveTokenAuthenticationSupplier.java new file mode 100644 index 000000000..2e1c205db --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ReactiveTokenAuthenticationSupplier.java @@ -0,0 +1,29 @@ +package org.hswebframework.web.authorization.token; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.ReactiveAuthenticationSupplier; +import org.hswebframework.web.context.ContextKey; +import org.hswebframework.web.context.ContextUtils; +import org.hswebframework.web.logger.ReactiveLogger; +import reactor.core.publisher.Mono; + +@AllArgsConstructor +public class ReactiveTokenAuthenticationSupplier implements ReactiveAuthenticationSupplier { + + private final TokenAuthenticationManager tokenManager; + + @Override + public Mono get(String userId) { + return Mono.empty(); + } + + @Override + public Mono get() { + return Mono + .deferContextual(context -> context + .getOrEmpty(ParsedToken.class) + .map(t -> tokenManager.getByToken(t.getToken())) + .orElse(Mono.empty())); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/SimpleParsedToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/SimpleParsedToken.java new file mode 100644 index 000000000..a11d95f05 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/SimpleParsedToken.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.authorization.token; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.springframework.http.HttpHeaders; + +import java.util.function.BiConsumer; + +@Getter +@Setter +@AllArgsConstructor(staticName = "of") +public class SimpleParsedToken implements ParsedToken { + + private String type; + + private String token; + + private BiConsumer headerSetter; + + @Override + public void apply(HttpHeaders headers) { + if (headerSetter != null) { + headerSetter.accept(headers,token); + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ThirdPartAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ThirdPartAuthenticationManager.java new file mode 100644 index 000000000..f56e2dee5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ThirdPartAuthenticationManager.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.Authentication; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +/** + * @author zhouhao + * @since 1.0 + */ +public interface ThirdPartAuthenticationManager { + + /** + * @return 支持的tokenType + */ + String getTokenType(); + + /** + * 根据用户ID获取权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + Optional getByUserId(String userId); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ThirdPartReactiveAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ThirdPartReactiveAuthenticationManager.java new file mode 100644 index 000000000..f8a3ea072 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/ThirdPartReactiveAuthenticationManager.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.Authentication; +import reactor.core.publisher.Mono; + +/** + * @author zhouhao + * @since 1.0 + */ +public interface ThirdPartReactiveAuthenticationManager { + + /** + * @return 支持的tokenType + */ + String getTokenType(); + + /** + * 根据用户ID获取权限信息 + * + * @param userId 用户ID + * @return 权限信息 + */ + Mono getByUserId(String userId); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenAuthenticationManager.java new file mode 100644 index 000000000..c806a9353 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenAuthenticationManager.java @@ -0,0 +1,40 @@ +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.Authentication; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * token 权限管理器,根据token来进行权限关联. + * + * @author zhouhao + * @since 4.0.7 + */ +public interface TokenAuthenticationManager { + + /** + * 根据token获取认证信息 + * + * @param token token + * @return 认证信息 + */ + Mono getByToken(String token); + + /** + * 设置token认证信息 + * + * @param token token + * @param auth 认证信息 + * @param ttl 有效期 + * @return void + */ + Mono putAuthentication(String token, Authentication auth, Duration ttl); + + /** + * 删除token + * @param token token + * @return void + */ + Mono removeToken(String token); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java new file mode 100644 index 000000000..6c9ac63af --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java @@ -0,0 +1,42 @@ +package org.hswebframework.web.authorization.token; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.dict.EnumDict; + +/** + * 令牌状态 + */ +@Getter +@AllArgsConstructor +public enum TokenState implements EnumDict { + /** + * 正常,有效 + */ + normal("normal","message.token_state_normal"), + + /** + * 已被禁止访问 + */ + deny("deny", "message.token_state_deny"), + + /** + * 已过期 + */ + expired("expired", "message.token_state_expired"), + + /** + * 已被踢下线 + * @see AllopatricLoginMode#offlineOther + */ + offline("offline", "message.token_state_offline"), + + /** + * 锁定 + */ + lock("lock", "message.token_state_lock"); + + private final String value; + + private final String text; +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserToken.java new file mode 100644 index 000000000..84d25bc7d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserToken.java @@ -0,0 +1,111 @@ +package org.hswebframework.web.authorization.token; + + +import org.hswebframework.web.authorization.User; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; + +import java.io.Serializable; + +/** + * 用户的token信息 + * + * @author zhouhao + * @since 3.0 + */ +public interface UserToken extends Serializable, Comparable { + /** + * @return 用户id + * @see User#getId() + */ + String getUserId(); + + /** + * @return token + */ + String getToken(); + + /** + * @return 请求总次数 + */ + long getRequestTimes(); + + /** + * @return 最后一次请求时间 + */ + long getLastRequestTime(); + + /** + * @return 首次请求时间 + */ + long getSignInTime(); + + /** + * @return 令牌状态 + */ + TokenState getState(); + + /** + * @return 令牌类型, 默认:default + */ + String getType(); + + /** + * @return 会话过期时间, 单位毫秒 + */ + long getMaxInactiveInterval(); + + /** + * 检查会话是否过期 + * + * @return 是否过期 + * @since 4.0.10 + */ + default boolean checkExpired() { + long maxInactiveInterval = getMaxInactiveInterval(); + if (maxInactiveInterval > 0) { + return System.currentTimeMillis() - getLastRequestTime() > maxInactiveInterval; + } + return false; + } + + default boolean isNormal() { + return getState() == TokenState.normal; + } + + /** + * @return 是否已过期 + */ + default boolean isExpired() { + return getState() == TokenState.expired; + } + + /** + * @return 是否离线 + */ + default boolean isOffline() { + return getState() == TokenState.offline; + } + + default boolean isLock() { + return getState() == TokenState.lock; + } + + default boolean isDeny() { + return getState() == TokenState.deny; + } + + default boolean validate() { + if (!isNormal()) { + throw new UnAuthorizedException(getState()); + } + return true; + } + + @Override + default int compareTo(UserToken target) { + if (target == null) { + return 0; + } + return Long.compare(getSignInTime(), target.getSignInTime()); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenAuthenticationSupplier.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenAuthenticationSupplier.java new file mode 100644 index 000000000..e0e8a979d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenAuthenticationSupplier.java @@ -0,0 +1,77 @@ +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.*; +import org.hswebframework.web.context.ContextKey; +import org.hswebframework.web.context.ContextUtils; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * @author zhouhao + */ +public class UserTokenAuthenticationSupplier implements AuthenticationSupplier { + + private AuthenticationManager defaultAuthenticationManager; + + private UserTokenManager userTokenManager; + + private Map thirdPartAuthenticationManager = new HashMap<>(); + + public UserTokenAuthenticationSupplier(UserTokenManager userTokenManager, AuthenticationManager defaultAuthenticationManager) { + this.defaultAuthenticationManager = defaultAuthenticationManager; + this.userTokenManager = userTokenManager; + } + + @Autowired(required = false) + public void setThirdPartAuthenticationManager(List thirdPartReactiveAuthenticationManager) { + for (ThirdPartAuthenticationManager manager : thirdPartReactiveAuthenticationManager) { + this.thirdPartAuthenticationManager.put(manager.getTokenType(), manager); + } + } + + @Override + public Optional get(String userId) { + if (userId == null) { + return Optional.empty(); + } + return get(this.defaultAuthenticationManager, userId); + } + + protected Optional get(ThirdPartAuthenticationManager authenticationManager, String userId) { + if (null == userId) { + return Optional.empty(); + } + if (null == authenticationManager) { + return this.defaultAuthenticationManager.getByUserId(userId); + } + return authenticationManager.getByUserId(userId); + } + + protected Optional get(AuthenticationManager authenticationManager, String userId) { + if (null == userId) { + return Optional.empty(); + } + if (null == authenticationManager) { + authenticationManager = this.defaultAuthenticationManager; + } + return authenticationManager.getByUserId(userId); + } + + @Override + public Optional get() { + + return ContextUtils.currentContext() + .get(ContextKey.of(ParsedToken.class)) + .map(t -> userTokenManager.getByToken(t.getToken())) + .map(tokenMono -> tokenMono + .map(token -> get(thirdPartAuthenticationManager.get(token.getType()), token.getUserId())) + .flatMap(Mono::justOrEmpty)) + .flatMap(Mono::blockOptional); + + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenBeforeCreateEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenBeforeCreateEvent.java new file mode 100644 index 000000000..ddcaa8967 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenBeforeCreateEvent.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.token; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +@Getter +@Setter +@AllArgsConstructor +public class UserTokenBeforeCreateEvent extends DefaultAsyncEvent { + private final UserToken token; + + /** + * 过期时间,单位毫秒,-1为不过期. + */ + private long expires; + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenHolder.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenHolder.java new file mode 100644 index 000000000..76c3c792a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenHolder.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.authorization.token; + + +import org.hswebframework.web.context.ContextUtils; + +/** + * @author zhouhao + */ +public final class UserTokenHolder { + + private UserTokenHolder() { + } + + public static UserToken currentToken() { + return ContextUtils.currentContext().get(UserToken.class).orElse(null); + } + + public static UserToken setCurrent(UserToken token) { + ContextUtils.currentContext().put(UserToken.class, token); + return token; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenManager.java new file mode 100644 index 000000000..6ae9fd685 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenManager.java @@ -0,0 +1,157 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.Authentication; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * 用户令牌管理器,用于管理用户令牌 + * + * @author zhouhao + * @since 3.0 + */ +public interface UserTokenManager { + + /** + * 根据token获取用户令牌信息 + * + * @param token token + * @return 令牌信息, 未授权时返回null + */ + Mono getByToken(String token); + + /** + * 根据用户id,获取全部令牌信息,如果没有则返回空集合而不是null + * + * @param userId 用户id + * @return 授权信息 + */ + Flux getByUserId(String userId); + + /** + * @param userId 用户ID + * @return 用户是否已经授权 + */ + Mono userIsLoggedIn(String userId); + + /** + * @param token token + * @return token是否已登记 + */ + Mono tokenIsLoggedIn(String token); + + /** + * @return 总用户数量,一个用户多个地方登陆数量算1 + */ + Mono totalUser(); + + /** + * @return 总token数量 + */ + Mono totalToken(); + + /** + * @return 所有token + */ + Flux allLoggedUser(); + + /** + * 删除用户授权信息 + * + * @param userId 用户ID + */ + Mono signOutByUserId(String userId); + + /** + * 根据token删除 + * + * @param token 令牌 + * @see org.hswebframework.web.authorization.token.event.UserTokenRemovedEvent + */ + Mono signOutByToken(String token); + + /** + * 修改userId的状态 + * + * @param userId userId + * @param state 状态 + * @see org.hswebframework.web.authorization.token.event.UserTokenChangedEvent + * @see UserTokenManager#changeTokenState + */ + Mono changeUserState(String userId, TokenState state); + + /** + * 修改token的状态 + * + * @param token token + * @param state 状态 + * @see org.hswebframework.web.authorization.token.event.UserTokenChangedEvent + */ + Mono changeTokenState(String token, TokenState state); + + /** + * 登记一个用户的token + * + * @param token token + * @param type 令牌类型 + * @param userId 用户id + * @param maxInactiveInterval 最大不活动时间(单位毫秒),超过后令牌状态{@link UserToken#getState()}将变为过期{@link TokenState#expired} + * @see org.hswebframework.web.authorization.token.event.UserTokenCreatedEvent + */ + Mono signIn(String token, String type, String userId, long maxInactiveInterval); + + /** + * 登记一个包含认证信息的token + * + * @param token token + * @param type 令牌类型 + * @param userId 用户ID + * @param maxInactiveInterval 最大不活动时间(单位毫秒),小于0永不过期,超过后令牌状态{@link UserToken#getState()}将变为过期{@link TokenState#expired} + * @param authentication 认证信息 + * @return token信息 + */ + default Mono signIn(String token, + String type, + String userId, + long maxInactiveInterval, + Authentication authentication) { + throw new UnsupportedOperationException(); + } + + /** + * 更新token,使其不过期 + * + * @param token token + */ + Mono touch(String token); + + /** + * 检查已过期的token,并将其remove + * + * @see UserTokenManager#signOutByToken(String) + */ + Mono checkExpiredToken(); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenReactiveAuthenticationSupplier.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenReactiveAuthenticationSupplier.java new file mode 100644 index 000000000..e8fc342da --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/UserTokenReactiveAuthenticationSupplier.java @@ -0,0 +1,94 @@ +package org.hswebframework.web.authorization.token; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.hswebframework.web.authorization.ReactiveAuthenticationSupplier; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.context.ContextKey; +import org.hswebframework.web.context.ContextUtils; +import org.hswebframework.web.logger.ReactiveLogger; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author zhouhao + */ +public class UserTokenReactiveAuthenticationSupplier implements ReactiveAuthenticationSupplier { + + private final ReactiveAuthenticationManager defaultAuthenticationManager; + + private final UserTokenManager userTokenManager; + + private final Map thirdPartAuthenticationManager = new HashMap<>(); + + public UserTokenReactiveAuthenticationSupplier(UserTokenManager userTokenManager, + ReactiveAuthenticationManager defaultAuthenticationManager) { + this.defaultAuthenticationManager = defaultAuthenticationManager; + this.userTokenManager = userTokenManager; + } + + @Autowired(required = false) + public void setThirdPartAuthenticationManager(List thirdPartReactiveAuthenticationManager) { + for (ThirdPartReactiveAuthenticationManager manager : thirdPartReactiveAuthenticationManager) { + this.thirdPartAuthenticationManager.put(manager.getTokenType(), manager); + } + } + + @Override + public Mono get(String userId) { + if (userId == null) { + return null; + } + return get(this.defaultAuthenticationManager, userId); + } + + protected Mono get(ThirdPartReactiveAuthenticationManager authenticationManager, String userId) { + if (null == userId) { + return null; + } + if (null == authenticationManager) { + return this.defaultAuthenticationManager.getByUserId(userId); + } + return authenticationManager.getByUserId(userId); + } + + protected Mono get(ReactiveAuthenticationManager authenticationManager, String userId) { + if (null == userId) { + return null; + } + if (null == authenticationManager) { + authenticationManager = this.defaultAuthenticationManager; + } + return authenticationManager.getByUserId(userId); + } + + @Override + public Mono get() { + return Mono + .deferContextual(context -> context + .getOrEmpty(ParsedToken.class) + .map(t -> userTokenManager + .getByToken(t.getToken()) + .flatMap(token -> { + //已过期则返回空 + if (token.isExpired()) { + return Mono.empty(); + } + if(!token.validate()){ + return Mono.empty(); + } + Mono before = userTokenManager.touch(token.getToken()); + if (token instanceof AuthenticationUserToken) { + return before.thenReturn(((AuthenticationUserToken) token).getAuthentication()); + } + return before.then(get(thirdPartAuthenticationManager.get(token.getType()), token.getUserId())); + })) + .orElse(Mono.empty())) + ; + + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenChangedEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenChangedEvent.java new file mode 100644 index 000000000..e9a7c412f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenChangedEvent.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.authorization.token.event; + +import org.hswebframework.web.authorization.events.AuthorizationEvent; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.event.DefaultAsyncEvent; + +public class UserTokenChangedEvent extends DefaultAsyncEvent implements AuthorizationEvent { + private final UserToken before, after; + + public UserTokenChangedEvent(UserToken before, UserToken after) { + this.before = before; + this.after = after; + } + + public UserToken getBefore() { + return before; + } + + public UserToken getAfter() { + return after; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenCreatedEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenCreatedEvent.java new file mode 100644 index 000000000..677e2355a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenCreatedEvent.java @@ -0,0 +1,17 @@ +package org.hswebframework.web.authorization.token.event; + +import org.hswebframework.web.authorization.events.AuthorizationEvent; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.event.DefaultAsyncEvent; + +public class UserTokenCreatedEvent extends DefaultAsyncEvent implements AuthorizationEvent { + private final UserToken detail; + + public UserTokenCreatedEvent(UserToken detail) { + this.detail = detail; + } + + public UserToken getDetail() { + return detail; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenRemovedEvent.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenRemovedEvent.java new file mode 100644 index 000000000..0d0f95809 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/event/UserTokenRemovedEvent.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.authorization.token.event; + +import org.hswebframework.web.authorization.events.AuthorizationEvent; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.event.DefaultAsyncEvent; + +public class UserTokenRemovedEvent extends DefaultAsyncEvent implements AuthorizationEvent { + + private static final long serialVersionUID = -6662943150068863177L; + + private final UserToken token; + + public UserTokenRemovedEvent(UserToken token) { + this.token=token; + } + + public UserToken getDetail() { + return token; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisTokenAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisTokenAuthenticationManager.java new file mode 100644 index 000000000..31874ea98 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisTokenAuthenticationManager.java @@ -0,0 +1,61 @@ +package org.hswebframework.web.authorization.token.redis; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.TokenAuthenticationManager; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +public class RedisTokenAuthenticationManager implements TokenAuthenticationManager { + + private final ReactiveRedisOperations operations; + + @SuppressWarnings("all") + public RedisTokenAuthenticationManager(ReactiveRedisConnectionFactory connectionFactory) { + this(new ReactiveRedisTemplate<>( + connectionFactory, RedisSerializationContext.newSerializationContext() + .key(RedisSerializer.string()) + .value((RedisSerializer) RedisSerializer.java()) + .hashKey(RedisSerializer.string()) + .hashValue(RedisSerializer.java()) + .build() + )); + } + + public RedisTokenAuthenticationManager(ReactiveRedisOperations operations) { + this.operations = operations; + } + + @Override + public Mono getByToken(String token) { + return operations + .opsForValue() + .get("token-auth:" + token); + } + + @Override + public Mono removeToken(String token) { + return operations + .delete(token) + .then(); + } + + @Override + public Mono putAuthentication(String token, Authentication auth, Duration ttl) { + return ttl.isNegative() + ? operations + .opsForValue() + .set("token-auth:" + token, auth) + .then() + : operations + .opsForValue() + .set("token-auth:" + token, auth, ttl) + .then() + ; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java new file mode 100644 index 000000000..ff2f00b89 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java @@ -0,0 +1,412 @@ +package org.hswebframework.web.authorization.token.redis; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.token.*; +import org.hswebframework.web.authorization.token.event.UserTokenChangedEvent; +import org.hswebframework.web.authorization.token.event.UserTokenCreatedEvent; +import org.hswebframework.web.authorization.token.event.UserTokenRemovedEvent; +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.event.AsyncEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.*; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class RedisUserTokenManager implements UserTokenManager { + + private final ReactiveRedisOperations operations; + + private final ReactiveHashOperations userTokenStore; + + private final ReactiveSetOperations userTokenMapping; + + @Setter + private Map localCache = new ConcurrentHashMap<>(); + + private FluxSink touchSink; + + public RedisUserTokenManager(ReactiveRedisOperations operations) { + this.operations = operations; + this.userTokenStore = operations.opsForHash(); + this.userTokenMapping = operations.opsForSet(); + this.operations + .listenToChannel("_user_token_removed") + .subscribe(msg -> localCache.remove(String.valueOf(msg.getMessage()))); + + Flux.create(sink -> this.touchSink = sink) + .buffer(Flux.interval(Duration.ofSeconds(10)), HashSet::new) + .flatMap(list -> Flux + .fromIterable(list) + .flatMap(token -> { + String key = getTokenRedisKey(token.getToken()); + return Mono + .zip(this.userTokenStore.put(key, "lastRequestTime", token.getLastRequestTime()), + this.operations.expire(key, Duration.ofMillis(token.getMaxInactiveInterval()))) + .then(); + }) + .onErrorResume(err -> Mono.empty())) + .subscribe(); + + } + + @SuppressWarnings("all") + public RedisUserTokenManager(ReactiveRedisConnectionFactory connectionFactory) { + this(new ReactiveRedisTemplate<>(connectionFactory, + RedisSerializationContext + .newSerializationContext() + .key((RedisSerializer) RedisSerializer.string()) + .value(RedisSerializer.java()) + .hashKey(RedisSerializer.string()) + .hashValue(RedisSerializer.java()) + .build() + )); + } + + @Getter + @Setter + private Map allopatricLoginModes = new HashMap<>(); + + @Getter + @Setter + //异地登录模式,默认允许异地登录 + private AllopatricLoginMode allopatricLoginMode = AllopatricLoginMode.allow; + + @Getter + @Setter + private Duration maxTokenExpires = Duration.ofSeconds(1).negated(); + + @Setter + private ApplicationEventPublisher eventPublisher; + + private String getTokenRedisKey(String key) { + return "user-token:".concat(key); + } + + private String getUserRedisKey(String key) { + return "user-token-user:".concat(key); + } + + @Override + public Mono getByToken(String token) { + SimpleUserToken inCache = localCache.get(token); + if (inCache != null && inCache.isNormal()) { + return Mono.just(inCache); + } + return userTokenStore + .entries(getTokenRedisKey(token)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)) + .filter(map -> !map.isEmpty() && map.containsKey("token") && map.containsKey("userId")) + .map(SimpleUserToken::of) + .doOnNext(userToken -> localCache.put(userToken.getToken(), userToken)) + .cast(UserToken.class); + } + + @Override + public Flux getByUserId(String userId) { + String redisKey = getUserRedisKey(userId); + return userTokenMapping + .members(redisKey) + .map(String::valueOf) + .flatMap(token -> getByToken(token) + .switchIfEmpty(Mono.defer(() -> userTokenMapping + .remove(redisKey, token) + .then(Mono.empty())))); + } + + @Override + public Mono userIsLoggedIn(String userId) { + return getByUserId(userId) + .any(UserToken::isNormal); + } + + @Override + public Mono tokenIsLoggedIn(String token) { + return getByToken(token) + .map(UserToken::isNormal) + .defaultIfEmpty(false); + } + + @Override + public Mono totalUser() { + + return operations + .scan(ScanOptions + .scanOptions() + .match("*user-token-user:*") + .build()) + .count() + .map(Long::intValue); + } + + @Override + public Mono totalToken() { + return operations + .scan(ScanOptions + .scanOptions() + .match("*user-token:*") + .build()) + .count() + .map(Long::intValue); + } + + @Override + public Flux allLoggedUser() { + return operations + .scan(ScanOptions + .scanOptions() + .match("*user-token:*") + .build()) + .map(val -> String.valueOf(val).substring(11)) + .flatMap(this::getByToken); + } + + @Override + public Mono signOutByUserId(String userId) { + return this + .getByUserId(userId) + .flatMap(userToken -> operations + .delete(getTokenRedisKey(userToken.getToken())) + .then(onTokenRemoved(userToken))) + .then(operations.delete(getUserRedisKey(userId))) + .then(); + } + + @Override + public Mono signOutByToken(String token) { + //delete token + //srem user token + return getByToken(token) + .flatMap(t -> operations + .delete(getTokenRedisKey(t.getToken())) + .then(userTokenMapping.remove(getUserRedisKey(t.getUserId()), token)) + .then(onTokenRemoved(t)) + ) + .then(); + } + + @Override + public Mono changeUserState(String userId, TokenState state) { + + return getByUserId(userId) + .flatMap(token -> changeTokenState(token.getToken(), state)) + .then(); + } + + @Override + public Mono changeTokenState(String token, TokenState state) { + + return getByToken(token) + .flatMap(old -> { + SimpleUserToken newToken = FastBeanCopier.copy(old, new SimpleUserToken()); + newToken.setState(state); + return userTokenStore + .put(getTokenRedisKey(token), "state", state.getValue()) + .then(onTokenChanged(old, newToken)); + }); + } + + protected Mono sign0(String token, + String type, + String userId, + long expires, + boolean ignoreAllopatricLoginMode, + Consumer> cacheBuilder) { + return Mono.defer(() -> { + Map map = new HashMap<>(); + map.put("token", token); + map.put("type", type); + map.put("userId", userId); + map.put("maxInactiveInterval", expires); + map.put("state", TokenState.normal.getValue()); + map.put("signInTime", System.currentTimeMillis()); + map.put("lastRequestTime", System.currentTimeMillis()); + cacheBuilder.accept(map); + String key = getTokenRedisKey(token); + SimpleUserToken userToken = SimpleUserToken.of(map); + + // 推送事件,自定义过期时间等场景 + UserTokenBeforeCreateEvent event = new UserTokenBeforeCreateEvent(userToken, expires); + + return this + .publishEvent(event) + .then(Mono.defer(() -> { + map.put("maxInactiveInterval", event.getExpires()); + if (event.getExpires() > 0) { + return userTokenStore + .putAll(key, map) + .then(operations.expire(key, Duration.ofMillis(event.getExpires()))); + } + return userTokenStore.putAll(key, map); + })) + .then(userTokenMapping.add(getUserRedisKey(userId), token)) + .thenReturn(userToken); + }); + } + + private Mono signIn(String token, + String type, + String userId, + long maxInactiveInterval, + boolean ignoreAllopatricLoginMode, + Consumer> cacheBuilder) { + long expires = maxTokenExpires.isNegative() ? maxInactiveInterval : Math.min(maxInactiveInterval, maxTokenExpires.toMillis()); + + return Mono + .defer(() -> { + Mono doSign = sign0( + token, + type, + userId, + expires, + ignoreAllopatricLoginMode, + cacheBuilder + ); + + if (ignoreAllopatricLoginMode) { + return doSign; + } + AllopatricLoginMode mode = allopatricLoginModes.getOrDefault(type, allopatricLoginMode); + if (mode == AllopatricLoginMode.deny) { + return userIsLoggedIn(userId) + .flatMap(r -> { + if (r) { + return Mono.error(new AccessDenyException("error.logged_in_elsewhere", TokenState.deny.getValue())); + } + return doSign; + }); + + } else if (mode == AllopatricLoginMode.offlineOther) { + return getByUserId(userId) + .flatMap(userToken -> { + if (type.equals(userToken.getType())) { + return this.changeTokenState(userToken.getToken(), TokenState.offline); + } + return Mono.empty(); + }) + .then(doSign); + } + + return doSign; + }) + .flatMap(this::onUserTokenCreated); + } + + @Override + public Mono signIn(String token, String type, String userId, long maxInactiveInterval) { + return signIn(token, type, userId, maxInactiveInterval, false, ignore -> { + }); + } + + @Override + public Mono signIn(String token, + String type, + String userId, + long maxInactiveInterval, + Authentication authentication) { + return this + .signIn(token, type, userId, maxInactiveInterval, + true, + cache -> cache.put("authentication", authentication)) + .cast(AuthenticationUserToken.class); + } + + @Override + public Mono touch(String token) { + SimpleUserToken inCache = localCache.get(token); + if (inCache != null && inCache.isNormal()) { + inCache.setLastRequestTime(System.currentTimeMillis()); + if (inCache.getMaxInactiveInterval() > 0) { + //异步touch + touchSink.next(inCache); + } + return Mono.empty(); + } + return getByToken(token) + .flatMap(userToken -> { + if (userToken.getMaxInactiveInterval() > 0) { + touchSink.next(userToken); + } + return Mono.empty(); + }); + } + + @Override + public Mono checkExpiredToken() { + + return operations + .scan(ScanOptions.scanOptions().match("*user-token-user:*").build()) + .map(String::valueOf) + .flatMap(key -> userTokenMapping + .members(key) + .map(String::valueOf) + .flatMap(token -> operations + .hasKey(getTokenRedisKey(token)) + .flatMap(exists -> { + if (!exists) { + return userTokenMapping.remove(key, token); + } + return Mono.empty(); + }))) + .then(); + } + + private Mono notifyTokenRemoved(String token) { + return operations.convertAndSend("_user_token_removed", token).then(); + } + + private Mono onTokenRemoved(UserToken token) { + localCache.remove(token.getToken()); + + if (eventPublisher == null) { + return notifyTokenRemoved(token.getToken()); + } + return new UserTokenRemovedEvent(token) + .publish(eventPublisher) + .then(notifyTokenRemoved(token.getToken())); + } + + private Mono onTokenChanged(UserToken old, SimpleUserToken newToken) { + localCache.put(newToken.getToken(), newToken); + if (eventPublisher == null) { + return notifyTokenRemoved(newToken.getToken()); + } + return new UserTokenChangedEvent(old, newToken) + .publish(eventPublisher) + .then(notifyTokenRemoved(newToken.getToken())); + } + + private Mono publishEvent(AsyncEvent event) { + if (eventPublisher != null) { + return event.publish(eventPublisher); + } + return Mono.empty(); + } + + private Mono onUserTokenCreated(SimpleUserToken token) { + localCache.put(token.getToken(), token); + if (eventPublisher == null) { + return notifyTokenRemoved(token.getToken()) + .thenReturn(token); + } + return new UserTokenCreatedEvent(token) + .publish(eventPublisher) + .then(notifyTokenRemoved(token.getToken())) + .thenReturn(token); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/SimpleAuthenticationUserToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/SimpleAuthenticationUserToken.java new file mode 100644 index 000000000..a5e2826eb --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/SimpleAuthenticationUserToken.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.authorization.token.redis; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.AuthenticationUserToken; + +@AllArgsConstructor +public class SimpleAuthenticationUserToken extends SimpleUserToken implements AuthenticationUserToken { + private final Authentication authentication; + + @Override + public Authentication getAuthentication() { + return authentication; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/SimpleUserToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/SimpleUserToken.java new file mode 100644 index 000000000..6e931ba73 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/SimpleUserToken.java @@ -0,0 +1,59 @@ +package org.hswebframework.web.authorization.token.redis; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.TokenState; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.bean.FastBeanCopier; + +import java.util.Map; + +@Getter +@Setter +@ToString(exclude = "token") +@EqualsAndHashCode(of = "token") +public class SimpleUserToken implements UserToken { + + private String userId; + + private String token; + + private long requestTimes; + + private long lastRequestTime; + + private long signInTime; + + private TokenState state; + + private String type; + + private long maxInactiveInterval; + + public static SimpleUserToken of(Map map) { + Object authentication = map.get("authentication"); + if (authentication instanceof Authentication) { + return FastBeanCopier.copy(map, new SimpleAuthenticationUserToken(((Authentication) authentication))); + } + return FastBeanCopier.copy(map, new SimpleUserToken()); + } + + public TokenState getState() { + if (state == TokenState.normal) { + checkExpired(); + } + return state; + } + + @Override + public boolean checkExpired() { + if (UserToken.super.checkExpired()) { + setState(TokenState.expired); + return true; + } + return false; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorToken.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorToken.java new file mode 100644 index 000000000..7586cd695 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorToken.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.authorization.twofactor; + +import java.io.Serializable; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public interface TwoFactorToken extends Serializable { + void generate(long timeout); + + boolean expired(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorTokenManager.java new file mode 100644 index 000000000..f4b0fdfcb --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorTokenManager.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.authorization.twofactor; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public interface TwoFactorTokenManager { + TwoFactorToken getToken(String userId, String operation); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidator.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidator.java new file mode 100644 index 000000000..b639eaa16 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidator.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.authorization.twofactor; + +/** + * 双重验证器,用于某些接口需要双重验证时使用,如: 短信验证码,动态口令等 + * + * @author zhouhao + * @since 3.0.4 + */ +public interface TwoFactorValidator { + + String getProvider(); + + /** + * 验证code是否有效,如果验证码有效,则保持此验证有效期.在有效期内,调用{@link this#expired()} 将返回false + * + * @param code 验证码 + * @param timeout 保持验证通过有效期 + * @return 验证码是否有效 + */ + boolean verify(String code, long timeout); + + /** + * 验证是否已经过期,过期则需要重新进行验证 + * + * @return 是否过期 + */ + boolean expired(); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidatorManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidatorManager.java new file mode 100644 index 000000000..df736a66e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidatorManager.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.authorization.twofactor; + +/** + * 双重验证管理器 + * @author zhouhao + * @since 3.0.4 + */ +public interface TwoFactorValidatorManager { + + /** + * 获取用户使用的双重验证器 + * + * @param provider 验证器供应商 + * @return 验证器 + */ + TwoFactorValidator getValidator(String userId,String operation, String provider); + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidatorProvider.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidatorProvider.java new file mode 100644 index 000000000..f89dfe20c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/TwoFactorValidatorProvider.java @@ -0,0 +1,12 @@ +package org.hswebframework.web.authorization.twofactor; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public interface TwoFactorValidatorProvider { + + String getProvider(); + + TwoFactorValidator createTwoFactorValidator(String userId,String operation); +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidator.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidator.java new file mode 100644 index 000000000..345d1b13d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidator.java @@ -0,0 +1,38 @@ +package org.hswebframework.web.authorization.twofactor.defaults; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.authorization.twofactor.TwoFactorToken; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidator; + +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@AllArgsConstructor +public class DefaultTwoFactorValidator implements TwoFactorValidator { + + @Getter + private String provider; + + private Function validator; + + private Supplier tokenSupplier; + + @Override + public boolean verify(String code, long timeout) { + boolean success = validator.apply(code); + if (success) { + tokenSupplier.get().generate(timeout); + } + return success; + } + + @Override + public boolean expired() { + return tokenSupplier.get().expired(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidatorManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidatorManager.java new file mode 100644 index 000000000..f0a0c98f9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidatorManager.java @@ -0,0 +1,54 @@ +package org.hswebframework.web.authorization.twofactor.defaults; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidator; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorManager; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorProvider; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public class DefaultTwoFactorValidatorManager implements TwoFactorValidatorManager, BeanPostProcessor { + + @Getter + @Setter + private String defaultProvider = "totp"; + + private Map providers = new HashMap<>(); + + @Override + public TwoFactorValidator getValidator(String userId, String operation, String provider) { + if (provider == null) { + provider = defaultProvider; + } + TwoFactorValidatorProvider validatorProvider = providers.get(provider); + if (validatorProvider == null) { + return new UnsupportedTwoFactorValidator(provider); + } + return validatorProvider.createTwoFactorValidator(userId, operation); + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + return bean; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof TwoFactorValidatorProvider) { + TwoFactorValidatorProvider provider = ((TwoFactorValidatorProvider) bean); + providers.put(provider.getProvider(), provider); + if (provider.getProvider().equalsIgnoreCase(defaultProvider)) { + providers.put("default", provider); + } + } + return bean; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidatorProvider.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidatorProvider.java new file mode 100644 index 000000000..b416fc284 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/DefaultTwoFactorValidatorProvider.java @@ -0,0 +1,30 @@ +package org.hswebframework.web.authorization.twofactor.defaults; + +import lombok.Getter; +import org.hswebframework.web.authorization.twofactor.TwoFactorTokenManager; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidator; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorProvider; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@Getter +public abstract class DefaultTwoFactorValidatorProvider implements TwoFactorValidatorProvider { + + private String provider; + + private TwoFactorTokenManager twoFactorTokenManager; + + public DefaultTwoFactorValidatorProvider(String provider, TwoFactorTokenManager twoFactorTokenManager) { + this.provider = provider; + this.twoFactorTokenManager = twoFactorTokenManager; + } + + protected abstract boolean validate(String userId, String code); + + @Override + public TwoFactorValidator createTwoFactorValidator(String userId, String operation) { + return new DefaultTwoFactorValidator(getProvider(), code -> validate(userId, code), () -> twoFactorTokenManager.getToken(userId, operation)); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/HashMapTwoFactorTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/HashMapTwoFactorTokenManager.java new file mode 100644 index 000000000..7dcefd902 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/HashMapTwoFactorTokenManager.java @@ -0,0 +1,70 @@ +package org.hswebframework.web.authorization.twofactor.defaults; + +import org.hswebframework.web.authorization.twofactor.TwoFactorToken; +import org.hswebframework.web.authorization.twofactor.TwoFactorTokenManager; + +import java.io.Serializable; +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public class HashMapTwoFactorTokenManager implements TwoFactorTokenManager { + + private Map> tokens = new ConcurrentHashMap<>(); + + private class TwoFactorTokenInfo implements Serializable { + private static final long serialVersionUID = -5246224779564760241L; + private volatile long lastRequestTime = System.currentTimeMillis(); + + private long timeOut; + + private boolean isExpire() { + return System.currentTimeMillis() - lastRequestTime >= timeOut; + } + } + + + private String createTokenInfoKey(String userId, String operation) { + return userId + "_" + operation; + } + + private TwoFactorTokenInfo getTokenInfo(String userId, String operation) { + return Optional.ofNullable(tokens.get(createTokenInfoKey(userId, operation))) + .map(WeakReference::get) + .orElse(null); + } + + @Override + public TwoFactorToken getToken(String userId, String operation) { + + return new TwoFactorToken() { + private static final long serialVersionUID = -5148037320548431456L; + + @Override + public void generate(long timeout) { + TwoFactorTokenInfo info = new TwoFactorTokenInfo(); + info.timeOut = timeout; + tokens.put(createTokenInfoKey(userId, operation), new WeakReference<>(info)); + } + + @Override + public boolean expired() { + TwoFactorTokenInfo info = getTokenInfo(userId, operation); + if (info == null) { + return true; + } + if (info.isExpire()) { + tokens.remove(createTokenInfoKey(userId, operation)); + return true; + } + info.lastRequestTime = System.currentTimeMillis(); + return false; + } + }; + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/UnsupportedTwoFactorValidator.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/UnsupportedTwoFactorValidator.java new file mode 100644 index 000000000..092b25631 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/twofactor/defaults/UnsupportedTwoFactorValidator.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.authorization.twofactor.defaults; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidator; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@AllArgsConstructor +public class UnsupportedTwoFactorValidator implements TwoFactorValidator { + + @Getter + private String provider; + + @Override + public boolean verify(String code, long timeout) { + throw new UnsupportedOperationException("不支持的验证规则:" + provider); + } + + @Override + public boolean expired() { + throw new UnsupportedOperationException("不支持的验证规则:" + provider); + } +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/hsweb-authorization/hsweb-authorization-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..1afa66b8e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.hswebframework.web.authorization.simple.DefaultAuthorizationAutoConfiguration \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_en.properties b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_en.properties new file mode 100644 index 000000000..1f4814b9c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_en.properties @@ -0,0 +1,17 @@ +error.access_denied=Access Denied +error.permission_denied=Permission Denied [{0}]:{1} +error.logged_in_elsewhere=User logged in elsewhere +error.illegal_password=The username and password are incorrect or the user has been disabled +error.illegal_user_password=Bad Password +error.user_disabled=User is disabled +# +message.token_state_normal=Normal +message.token_state_deny=Login has denied +message.token_state_expired=Login has expired +message.token_state_offline=User logged in elsewhere +message.token_state_lock=User Locked +# +validation.need_two_factor_verify=Two factor verification required +validation.username_must_not_be_empty=Username must not be empty +validation.password_must_not_be_empty=Password must not be empty +validation.verify_code_error=Verification code error \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_zh.properties b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_zh.properties new file mode 100644 index 000000000..9d34f7188 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_zh.properties @@ -0,0 +1,17 @@ +error.access_denied=权限不足,拒绝访问! +error.permission_denied=当前用户无权限[{0}]:{1} +error.logged_in_elsewhere=该用户已在其他地方登陆 +error.illegal_password=用户名密码错误或用户已被禁用 +error.illegal_user_password=密码错误 +error.user_disabled=用户已被禁用 +# +message.token_state_normal=正常 +message.token_state_deny=已被禁止访问 +message.token_state_expired=用户未登录 +message.token_state_offline=用户已在其他地方登录 +message.token_state_lock=登录状态已被锁定 +# +validation.need_two_factor_verify=需要双因子验证 +validation.username_must_not_be_empty=用户名不能为空 +validation.password_must_not_be_empty=密码不能为空 +validation.verify_code_error=验证码错误 \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/AuthenticationTests.java b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/AuthenticationTests.java new file mode 100644 index 000000000..0eb341d6f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/AuthenticationTests.java @@ -0,0 +1,152 @@ +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.builder.AuthenticationBuilder; +import org.hswebframework.web.authorization.simple.builder.SimpleAuthenticationBuilder; +import org.hswebframework.web.authorization.simple.builder.SimpleDataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.token.*; +import org.hswebframework.web.context.ContextKey; +import org.hswebframework.web.logger.ReactiveLogger; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.support.StaticApplicationContext; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + +import java.util.Collections; +import java.util.Set; + +import static org.hswebframework.web.context.ContextUtils.*; +import static org.junit.Assert.*; + +public class AuthenticationTests { + + private AuthenticationBuilder builder; + + @Before + public void setup() { + SimpleDataAccessConfigBuilderFactory builderFactory = new SimpleDataAccessConfigBuilderFactory(); + + builderFactory.init(); + + builder = new SimpleAuthenticationBuilder(builderFactory); + } + + /** + * 测试初始化基本的权限信息 + */ + @Test + public void testInitUserRoleAndPermission() { + Authentication authentication = builder.user("{\"id\":\"admin\",\"username\":\"admin\",\"name\":\"Administrator\",\"userType\":\"default\"}") + .role("[{\"id\":\"admin-role\",\"name\":\"admin\"}]") + .permission("[{\"id\":\"user-manager\",\"actions\":[\"query\",\"get\",\"update\"]" + + ",\"dataAccesses\":[{\"action\":\"query\",\"field\":\"test\",\"fields\":[\"1\",\"2\",\"3\"],\"scopeType\":\"CUSTOM_SCOPE\",\"type\":\"DENY_FIELDS\"}]}]") + .build(); + + //test user + assertEquals(authentication.getUser().getId(), "admin"); + assertEquals(authentication.getUser().getUsername(), "admin"); + assertEquals(authentication.getUser().getName(), "Administrator"); + assertEquals(authentication.getUser().getUserType(), "default"); + + //test role + assertNotNull(authentication.getDimension("role","admin-role").orElse(null)); + assertEquals(authentication.getDimension("role","admin-role").get().getName(), "admin"); + assertTrue(authentication.hasDimension("role","admin-role")); + + + //test permission + assertEquals(authentication.getPermissions().size(), 1); + assertTrue(authentication.hasPermission("user-manager")); + assertTrue(authentication.hasPermission("user-manager", "get")); + assertFalse(authentication.hasPermission("user-manager", "delete")); + + boolean has = AuthenticationPredicate.has("permission:user-manager") + .or(AuthenticationPredicate.dimension("role","admin-role")) + .test(authentication); + + Assert.assertTrue(has); + has = AuthenticationPredicate.has("permission:user-manager:test") + .and(AuthenticationPredicate.dimension("role","admin-role")) + .test(authentication); + Assert.assertFalse(has); + + has = AuthenticationPredicate.has("permission:user-manager:get and role:admin-role") + .test(authentication); + Assert.assertTrue(has); + + has = AuthenticationPredicate.has("permission:user-manager:test or role:admin-role") + .test(authentication); + Assert.assertTrue(has); + + //获取数据权限配置 +// Set fields = authentication.getPermission("user-manager") +// .map(permission -> permission.findDenyFields(Permission.ACTION_QUERY)) +// .orElseGet(Collections::emptySet); + +// Assert.assertEquals(fields.size(), 3); +// System.out.println(fields); + + } + + /** + * 测试设置获取当前登录用户 + */ + @Test + public void testGetSetCurrentUser() { + Authentication authentication = builder.user("{\"id\":\"admin\",\"username\":\"admin\",\"name\":\"Administrator\",\"type\":\"default\"}") + .build(); + + //初始化权限管理器,用于获取用户的权限信息 + ReactiveAuthenticationManager authenticationManager = new ReactiveAuthenticationManager() { + @Override + public Mono authenticate(Mono request) { + return Mono.empty(); + } + + @Override + public Mono getByUserId(String userId) { +// if (userId.equals("admin")) { +// return Mono.just(authentication); +// } + return Mono.empty(); + } + + }; + //绑定用户token + DefaultUserTokenManager userTokenManager = new DefaultUserTokenManager(); + StaticApplicationContext ctx= new StaticApplicationContext(); + ctx.refresh(); + userTokenManager.setEventPublisher(ctx); + UserToken token = userTokenManager.signIn("test", "token-test", "admin", -1,authentication) + .block(); + + ReactiveAuthenticationHolder.addSupplier(new UserTokenReactiveAuthenticationSupplier(userTokenManager, authenticationManager)); + ParsedToken parsedToken=new ParsedToken() { + @Override + public String getToken() { + return token.getToken(); + } + + @Override + public String getType() { + return token.getType(); + } + }; + + //获取当前登录用户 + Authentication + .currentReactive() + .map(Authentication::getUser) + .map(User::getId) + .contextWrite(Context.of(ParsedToken.class, parsedToken)) + .contextWrite(ReactiveLogger.start("rid","1")) + .as(StepVerifier::create) + .expectNext("admin") + .verifyComplete(); + + + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/UserTokenManagerTests.java b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/UserTokenManagerTests.java new file mode 100644 index 000000000..74a9e0953 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/UserTokenManagerTests.java @@ -0,0 +1,146 @@ +package org.hswebframework.web.authorization; + +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import org.hswebframework.web.authorization.token.*; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.context.support.StaticApplicationContext; +import reactor.test.StepVerifier; + +public class UserTokenManagerTests { + + private DefaultUserTokenManager createUserTokenManager(){ + DefaultUserTokenManager userTokenManager = new DefaultUserTokenManager(); + StaticApplicationContext context=new StaticApplicationContext(); + context.refresh(); + + userTokenManager.setEventPublisher(context); + return userTokenManager; + } + + /** + * 基本功能测试 + * + * @throws InterruptedException Thread.sleep error + */ + @Test + public void testDefaultSetting() throws InterruptedException { + DefaultUserTokenManager userTokenManager = createUserTokenManager(); + userTokenManager.setAllopatricLoginMode(AllopatricLoginMode.allow); //允许异地登录 + + UserToken userToken = userTokenManager.signIn("test", "sessionId", "admin", 1000).block(); + Assert.assertNotNull(userToken); + + //可重复登录 + userTokenManager.signIn("test2", "sessionId", "admin", 30000).block(); + + //2个token + userTokenManager.totalToken() + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + //1个用户 + userTokenManager.totalUser() + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + //改变token状态 + userTokenManager.changeUserState("admin", TokenState.deny).subscribe(); + + userToken = userTokenManager.getByToken(userToken.getToken()).block(); + + Assert.assertEquals(userToken.getState(), TokenState.deny); + + userTokenManager.changeUserState("admin", TokenState.normal).subscribe(); + + Thread.sleep(1200); + + userTokenManager.getByToken(userToken.getToken()) + .map(UserToken::isExpired) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + + userTokenManager.checkExpiredToken().subscribe(); + + + userTokenManager.getByToken(userToken.getToken()) + .as(StepVerifier::create) + .expectNextCount(0) + .verifyComplete(); + + userTokenManager.totalToken() + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + userTokenManager.totalUser() + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + } + + + /** + * 测试异地登录模式之禁止登录 + */ + @Test + public void testDeny() throws InterruptedException { + DefaultUserTokenManager userTokenManager = new DefaultUserTokenManager(); + userTokenManager.setAllopatricLoginMode(AllopatricLoginMode.deny);//如果在其他地方登录,本地禁止登录 + userTokenManager.setEventPublisher(new StaticApplicationContext()); + + userTokenManager.signIn("test", "sessionId", "admin", 10000).subscribe(); + + try { + userTokenManager.signIn("test2", "sessionId", "admin", 30000).block(); + Assert.assertTrue(false); + } catch (AccessDenyException e) { + + } + Assert.assertTrue(userTokenManager.getByToken("test").block().isNormal()); + Assert.assertNull(userTokenManager.getByToken("test2").block()); + + } + + /** + * 测试异地登录模式之踢下线 + */ + @Test + public void testOffline() { + DefaultUserTokenManager userTokenManager = createUserTokenManager(); + + userTokenManager.setAllopatricLoginMode(AllopatricLoginMode.offlineOther); //将其他地方登录的用户踢下线 + + userTokenManager.signIn("test", "sessionId", "admin", 1000).subscribe(); + + userTokenManager.signIn("test2", "sessionId", "admin", 30000).subscribe(); + + Assert.assertTrue(userTokenManager.getByToken("test2").block().isNormal()); + + Assert.assertTrue(userTokenManager.getByToken("test").block().isOffline()); + + } + + @Test + public void testAuth() { + DefaultUserTokenManager userTokenManager = createUserTokenManager(); + Authentication authentication = new SimpleAuthentication(); + + userTokenManager.signIn("test", "test", "test", 1000, authentication) + .as(StepVerifier::create) + .expectNextMatches(token -> token.getAuthentication() == authentication) + .verifyComplete(); + + userTokenManager.getByToken("test") + .cast(AuthenticationUserToken.class) + .as(StepVerifier::create) + .expectNextMatches(token -> token.getAuthentication() == authentication) + .verifyComplete(); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/define/MergedAuthorizeDefinitionTest.java b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/define/MergedAuthorizeDefinitionTest.java new file mode 100644 index 000000000..4d3591b88 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/define/MergedAuthorizeDefinitionTest.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.authorization.define; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Set; + +import static org.junit.Assert.*; + +public class MergedAuthorizeDefinitionTest { + + @Test + public void test() { + MergedAuthorizeDefinition definition = new MergedAuthorizeDefinition(); + definition.addResource(ResourceDefinition.of("test", "测试").addAction("create", "新增")); + definition.addResource(ResourceDefinition.of("test", "测试").addAction("update", "修改")); + definition.addResource(ResourceDefinition.of("test", "测试").addAction("update", "修改")); + + + Set definitions = definition.getResources(); + Assert.assertEquals(definitions.size(), 1); + Assert.assertTrue(definitions.iterator().next().hasAction(Arrays.asList("create"))); + Assert.assertTrue(definitions.iterator().next().hasAction(Arrays.asList("update"))); + + } + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/simple/DefaultDimensionManagerTest.java b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/simple/DefaultDimensionManagerTest.java new file mode 100644 index 000000000..b780c30e2 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/simple/DefaultDimensionManagerTest.java @@ -0,0 +1,59 @@ +package org.hswebframework.web.authorization.simple; + +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.DimensionProvider; +import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.dimension.DimensionUserBind; +import org.hswebframework.web.authorization.dimension.DimensionUserBindProvider; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Collection; +import java.util.Collections; + +import static org.junit.Assert.*; + +public class DefaultDimensionManagerTest { + + @Test + public void test() { + DefaultDimensionManager manager = new DefaultDimensionManager(); + manager.addBindProvider(userIdList -> Flux.just( + DimensionUserBind.of("testUser", "testType", "testId") + , DimensionUserBind.of("testUser", "testType", "testId2"))); + manager.addProvider(new DimensionProvider() { + @Override + public Flux getAllType() { + return Flux.just(SimpleDimensionType.of("testType")); + } + + @Override + public Flux getDimensionsById(DimensionType type, + Collection idList) { + return Flux.just(SimpleDimension.of("testId", "testName", SimpleDimensionType.of("testType"), null)); + } + + @Override + public Flux getDimensionByUserId(String userId) { + return Flux.empty(); + } + + @Override + public Mono getDimensionById(DimensionType type, String id) { + return Mono.empty(); + } + + @Override + public Flux getUserIdByDimensionId(String dimensionId) { + return Flux.empty(); + } + }); + + manager.getUserDimension(Collections.singleton("testUser")) + .as(StepVerifier::create) + .expectNextMatches(detail -> detail.getDimensions().size() == 1) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManagerTest.java b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManagerTest.java new file mode 100644 index 000000000..bfea6787a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManagerTest.java @@ -0,0 +1,161 @@ +package org.hswebframework.web.authorization.token.redis; + +import lombok.SneakyThrows; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import org.hswebframework.web.authorization.token.*; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.HashMap; + +import static org.junit.Assert.*; + +@Ignore +public class RedisUserTokenManagerTest { + + UserTokenManager tokenManager; + + @Before + public void init() { + LettuceConnectionFactory factory = new LettuceConnectionFactory(new RedisStandaloneConfiguration("127.0.0.1")); + + ReactiveRedisTemplate template = new ReactiveRedisTemplate<>( + factory, + RedisSerializationContext.java() + ); + factory.afterPropertiesSet(); + + RedisUserTokenManager tokenManager = new RedisUserTokenManager(template); + this.tokenManager = tokenManager; + tokenManager.setAllopatricLoginModes(new HashMap() { + { + put("offline", AllopatricLoginMode.offlineOther); + put("deny", AllopatricLoginMode.deny); + } + }); + } + + @Test + public void testSign() { + + tokenManager.signIn("test-token", "test", "test", 10000) + .map(UserToken::getToken) + .as(StepVerifier::create) + .expectNext("test-token") + .verifyComplete(); + + tokenManager.userIsLoggedIn("test") + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + + tokenManager.tokenIsLoggedIn("test-token") + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + + tokenManager.getByToken("test-token") + .map(UserToken::getState) + .as(StepVerifier::create) + .expectNext(TokenState.normal) + .verifyComplete(); + + tokenManager.signOutByToken("test-token") + .as(StepVerifier::create) + .verifyComplete(); + + } + + + @Test + @SneakyThrows + public void testOfflineOther() { + tokenManager.signIn("test-token_offline1", "offline", "user1", 1000) + .map(UserToken::getToken) + .as(StepVerifier::create) + .expectNext("test-token_offline1") + .verifyComplete(); + + tokenManager.signIn("test-token_offline2", "offline", "user1", 1000) + .map(UserToken::getToken) + .as(StepVerifier::create) + .expectNext("test-token_offline2") + .verifyComplete(); + + tokenManager.getByToken("test-token_offline1") + .map(UserToken::getState) + .as(StepVerifier::create) + .expectNext(TokenState.offline) + .verifyComplete(); + } + + @Test + @SneakyThrows + public void testDeny() { + tokenManager.signIn("test-token_offline3", "deny", "user2", 1000) + .map(UserToken::getToken) + .as(StepVerifier::create) + .expectNext("test-token_offline3") + .verifyComplete(); + + tokenManager.signIn("test-token_offline4", "deny", "user2", 1000) + .map(UserToken::getToken) + .as(StepVerifier::create) + .expectError(AccessDenyException.class) + .verify(); + } + + @Test + @SneakyThrows + public void testSignTimeout() { + tokenManager.signIn("test-token_2", "test", "test2", 1000) + .map(UserToken::getToken) + .as(StepVerifier::create) + .expectNext("test-token_2") + .verifyComplete(); + + tokenManager.touch("test-token_2") + .as(StepVerifier::create) + .expectComplete() + .verify(); + + Thread.sleep(2000); + tokenManager.getByToken("test-token_2") + .switchIfEmpty(Mono.error(new UnAuthorizedException())) + .as(StepVerifier::create) + .expectError(UnAuthorizedException.class) + .verify(); + + tokenManager.getByUserId("test2") + .count() + .as(StepVerifier::create) + .expectNext(0L) + .verifyComplete(); + } + + @Test + public void testAuth() { + Authentication authentication = new SimpleAuthentication(); + + tokenManager.signIn("testAuth", "test", "test", 1000, authentication) + .as(StepVerifier::create) + .expectNextMatches(token -> token.getAuthentication() == authentication) + .verifyComplete(); + + tokenManager.getByToken("testAuth") + .cast(AuthenticationUserToken.class) + .as(StepVerifier::create) + .expectNextMatches(token -> token.getAuthentication() != null) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/twofactor/defaults/HashMapTwoFactorTokenManagerTest.java b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/twofactor/defaults/HashMapTwoFactorTokenManagerTest.java new file mode 100644 index 000000000..7e39dc7e7 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/test/java/org/hswebframework/web/authorization/twofactor/defaults/HashMapTwoFactorTokenManagerTest.java @@ -0,0 +1,29 @@ +package org.hswebframework.web.authorization.twofactor.defaults; + +import lombok.SneakyThrows; +import org.hswebframework.web.authorization.twofactor.TwoFactorToken; +import org.junit.Assert; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * @author zhouhao + * @since 3.0.4 + */ +public class HashMapTwoFactorTokenManagerTest { + + HashMapTwoFactorTokenManager tokenManager = new HashMapTwoFactorTokenManager(); + + @Test + @SneakyThrows + public void test() { + TwoFactorToken twoFactorToken = tokenManager.getToken("test", "test"); + + Assert.assertTrue(twoFactorToken.expired()); + twoFactorToken.generate(1000L); + Assert.assertFalse(twoFactorToken.expired()); + Thread.sleep(1100); + Assert.assertTrue(twoFactorToken.expired()); + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/token.md b/hsweb-authorization/hsweb-authorization-api/token.md new file mode 100644 index 000000000..65814024a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/token.md @@ -0,0 +1,3 @@ +# 用户令牌管理 +用于管理已授权的用户,并这些用户进行操作,如: 统计人数,踢下线,禁止多地点同时登录等操作 + diff --git a/hsweb-authorization/hsweb-authorization-basic/README.md b/hsweb-authorization/hsweb-authorization-basic/README.md new file mode 100644 index 000000000..2aad3b2d5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/README.md @@ -0,0 +1,92 @@ +# 权限控制基础实现 + +1. 实现RBAC权限控制 +2. 实现数据权限控制 +3. 可动态进行权限配置设置 + + +## 授权 +使用`hsweb-authorization-api`提供的监听器,类`UserOnSignIn`监听用户授权事件`AuthorizationSuccessEvent` +当用户完成授权(授权方式可自行实现或者使用框架默认的授权方式,主要触发该事件即可).授权通过后会触发该事件.流程如下 +1. 完成授权,触发`AuthorizationSuccessEvent` +2. `UserOnSignIn` 收到`AuthorizationSuccessEvent`事件,获取参数`token_type`(默认为`sessionId`),以及授权信息 +3. 根据`token_type` 生成token. +4. 将token和授权信息中的userId注册到`UserTokenManager` +5. 将token返回给授权接口 + +![授权](./img/autz-flow.png "授权") + + +## 权限控制 +1. `AopAuthorizingController` aop拦截所有controller方法(注解了:`Controller`或者`RestController`的类的方法) +2. 在客户端发起请求的时候,将拦截到的方法信息(`MethodInterceptorContext`)传给权限定义解析器(`AopMethodAuthorizeDefinitionParser`) +进行解析 +3. 框架默认实现的解析器会先调用所有的`AopMethodAuthorizeDefinitionCustomizerParser`获取自定义的配置(实现`AopMethodAuthorizeDefinitionCustomizerParser`接口并注入到spring即可,自定义未进行缓存,请自行实现缓存策略) +如果没有,则获取缓存,如果缓存不存在就开始解析方法以及类上的注解,并放入缓存后返回权限配 +4. 如果解析器返回的结果不为空,并且用户已经登录,则调用`AuthorizingHandler`进行权限控制 +5. 默认的权限控制实现`DefaultAuthorizingHandler`,将分别进行RBAC,数据权限,表达式方式的权限控制. +6. 如果授权未通过,则抛出`AccessDenyException`异常 + +![权限控制](./img/autz-handle-flow.png "权限控制") + + +## 双重验证 + +配置 application.yml +```yml +hsweb: + authorize: + two-factor: + enable: true +``` + +在需要验证的接口上注解: + +```java +@PostMapping +@TwoFactor("update-password") +public ResponseMessage updatePassword(String password){ + + // +} +``` + +## 注销 +与授权同理,类`UserOnSignOut`监听`AuthorizationExitEvent` ,当触发事件后,调用`UserTokenManager`移除当前登录的token信息 + +## rbac权限控制 +默认对注解`Authorize`进行实现,具体功能,请查看源代码 + +## 数据权限 +原理: 通过用户的权限信息,对aop拦截到的参数进行操作 + +约束: 对方法的参数有要求,如动态查询需要有参数`QueryParamEntity`,controller需要实现`hsweb-commons-controller`中提供的通用controller等 + +例如:用户设置了 机构管理权限(org)只能查询(query)自己和下属的机构. +通过获取拦截到方法的动态查询参数`QueryParamEntity`,对参数进行重构, +客户端的查询条件翻译为sql: +```sql +where name like ? or full_name like +``` + +重构后为: +```sql +--u_id in (用户可访问的机构id) +where u_id in(?,?,?) and (name like ? or full_name like) +``` + +## 授权登录接口 +http接口: `POST /authorize/login`, 登录接口支持2种`content-type`,`application/json`(Json RequestBody方式)和`application/x-www-form-urlencoded`(表单方式), +请在调用等时候指定对应等`content-type`.必要参数: `username` 和 `password`. + +⚠️注意: 此接口只实现了简单的登录逻辑,不过会通过发布各种事件来实现自定义的逻辑处理. + +1. `AuthorizationDecodeEvent` 在接收到登录请求之后触发,如果在登录前对用户名密码进行里加密,可以通过监听此事件实现对用户名密码的解密操作 +2. `AuthorizationBeforeEvent` 在`AuthorizationDecodeEvent`事件完成后触发,可通过监听此事件并获取请求参数,实现验证码功能 +3. `AuthorizationSuccessEvent` 在授权成功后触发.注意: 权限控制模块也是通过监听此事件来完成授权 +4. `AuthorizationFailedEvent` 授权失败时触发.当发生过程中异常时触发此事件 + +什么? 还不知道如何监听事件? [快看这里](https://github.com/hs-web/hsweb-framework/wiki/事件驱动) + +# 会话状态 +此模块默认使用sessionId绑定用户信息。还可以使用 [jwt](../hsweb-authorization-jwt) 方式 diff --git a/hsweb-authorization/hsweb-authorization-basic/img/autz-flow.png b/hsweb-authorization/hsweb-authorization-basic/img/autz-flow.png new file mode 100644 index 000000000..4a8378c28 Binary files /dev/null and b/hsweb-authorization/hsweb-authorization-basic/img/autz-flow.png differ diff --git a/hsweb-authorization/hsweb-authorization-basic/img/autz-handle-flow.png b/hsweb-authorization/hsweb-authorization-basic/img/autz-handle-flow.png new file mode 100644 index 000000000..b2a3ba162 Binary files /dev/null and b/hsweb-authorization/hsweb-authorization-basic/img/autz-handle-flow.png differ diff --git a/hsweb-authorization/hsweb-authorization-basic/pom.xml b/hsweb-authorization/hsweb-authorization-basic/pom.xml new file mode 100644 index 000000000..cb9153818 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/pom.xml @@ -0,0 +1,108 @@ + + + + hsweb-authorization + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-authorization-basic + + 实现hsweb-authorization-api的相关接口以及使用aop实现RBAC和数据权限的控制 + + + + org.hswebframework.web + hsweb-authorization-api + ${project.version} + + + + org.hswebframework + hsweb-expands-script + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.hswebframework.web + hsweb-commons-crud + ${project.version} + + + + org.hswebframework.web + hsweb-access-logging-api + ${project.version} + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework + spring-webmvc + true + + + + org.springframework + spring-webflux + + + + commons-beanutils + commons-beanutils + + + org.hswebframework + hsweb-easy-orm-rdb + + + + javax.servlet + javax.servlet-api + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + test + + + + io.r2dbc + r2dbc-h2 + test + + + + org.springframework + spring-aspects + + + + org.springframework.boot + spring-boot-starter-webflux + test + + + + + \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopAuthorizingController.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopAuthorizingController.java new file mode 100644 index 000000000..de642d936 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopAuthorizingController.java @@ -0,0 +1,218 @@ +package org.hswebframework.web.authorization.basic.aop; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.hswebframework.web.aop.MethodInterceptorContext; +import org.hswebframework.web.aop.MethodInterceptorHolder; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.basic.handler.AuthorizingHandler; +import org.hswebframework.web.authorization.define.*; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.utils.AnnotationUtils; +import org.reactivestreams.Publisher; +import org.springframework.aop.support.StaticMethodMatcherPointcutAdvisor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * @author zhouhao + * @see AuthorizeDefinitionInitializedEvent + */ +@Slf4j +@SuppressWarnings("all") +public class AopAuthorizingController extends StaticMethodMatcherPointcutAdvisor implements CommandLineRunner, MethodInterceptor { + + private static final long serialVersionUID = 1154190623020670672L; + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Autowired + private AuthorizingHandler authorizingHandler; + + @Autowired + private AopMethodAuthorizeDefinitionParser aopMethodAuthorizeDefinitionParser; + +// private DefaultAopMethodAuthorizeDefinitionParser defaultParser = new DefaultAopMethodAuthorizeDefinitionParser(); + + private boolean autoParse = false; + + public void setAutoParse(boolean autoParse) { + this.autoParse = autoParse; + } + + + protected Publisher handleReactive0(AuthorizeDefinition definition, + MethodInterceptorHolder holder, + AuthorizingContext context, + Supplier> invoker) { + MethodInterceptorContext interceptorContext = holder.createParamContext(invoker.get()); + context.setParamContext(interceptorContext); + return this + .invokeReactive( + Authentication + .currentReactive() + .switchIfEmpty( + context.getDefinition().allowAnonymous() + ? Mono.empty() + : Mono.error(UnAuthorizedException.NoStackTrace::new)) + .flatMap(auth -> { + context.setAuthentication(auth); + //响应式不再支持数据权限控制 + return authorizingHandler.handRBACAsync(context); + }), + (Publisher) interceptorContext.getInvokeResult()); + } + + private Publisher invokeReactive(Mono before, Publisher source) { + if (source instanceof Mono) { + return before.then((Mono) source); + } + return before.thenMany(source); + } + + private T invokeReactive(MethodInvocation invocation) { + if (Mono.class.isAssignableFrom(invocation.getMethod().getReturnType())) { + return (T) Mono.defer(() -> doProceed(invocation)); + } + if (Flux.class.isAssignableFrom(invocation.getMethod().getReturnType())) { + return (T) Flux.defer(() -> doProceed(invocation)); + } + return doProceed(invocation); + } + + @SneakyThrows + private T doProceed(MethodInvocation invocation) { + + return (T) invocation.proceed(); + } + + @Override + public Object invoke(MethodInvocation methodInvocation) throws Throwable { + MethodInterceptorHolder holder = MethodInterceptorHolder.create(methodInvocation); + + MethodInterceptorContext paramContext = holder.createParamContext(); + + AuthorizeDefinition definition = aopMethodAuthorizeDefinitionParser + .parse(methodInvocation + .getThis() + .getClass(), + methodInvocation.getMethod(), + paramContext); + Object result = null; + boolean isControl = false; + if (null != definition && !definition.isEmpty()) { + AuthorizingContext context = new AuthorizingContext(); + context.setDefinition(definition); + context.setParamContext(paramContext); + + Class returnType = methodInvocation.getMethod().getReturnType(); + //handle reactive method + if (Publisher.class.isAssignableFrom(returnType)) { + return handleReactive0(definition, holder, context, () -> invokeReactive(methodInvocation)); + } + + Authentication authentication = Authentication + .current() + .orElseThrow(UnAuthorizedException.NoStackTrace::new); + + context.setAuthentication(authentication); + isControl = true; + + Phased dataAccessPhased = definition.getResources().getPhased(); + if (definition.getPhased() == Phased.before) { + //RDAC before + authorizingHandler.handRBAC(context); + + //方法调用前验证数据权限 + if (dataAccessPhased == Phased.before) { + authorizingHandler.handleDataAccess(context); + } + + result = methodInvocation.proceed(); + + //方法调用后验证数据权限 + if (dataAccessPhased == Phased.after) { + context.setParamContext(holder.createParamContext(result)); + authorizingHandler.handleDataAccess(context); + } + } else { + //方法调用前验证数据权限 + if (dataAccessPhased == Phased.before) { + authorizingHandler.handleDataAccess(context); + } + + result = methodInvocation.proceed(); + context.setParamContext(holder.createParamContext(result)); + + authorizingHandler.handRBAC(context); + + //方法调用后验证数据权限 + if (dataAccessPhased == Phased.after) { + authorizingHandler.handleDataAccess(context); + } + } + } + if (!isControl) { + result = methodInvocation.proceed(); + } + return result; + + } + + public AopAuthorizingController(AuthorizingHandler authorizingHandler, AopMethodAuthorizeDefinitionParser aopMethodAuthorizeDefinitionParser) { + this.authorizingHandler = authorizingHandler; + this.aopMethodAuthorizeDefinitionParser = aopMethodAuthorizeDefinitionParser; + setAdvice(this); + } + + @Override + public boolean matches(Method method, Class aClass) { + Authorize authorize; + boolean support = AnnotationUtils.findAnnotation(aClass, Controller.class) != null + || AnnotationUtils.findAnnotation(aClass, RestController.class) != null + || AnnotationUtils.findAnnotation(aClass, RequestMapping.class) != null + || ((authorize = AnnotationUtils.findAnnotation(aClass, method, Authorize.class)) != null && !authorize.ignore() + ); + + if (support && autoParse) { + aopMethodAuthorizeDefinitionParser.parse(aClass, method); + } + return support; + } + + @Override + public void run(String... args) throws Exception { + if (autoParse) { + List definitions = aopMethodAuthorizeDefinitionParser + .getAllParsed() + .stream() + .filter(def -> !def.isEmpty()) + .collect(Collectors.toList()); + log.info("publish AuthorizeDefinitionInitializedEvent,definition size:{}", definitions.size()); + eventPublisher.publishEvent(new AuthorizeDefinitionInitializedEvent(definitions)); + + // defaultParser.destroy(); + } + } + + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopMethodAuthorizeDefinitionCustomizerParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopMethodAuthorizeDefinitionCustomizerParser.java new file mode 100644 index 000000000..a66dbe254 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopMethodAuthorizeDefinitionCustomizerParser.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.web.aop.MethodInterceptorContext; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; + +import java.lang.reflect.Method; + +/** + * 自定义权限控制定义,在拦截到方法后,优先使用此接口来获取权限控制方式 + * @see AuthorizeDefinition + * @author zhouhao + */ +public interface AopMethodAuthorizeDefinitionCustomizerParser { + AuthorizeDefinition parse(Class target, Method method, MethodInterceptorContext context); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopMethodAuthorizeDefinitionParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopMethodAuthorizeDefinitionParser.java new file mode 100644 index 000000000..3e40edf24 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/AopMethodAuthorizeDefinitionParser.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.web.aop.MethodInterceptorContext; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; + +import java.lang.reflect.Method; +import java.util.List; + +/** + * 权限控制定义解析器,用于解析被拦截的请求是否需要进行权限控制,以及权限控制的方式 + * + * @author zhouhao + * @see AuthorizeDefinition + */ +public interface AopMethodAuthorizeDefinitionParser { + + /** + * 解析权限控制定义 + * + * @param target class + * @param method method + * @return 权限控制定义, 如果不进行权限控制则返回{@code null} + */ + AuthorizeDefinition parse(Class target, Method method, MethodInterceptorContext context); + + default AuthorizeDefinition parse(Class target, Method method) { + return parse(target, method, null); + } + + List getAllParsed(); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/DefaultAopMethodAuthorizeDefinitionParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/DefaultAopMethodAuthorizeDefinitionParser.java new file mode 100644 index 000000000..6b3c96afe --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/aop/DefaultAopMethodAuthorizeDefinitionParser.java @@ -0,0 +1,124 @@ +package org.hswebframework.web.authorization.basic.aop; + +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.aop.MethodInterceptorContext; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.DataAccess; +import org.hswebframework.web.authorization.annotation.Dimension; +import org.hswebframework.web.authorization.annotation.ResourceAction; +import org.hswebframework.web.authorization.basic.define.DefaultBasicAuthorizeDefinition; +import org.hswebframework.web.authorization.basic.define.EmptyAuthorizeDefinition; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; +import org.hswebframework.web.utils.AnnotationUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 注解权限控制定义解析器,通过判断方法上的注解来获取权限控制的方式 + * + * @author zhouhao + * @see AopMethodAuthorizeDefinitionParser + * @see AuthorizeDefinition + */ +@Slf4j +public class DefaultAopMethodAuthorizeDefinitionParser implements AopMethodAuthorizeDefinitionParser { + + private final Map cache = new ConcurrentHashMap<>(); + + private List parserCustomizers; + + private static final Set excludeMethodName = new HashSet<>(Arrays.asList("toString", "clone", "hashCode", "getClass")); + + @Autowired(required = false) + public void setParserCustomizers(List parserCustomizers) { + this.parserCustomizers = parserCustomizers; + } + + @Override + public List getAllParsed() { + return new ArrayList<>(cache.values()); + } + + @Override + @SuppressWarnings("all") + public AuthorizeDefinition parse(Class target, Method method, MethodInterceptorContext context) { + if (excludeMethodName.contains(method.getName())) { + return null; + } + CacheKey key = buildCacheKey(target, method); + + AuthorizeDefinition definition = cache.get(key); + if (definition instanceof EmptyAuthorizeDefinition) { + return null; + } + if (null != definition) { + return definition; + } + //使用自定义 + if (!CollectionUtils.isEmpty(parserCustomizers)) { + definition = parserCustomizers + .stream() + .map(customizer -> customizer.parse(target, method, context)) + .filter(Objects::nonNull) + .findAny().orElse(null); + if (definition instanceof EmptyAuthorizeDefinition) { + return null; + } + if (definition != null) { + return definition; + } + } + + Authorize annotation = AnnotationUtils.findAnnotation(target, method, Authorize.class); + + if (isIgnoreMethod(method) || (annotation != null && annotation.ignore())) { + cache.put(key, EmptyAuthorizeDefinition.instance); + return null; + } + synchronized (cache) { + return cache.computeIfAbsent(key, (__) -> { + return DefaultBasicAuthorizeDefinition.from(target, method); + }); + } + } + + public CacheKey buildCacheKey(Class target, Method method) { + return new CacheKey(ClassUtils.getUserClass(target), method); + } + + @EqualsAndHashCode + static class CacheKey { + private final Class type; + private final Method method; + + public CacheKey(Class type, Method method) { + this.type = type; + this.method = method; + } + } + + public void destroy() { + cache.clear(); + } + + static boolean isIgnoreMethod(Method method) { + //不是public的方法 + if(!Modifier.isPublic(method.getModifiers())){ + return true; + } + //没有以下注解 + return null == AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class) + && null == AnnotatedElementUtils.findMergedAnnotation(method, ResourceAction.class); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/AopAuthorizeAutoConfiguration.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/AopAuthorizeAutoConfiguration.java new file mode 100644 index 000000000..c8c2527c9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/AopAuthorizeAutoConfiguration.java @@ -0,0 +1,35 @@ +package org.hswebframework.web.authorization.basic.configuration; + +import org.hswebframework.web.authorization.basic.aop.AopAuthorizingController; +import org.hswebframework.web.authorization.basic.aop.AopMethodAuthorizeDefinitionParser; +import org.hswebframework.web.authorization.basic.aop.DefaultAopMethodAuthorizeDefinitionParser; +import org.hswebframework.web.authorization.basic.handler.AuthorizingHandler; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author zhouhao + */ +@Configuration +@AutoConfigureAfter(AuthorizingHandlerAutoConfiguration.class) +public class AopAuthorizeAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(AopMethodAuthorizeDefinitionParser.class) + public DefaultAopMethodAuthorizeDefinitionParser defaultAopMethodAuthorizeDefinitionParser() { + return new DefaultAopMethodAuthorizeDefinitionParser(); + } + + + @Bean + @ConfigurationProperties(prefix = "hsweb.authorize") + public AopAuthorizingController aopAuthorizingController(AuthorizingHandler authorizingHandler, + AopMethodAuthorizeDefinitionParser aopMethodAuthorizeDefinitionParser) { + + return new AopAuthorizingController(authorizingHandler, aopMethodAuthorizeDefinitionParser); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/AuthorizingHandlerAutoConfiguration.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/AuthorizingHandlerAutoConfiguration.java new file mode 100644 index 000000000..83dd6e592 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/AuthorizingHandlerAutoConfiguration.java @@ -0,0 +1,103 @@ +package org.hswebframework.web.authorization.basic.configuration; + +import org.hswebframework.web.authorization.AuthenticationManager; +import org.hswebframework.web.authorization.ReactiveAuthenticationManagerProvider; +import org.hswebframework.web.authorization.access.DataAccessController; +import org.hswebframework.web.authorization.basic.embed.EmbedAuthenticationProperties; +import org.hswebframework.web.authorization.basic.embed.EmbedReactiveAuthenticationManager; +import org.hswebframework.web.authorization.basic.handler.AuthorizationLoginLoggerInfoHandler; +import org.hswebframework.web.authorization.basic.handler.DefaultAuthorizingHandler; +import org.hswebframework.web.authorization.basic.handler.UserAllowPermissionHandler; +import org.hswebframework.web.authorization.basic.handler.access.DefaultDataAccessController; +import org.hswebframework.web.authorization.basic.web.*; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.*; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 权限控制自动配置类 + * + * @author zhouhao + * @since 3.0 + */ +@AutoConfiguration +@EnableConfigurationProperties(EmbedAuthenticationProperties.class) +public class AuthorizingHandlerAutoConfiguration { + + @Bean + public DefaultDataAccessController dataAccessController() { + return new DefaultDataAccessController(); + } + + @Bean + public DefaultAuthorizingHandler authorizingHandler(DataAccessController dataAccessController) { + return new DefaultAuthorizingHandler(dataAccessController); + } + + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public UserTokenWebFilter userTokenWebFilter() { + return new UserTokenWebFilter(); + } + + + @Bean + public ReactiveAuthenticationManagerProvider embedAuthenticationManager(EmbedAuthenticationProperties properties) { + return new EmbedReactiveAuthenticationManager(properties); + } + + @Bean + public UserAllowPermissionHandler userAllowPermissionHandler() { + return new UserAllowPermissionHandler(); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + @ConfigurationProperties(prefix = "hsweb.authorize.token.default") + public DefaultUserTokenGenPar defaultUserTokenGenPar() { + return new DefaultUserTokenGenPar(); + } + + @Bean + public AuthorizationController authorizationController() { + return new AuthorizationController(); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public ReactiveUserTokenController userTokenController() { + return new ReactiveUserTokenController(); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public BearerTokenParser bearerTokenParser() { + return new BearerTokenParser(); + } + + + @Configuration + @ConditionalOnProperty(prefix = "hsweb.authorize", name = "basic-authorization", havingValue = "true") + @ConditionalOnClass(UserTokenForTypeParser.class) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public static class BasicAuthorizationConfiguration { + @Bean + public BasicAuthorizationTokenParser basicAuthorizationTokenParser(AuthenticationManager authenticationManager, + UserTokenManager tokenManager) { + return new BasicAuthorizationTokenParser(authenticationManager, tokenManager); + } + + } + + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public AuthorizationLoginLoggerInfoHandler authorizationLoginLoggerInfoHandler() { + return new AuthorizationLoginLoggerInfoHandler(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/BasicAuthorizationTokenParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/BasicAuthorizationTokenParser.java new file mode 100644 index 000000000..0a896adfa --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/BasicAuthorizationTokenParser.java @@ -0,0 +1,96 @@ +package org.hswebframework.web.authorization.basic.configuration; + +import org.apache.commons.codec.binary.Base64; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.AuthenticationManager; +import org.hswebframework.web.authorization.basic.web.AuthorizedToken; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.authorization.basic.web.UserTokenForTypeParser; +import org.hswebframework.web.authorization.simple.PlainTextUsernamePasswordAuthenticationRequest; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.authorization.token.UserTokenManager; +import reactor.core.publisher.Mono; + +import javax.servlet.http.HttpServletRequest; + +public class BasicAuthorizationTokenParser implements UserTokenForTypeParser { + + private final AuthenticationManager authenticationManager; + + private final UserTokenManager userTokenManager; + + @Override + public String getTokenType() { + return "basic"; + } + + public BasicAuthorizationTokenParser(AuthenticationManager authenticationManager, UserTokenManager userTokenManager) { + this.authenticationManager = authenticationManager; + this.userTokenManager = userTokenManager; + } + + @Override + public ParsedToken parseToken(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization == null) { + return null; + } + if (authorization.contains(" ")) { + String[] info = authorization.split("[ ]"); + if (info[0].equalsIgnoreCase(getTokenType())) { + authorization = info[1]; + } + } + try { + String usernameAndPassword = new String(Base64.decodeBase64(authorization)); + UserToken token = userTokenManager.getByToken(usernameAndPassword).blockOptional().orElse(null); + if (token != null && token.isNormal()) { + return new ParsedToken() { + @Override + public String getToken() { + return usernameAndPassword; + } + + @Override + public String getType() { + return getTokenType(); + } + }; + } + if (usernameAndPassword.contains(":")) { + String[] arr = usernameAndPassword.split("[:]"); + Authentication authentication = authenticationManager + .authenticate(new PlainTextUsernamePasswordAuthenticationRequest(arr[0], arr[1])) + ; + if (authentication != null) { + return new AuthorizedToken() { + @Override + public String getUserId() { + return authentication.getUser().getId(); + } + + @Override + public String getToken() { + return usernameAndPassword; + } + + @Override + public String getType() { + return getTokenType(); + } + + @Override + public long getMaxInactiveInterval() { + //60分钟有效期 + return 60 * 60 * 1000L; + } + }; + } + } + } catch (Exception e) { + return null; + } + + return null; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/EnableAopAuthorize.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/EnableAopAuthorize.java new file mode 100644 index 000000000..e0063da8d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/EnableAopAuthorize.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.authorization.basic.configuration; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; + +import java.lang.annotation.*; + +/** + * 开启基于AOP的权限控制 + * + * @author zhouhao + * @see org.hswebframework.web.authorization.Authentication + * @see org.hswebframework.web.authorization.annotation.Authorize + * @see org.hswebframework.web.authorization.annotation.Resource + * @see org.hswebframework.web.authorization.annotation.ResourceAction + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration({AopAuthorizeAutoConfiguration.class}) +public @interface EnableAopAuthorize { + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/WebMvcAuthorizingConfiguration.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/WebMvcAuthorizingConfiguration.java new file mode 100644 index 000000000..7ff7373e0 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/configuration/WebMvcAuthorizingConfiguration.java @@ -0,0 +1,79 @@ +package org.hswebframework.web.authorization.basic.configuration; + +import org.hswebframework.web.authorization.basic.aop.AopMethodAuthorizeDefinitionParser; +import org.hswebframework.web.authorization.basic.twofactor.TwoFactorHandlerInterceptorAdapter; +import org.hswebframework.web.authorization.basic.web.*; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorManager; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.*; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Nonnull; +import java.util.List; + +@AutoConfiguration +@ConditionalOnClass(name = "org.springframework.web.servlet.config.annotation.WebMvcConfigurer") +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class WebMvcAuthorizingConfiguration { + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + @ConditionalOnBean(AopMethodAuthorizeDefinitionParser.class) + public WebMvcConfigurer webUserTokenInterceptorConfigurer(UserTokenManager userTokenManager, + AopMethodAuthorizeDefinitionParser parser, + List userTokenParser) { + + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new WebUserTokenInterceptor(userTokenManager, userTokenParser, parser)); + } + }; + } + + @Bean + public UserOnSignIn userOnSignIn(UserTokenManager userTokenManager) { + return new UserOnSignIn(userTokenManager); + } + + @Bean + public UserOnSignOut userOnSignOut(UserTokenManager userTokenManager) { + return new UserOnSignOut(userTokenManager); + } + + @SuppressWarnings("all") + @ConfigurationProperties(prefix = "hsweb.authorize.token.default") + public ServletUserTokenGenPar servletUserTokenGenPar() { + return new ServletUserTokenGenPar(); + } + + @Bean + @ConditionalOnMissingBean(UserTokenParser.class) + public UserTokenParser userTokenParser() { + return new SessionIdUserTokenParser(); + } + + @Bean + public SessionIdUserTokenGenerator sessionIdUserTokenGenerator() { + return new SessionIdUserTokenGenerator(); + } + + @Bean + @ConditionalOnProperty(prefix = "hsweb.authorize.two-factor", name = "enable", havingValue = "true") + @Order(100) + public WebMvcConfigurer twoFactorHandlerConfigurer(TwoFactorValidatorManager manager) { + return new WebMvcConfigurer() { + @Override + public void addInterceptors(@Nonnull InterceptorRegistry registry) { + registry.addInterceptor(new TwoFactorHandlerInterceptorAdapter(manager)); + } + }; + } + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/AopAuthorizeDefinitionParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/AopAuthorizeDefinitionParser.java new file mode 100644 index 000000000..e2810245a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/AopAuthorizeDefinitionParser.java @@ -0,0 +1,160 @@ +package org.hswebframework.web.authorization.basic.define; + +import org.hswebframework.web.authorization.annotation.*; +import org.hswebframework.web.authorization.define.AopAuthorizeDefinition; +import org.hswebframework.web.authorization.define.ResourceActionDefinition; +import org.hswebframework.web.authorization.define.ResourceDefinition; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.CollectionUtils; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class AopAuthorizeDefinitionParser { + + private static final Set> types = new HashSet<>(Arrays.asList( + Authorize.class, + DataAccess.class, + Dimension.class, + Resource.class, + ResourceAction.class, + DataAccessType.class + )); + + private final Set methodAnnotation; + + private final Set classAnnotation; + + private final Map, List> classAnnotationGroup; + + private final Map, List> methodAnnotationGroup; + + private final DefaultBasicAuthorizeDefinition definition; + + AopAuthorizeDefinitionParser(Class targetClass, Method method) { + definition = new DefaultBasicAuthorizeDefinition(); + definition.setTargetClass(targetClass); + definition.setTargetMethod(method); + + methodAnnotation = AnnotatedElementUtils.findAllMergedAnnotations(method, types); + + classAnnotation = AnnotatedElementUtils.findAllMergedAnnotations(targetClass, types); + + classAnnotationGroup = classAnnotation + .stream() + .collect(Collectors.groupingBy(Annotation::annotationType)); + + methodAnnotationGroup = methodAnnotation + .stream() + .collect(Collectors.groupingBy(Annotation::annotationType)); + } + + private void initClassAnnotation() { + for (Annotation annotation : classAnnotation) { + if (annotation instanceof Authorize) { + definition.putAnnotation(((Authorize) annotation)); + } + if (annotation instanceof Resource) { + definition.putAnnotation(((Resource) annotation)); + } + } + } + + private void initMethodAnnotation() { + for (Annotation annotation : methodAnnotation) { + if (annotation instanceof Authorize) { + definition.putAnnotation(((Authorize) annotation)); + } + if (annotation instanceof Resource) { + definition.putAnnotation(((Resource) annotation)); + } + if (annotation instanceof Dimension) { + definition.putAnnotation(((Dimension) annotation)); + } + } + } + + private void initClassDataAccessAnnotation() { + for (Annotation annotation : classAnnotation) { + if (annotation instanceof DataAccessType || + annotation instanceof DataAccess) { + for (ResourceDefinition resource : definition.getResources().getResources()) { + for (ResourceActionDefinition action : resource.getActions()) { + if (annotation instanceof DataAccessType) { + definition.putAnnotation(action, (DataAccessType) annotation); + } else { + definition.putAnnotation(action, (DataAccess) annotation); + } + } + } + } + } + } + + private void initMethodDataAccessAnnotation() { + for (Annotation annotation : methodAnnotation) { + + if (annotation instanceof ResourceAction) { + getAnnotationByType(Resource.class) + .map(res -> definition.getResources().getResource(res.id()).orElse(null)) + .filter(Objects::nonNull) + .forEach(res -> { + ResourceAction ra = (ResourceAction) annotation; + ResourceActionDefinition action = definition.putAnnotation(res, ra); + getAnnotationByType(DataAccessType.class) + .findFirst() + .ifPresent(dat -> definition.putAnnotation(action, dat)); + }); + } + Optional actionDefinition = getAnnotationByType(Resource.class) + .map(res -> definition.getResources().getResource(res.id()).orElse(null)) + .filter(Objects::nonNull) + .flatMap(res -> getAnnotationByType(ResourceAction.class) + .map(ra -> res.getAction(ra.id()) + .orElse(null)) + ) + .filter(Objects::nonNull) + .findFirst(); + + if (annotation instanceof DataAccessType) { + actionDefinition.ifPresent(ra -> definition.putAnnotation(ra, (DataAccessType) annotation)); + } + + if (annotation instanceof DataAccess) { + actionDefinition.ifPresent(ra -> { + definition.putAnnotation(ra, (DataAccess) annotation); + getAnnotationByType(DataAccessType.class) + .findFirst() + .ifPresent(dat -> definition.putAnnotation(ra, dat)); + }); + } + + } + } + + AopAuthorizeDefinition parse() { + //没有任何注解 + if (CollectionUtils.isEmpty(classAnnotation) && CollectionUtils.isEmpty(methodAnnotation)) { + return EmptyAuthorizeDefinition.instance; + } + initClassAnnotation(); + initClassDataAccessAnnotation(); + initMethodAnnotation(); + initMethodDataAccessAnnotation(); + + return definition; + } + + + private Stream getAnnotationByType(Class type) { + return Optional.ofNullable(methodAnnotationGroup.getOrDefault(type, classAnnotationGroup.get(type))) + .map(Collection::stream) + .orElseGet(Stream::empty) + .map(type::cast); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java new file mode 100644 index 000000000..d41ce68c6 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java @@ -0,0 +1,168 @@ +package org.hswebframework.web.authorization.basic.define; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.*; +import org.hswebframework.web.authorization.annotation.*; +import org.hswebframework.web.authorization.define.*; +import org.springframework.util.StringUtils; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.hswebframework.web.authorization.define.ResourceDefinition.supportLocale; + +/** + * 默认权限权限定义 + * + * @author zhouhao + * @since 3.0 + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class DefaultBasicAuthorizeDefinition implements AopAuthorizeDefinition { + + @JsonIgnore + private Class targetClass; + + @JsonIgnore + private Method targetMethod; + + private ResourcesDefinition resources = new ResourcesDefinition(); + private DimensionsDefinition dimensions = new DimensionsDefinition(); + + private String message = "error.access_denied"; + + private Phased phased = Phased.before; + + private boolean allowAnonymous = false; + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean allowAnonymous() { + return allowAnonymous; + } + + private static final Set> types = new HashSet<>(Arrays.asList( + Authorize.class, + DataAccess.class, + Dimension.class, + Resource.class, + ResourceAction.class, + DataAccessType.class + )); + + public static AopAuthorizeDefinition from(Class targetClass, Method method) { + AopAuthorizeDefinitionParser parser = new AopAuthorizeDefinitionParser(targetClass, method); + + return parser.parse(); + } + + public void putAnnotation(Authorize ann) { + if (!ann.merge()) { + getResources().getResources().clear(); + getDimensions().getDimensions().clear(); + } + setPhased(ann.phased()); + getResources().setPhased(ann.phased()); + for (Resource resource : ann.resources()) { + putAnnotation(resource); + } + for (Dimension dimension : ann.dimension()) { + putAnnotation(dimension); + } + if (ann.anonymous()) { + allowAnonymous = true; + } + } + + public void putAnnotation(Dimension ann) { + if (ann.ignore()) { + getDimensions().getDimensions().clear(); + return; + } + DimensionDefinition definition = new DimensionDefinition(); + definition.setTypeId(ann.type()); + definition.setDimensionId(new HashSet<>(Arrays.asList(ann.id()))); + definition.setLogical(ann.logical()); + getDimensions().addDimension(definition); + } + + public void putAnnotation(Resource ann) { + ResourceDefinition resource = new ResourceDefinition(); + resource.setId(ann.id()); + resource.setName(ann.name()); + resource.setLogical(ann.logical()); + resource.setPhased(ann.phased()); + resource.setDescription(String.join("\n", ann.description())); + for (ResourceAction action : ann.actions()) { + putAnnotation(resource, action); + } + resource.setGroup(new ArrayList<>(Arrays.asList(ann.group()))); + setPhased(ann.phased()); + getResources().setPhased(ann.phased()); + resources.addResource(resource, ann.merge()); + } + + public ResourceActionDefinition putAnnotation(ResourceDefinition definition, ResourceAction ann) { + ResourceActionDefinition actionDefinition = new ResourceActionDefinition(); + actionDefinition.setId(ann.id()); + actionDefinition.setName(ann.name()); + actionDefinition.setDescription(String.join("\n", ann.description())); + for (DataAccess dataAccess : ann.dataAccess()) { + putAnnotation(actionDefinition, dataAccess); + } + definition.addAction(actionDefinition); + return actionDefinition; + } + + + public void putAnnotation(ResourceActionDefinition definition, DataAccess ann) { + if (ann.ignore()) { + return; + } + DataAccessTypeDefinition typeDefinition = new DataAccessTypeDefinition(); + for (DataAccessType dataAccessType : ann.type()) { + if (dataAccessType.ignore()) { + continue; + } + typeDefinition.setId(dataAccessType.id()); + typeDefinition.setName(dataAccessType.name()); + typeDefinition.setController(dataAccessType.controller()); + typeDefinition.setConfiguration(dataAccessType.configuration()); + typeDefinition.setDescription(String.join("\n", dataAccessType.description())); + } + if (StringUtils.isEmpty(typeDefinition.getId())) { + return; + } + definition.getDataAccess() + .getDataAccessTypes() + .add(typeDefinition); + } + + public void putAnnotation(ResourceActionDefinition definition, DataAccessType dataAccessType) { + if (dataAccessType.ignore()) { + return; + } + DataAccessTypeDefinition typeDefinition = new DataAccessTypeDefinition(); + typeDefinition.setId(dataAccessType.id()); + typeDefinition.setName(dataAccessType.name()); + typeDefinition.setController(dataAccessType.controller()); + typeDefinition.setConfiguration(dataAccessType.configuration()); + typeDefinition.setDescription(String.join("\n", dataAccessType.description())); + definition.getDataAccess() + .getDataAccessTypes() + .add(typeDefinition); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/EmptyAuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/EmptyAuthorizeDefinition.java new file mode 100644 index 000000000..d731e4b00 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/EmptyAuthorizeDefinition.java @@ -0,0 +1,55 @@ +package org.hswebframework.web.authorization.basic.define; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hswebframework.web.authorization.define.*; + +import java.lang.reflect.Method; + +/** + * @author zhouhao + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EmptyAuthorizeDefinition implements AopAuthorizeDefinition { + + public static EmptyAuthorizeDefinition instance = new EmptyAuthorizeDefinition(); + + + @Override + public ResourcesDefinition getResources() { + throw new UnsupportedOperationException(); + } + + @Override + public DimensionsDefinition getDimensions() { + + throw new UnsupportedOperationException(); + } + + @Override + public String getMessage() { + + throw new UnsupportedOperationException(); + } + + @Override + public Phased getPhased() { + + throw new UnsupportedOperationException(); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public Class getTargetClass() { + throw new UnsupportedOperationException(); + } + + @Override + public Method getTargetMethod() { + throw new UnsupportedOperationException(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/MergedAuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/MergedAuthorizeDefinition.java new file mode 100644 index 000000000..9b4678858 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/MergedAuthorizeDefinition.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.authorization.basic.define; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; +import org.hswebframework.web.authorization.define.DimensionsDefinition; +import org.hswebframework.web.authorization.define.ResourcesDefinition; + +import java.io.Serializable; +import java.util.List; + +@Getter +@Setter +public class MergedAuthorizeDefinition implements Serializable { + + private ResourcesDefinition resources = new ResourcesDefinition(); + private DimensionsDefinition dimensions = new DimensionsDefinition(); + + + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationInfo.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationInfo.java new file mode 100644 index 000000000..38dd0684c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationInfo.java @@ -0,0 +1,111 @@ +package org.hswebframework.web.authorization.basic.embed; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import org.hswebframework.web.authorization.simple.SimplePermission; +import org.hswebframework.web.authorization.simple.SimpleRole; +import org.hswebframework.web.authorization.simple.SimpleUser; + +import java.util.*; +import java.util.stream.Collectors; + +/** + *
+ * hsweb:
+ *      users:
+ *          admin:
+ *            name: 超级管理员
+ *            username: admin
+ *            password: admin
+ *            roles:
+ *              - id: admin
+ *                name: 管理员
+ *              - id: user
+ *                name: 用户
+ *            permissions:
+ *              - id: user-manager
+ *                actions: *
+ *                dataAccesses:
+ *                  - action: query
+ *                    type: DENY_FIELDS
+ *                    fields: password,salt
+ * 
+ * + * @author zhouhao + * @since 3.0.0-RC + */ +@Getter +@Setter +public class EmbedAuthenticationInfo { + + private String id; + + private String name; + + private String username; + + private String type; + + private String password; + + private List roles = new ArrayList<>(); + + private List permissions = new ArrayList<>(); + + private Map> permissionsSimple = new HashMap<>(); + + @Getter + @Setter + public static class PermissionInfo { + private String id; + + private String name; + + private Set actions = new HashSet<>(); + + private List> dataAccesses = new ArrayList<>(); + } + + public Authentication toAuthentication(DataAccessConfigBuilderFactory factory) { + SimpleAuthentication authentication = new SimpleAuthentication(); + SimpleUser user = new SimpleUser(); + user.setId(id); + user.setName(name); + user.setUsername(username); + user.setUserType(type); + authentication.setUser(user); + authentication.getDimensions().addAll(roles); + List permissionList = new ArrayList<>(); + + permissionList.addAll(permissions.stream() + .map(info -> { + SimplePermission permission = new SimplePermission(); + permission.setId(info.getId()); + permission.setName(info.getName()); + permission.setActions(info.getActions()); + permission.setDataAccesses(info.getDataAccesses() + .stream().map(conf -> factory.create() + .fromMap(conf) + .build()).collect(Collectors.toSet())); + return permission; + + }) + .collect(Collectors.toList())); + + permissionList.addAll(permissionsSimple.entrySet().stream() + .map(entry -> { + SimplePermission permission = new SimplePermission(); + permission.setId(entry.getKey()); + permission.setActions(new HashSet<>(entry.getValue())); + return permission; + }).collect(Collectors.toList())); + + authentication.setPermissions(permissionList); + return authentication; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationManager.java new file mode 100644 index 000000000..6a842034a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationManager.java @@ -0,0 +1,37 @@ +package org.hswebframework.web.authorization.basic.embed; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.AuthenticationManager; +import org.hswebframework.web.authorization.AuthenticationRequest; +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +/** + * @author zhouhao + * @since 3.0.0-RC + */ + +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EmbedAuthenticationManager implements AuthenticationManager { + + @Autowired + private EmbedAuthenticationProperties properties; + + @Override + public Authentication authenticate(AuthenticationRequest request) { + return properties.authenticate(request); + + } + + @Override + public Optional getByUserId(String userId) { + return properties.getAuthentication(userId); + } + + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationProperties.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationProperties.java new file mode 100644 index 000000000..1b35e4b1a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedAuthenticationProperties.java @@ -0,0 +1,113 @@ +package org.hswebframework.web.authorization.basic.embed; + +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.AuthenticationRequest; +import org.hswebframework.web.authorization.builder.DataAccessConfigBuilderFactory; +import org.hswebframework.web.authorization.simple.PlainTextUsernamePasswordAuthenticationRequest; +import org.hswebframework.web.authorization.simple.builder.SimpleDataAccessConfigBuilderFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import javax.validation.ValidationException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + *
+ * hsweb:
+ *    auth:
+ *      users:
+ *          admin:
+ *            name: 超级管理员
+ *            username: admin
+ *            password: admin
+ *            roles:
+ *              - id: admin
+ *                name: 管理员
+ *              - id: user
+ *                name: 用户
+ *            permissions:
+ *              - id: user-manager
+ *                actions: *
+ *                dataAccesses:
+ *                  - action: query
+ *                    type: DENY_FIELDS
+ *                    fields: password,salt
+ * 
+ * + * @author zhouhao + * @since 3.0.0-RC + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "hsweb.auth") +public class EmbedAuthenticationProperties implements InitializingBean { + + private Map authentications = new HashMap<>(); + + @Getter + @Setter + private Map users = new HashMap<>(); + + @Autowired(required = false) + private DataAccessConfigBuilderFactory dataAccessConfigBuilderFactory = new SimpleDataAccessConfigBuilderFactory(); + + @Override + public void afterPropertiesSet() { + users.forEach((id, properties) -> { + if (StringUtils.isEmpty(properties.getId())) { + properties.setId(id); + } + for (EmbedAuthenticationInfo.PermissionInfo permissionInfo : properties.getPermissions()) { + for (Map objectMap : permissionInfo.getDataAccesses()) { + for (Map.Entry stringObjectEntry : objectMap.entrySet()) { + if (stringObjectEntry.getValue() instanceof Map) { + Map mapVal = ((Map) stringObjectEntry.getValue()); + boolean maybeIsList = mapVal + .keySet() + .stream() + .allMatch(org.hswebframework.utils.StringUtils::isInt); + if (maybeIsList) { + stringObjectEntry.setValue(mapVal.values()); + } + } + } + } + } + authentications.put(id, properties.toAuthentication(dataAccessConfigBuilderFactory)); + }); + } + + public Authentication authenticate(AuthenticationRequest request) { + if (MapUtils.isEmpty(users)) { + return null; + } + if (request instanceof PlainTextUsernamePasswordAuthenticationRequest) { + PlainTextUsernamePasswordAuthenticationRequest pwdReq = ((PlainTextUsernamePasswordAuthenticationRequest) request); + for (EmbedAuthenticationInfo user : users.values()) { + if (pwdReq.getUsername().equals(user.getUsername())) { + if (pwdReq.getPassword().equals(user.getPassword())) { + return user.toAuthentication(dataAccessConfigBuilderFactory); + } + return null; + } + } + return null; + } + + throw new UnsupportedOperationException("不支持的授权请求:" + request); + } + + public Optional getAuthentication(String userId) { + return Optional.ofNullable(authentications.get(userId)); + } + + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedReactiveAuthenticationManager.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedReactiveAuthenticationManager.java new file mode 100644 index 000000000..94fbd6739 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/embed/EmbedReactiveAuthenticationManager.java @@ -0,0 +1,46 @@ +package org.hswebframework.web.authorization.basic.embed; + +import lombok.AllArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.AuthenticationRequest; +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.hswebframework.web.authorization.ReactiveAuthenticationManagerProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import reactor.core.publisher.Mono; + +/** + * @author zhouhao + * @since 4.0.0 + */ +@Order(10) +@AllArgsConstructor +public class EmbedReactiveAuthenticationManager implements ReactiveAuthenticationManagerProvider { + + private final EmbedAuthenticationProperties properties; + + @Override + public Mono authenticate(Mono request) { + if (MapUtils.isEmpty(properties.getUsers())) { + return Mono.empty(); + } + return request. + handle((req, sink) -> { + Authentication auth = properties.authenticate(req); + if (auth != null) { + sink.next(auth); + } + }); + + } + + @Override + public Mono getByUserId(String userId) { + return Mono.justOrEmpty(properties.getAuthentication(userId)); + } + + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/AuthorizationLoginLoggerInfoHandler.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/AuthorizationLoginLoggerInfoHandler.java new file mode 100644 index 000000000..13c08cff9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/AuthorizationLoginLoggerInfoHandler.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.authorization.basic.handler; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.events.AuthorizationSuccessEvent; +import org.hswebframework.web.logging.AccessLoggerInfo; +import org.springframework.context.event.EventListener; +import reactor.core.publisher.Mono; + +/** + * @author gyl + * @since 2.2 + */ +public class AuthorizationLoginLoggerInfoHandler { + + @EventListener + public void fillLoggerInfoAuth(AuthorizationSuccessEvent event) { + event.async( + //填充操作日志用户认证信息 + Mono.deferContextual(ctx -> { + ctx.getOrEmpty(AccessLoggerInfo.class) + .ifPresent(loggerInfo -> { + Authentication auth = event.getAuthentication(); + loggerInfo.putContext("userId", auth.getUser().getId()); + loggerInfo.putContext("username", auth.getUser().getUsername()); + loggerInfo.putContext("userName", auth.getUser().getName()); + }); + // FIXME: 2024/3/26 未传递用户维度信息,如有需要也可通过上下文传递 + return Mono.empty(); + }) + ); + + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/AuthorizingHandler.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/AuthorizingHandler.java new file mode 100644 index 000000000..8a630a303 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/AuthorizingHandler.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.authorization.basic.handler; + +import org.hswebframework.web.authorization.define.AuthorizingContext; +import reactor.core.publisher.Mono; + +/** + * aop方式权限控制处理器 + * + * @author zhouhao + */ +public interface AuthorizingHandler { + + void handRBAC(AuthorizingContext context); + + default Mono handRBACAsync(AuthorizingContext context) { + return Mono.fromRunnable(() -> handRBAC(context)); + } + + @Deprecated + void handleDataAccess(AuthorizingContext context); + + @Deprecated + default void handle(AuthorizingContext context) { + handRBAC(context); + handleDataAccess(context); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/DefaultAuthorizingHandler.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/DefaultAuthorizingHandler.java new file mode 100644 index 000000000..bfac89095 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/DefaultAuthorizingHandler.java @@ -0,0 +1,160 @@ +package org.hswebframework.web.authorization.basic.handler; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.access.DataAccessController; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; +import org.hswebframework.web.authorization.define.AuthorizingContext; +import org.hswebframework.web.authorization.define.HandleType; +import org.hswebframework.web.authorization.define.ResourcesDefinition; +import org.hswebframework.web.authorization.events.AuthorizingHandleBeforeEvent; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import reactor.core.publisher.Mono; + +import java.util.concurrent.TimeUnit; + +/** + * @author zhouhao + */ +@Slf4j +public class DefaultAuthorizingHandler implements AuthorizingHandler { + + private DataAccessController dataAccessController; + + private ApplicationEventPublisher eventPublisher; + + public DefaultAuthorizingHandler(DataAccessController dataAccessController) { + this.dataAccessController = dataAccessController; + } + + public DefaultAuthorizingHandler() { + } + + public void setDataAccessController(DataAccessController dataAccessController) { + this.dataAccessController = dataAccessController; + } + + @Autowired + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @Override + public void handRBAC(AuthorizingContext context) { + if (handleEvent(context, HandleType.RBAC)) { + return; + } + //进行rdac权限控制 + handleRBAC(context.getAuthentication(), context.getDefinition()); + + } + + @Override + public Mono handRBACAsync(AuthorizingContext context) { + return this + .handleEventAsync(context, HandleType.RBAC) + .doOnNext(handled -> { + //没有自定义事件处理 + if (!handled) { + handleRBAC(context.getAuthentication(), context.getDefinition()); + } + }) + .then(); + } + + private Mono handleEventAsync(AuthorizingContext context, HandleType type) { + if (null != eventPublisher) { + AuthorizingHandleBeforeEvent event = new AuthorizingHandleBeforeEvent(context, type); + return event + .publish(eventPublisher) + .then(Mono.fromCallable(() -> { + if (!event.isExecute()) { + if (event.isAllow()) { + return true; + } else { + throw new AccessDenyException.NoStackTrace(event.getMessage()); + } + } + return false; + })); + } + return Mono.just(false); + } + + @SneakyThrows + private boolean handleEvent(AuthorizingContext context, HandleType type) { + if (null != eventPublisher) { + AuthorizingHandleBeforeEvent event = new AuthorizingHandleBeforeEvent(context, type); + eventPublisher.publishEvent(event); + if (event.hasListener()) { + event + .getAsync() + .toFuture() + .get(10, TimeUnit.SECONDS); + } + if (!event.isExecute()) { + if (event.isAllow()) { + return true; + } else { + throw new AccessDenyException.NoStackTrace(event.getMessage()); + } + } + } + return false; + } + + public void handleDataAccess(AuthorizingContext context) { + + if (dataAccessController == null) { + log.warn("dataAccessController is null,skip result access control!"); + return; + } + if (context.getDefinition().getResources() == null) { + return; + } + if (handleEvent(context, HandleType.DATA)) { + return; + } + + DataAccessController finalAccessController = dataAccessController; + Authentication autz = context.getAuthentication(); + + boolean isAccess = context + .getDefinition() + .getResources() + .getDataAccessResources() + .stream() + .allMatch(resource -> { + Permission permission = autz + .getPermission(resource.getId()) + .orElseThrow(AccessDenyException.NoStackTrace::new); + return resource + .getDataAccessAction() + .stream() + .allMatch(act -> permission + .getDataAccesses(act.getId()) + .stream() + .allMatch(dataAccessConfig -> finalAccessController.doAccess(dataAccessConfig, context))); + + }); + if (!isAccess) { + throw new AccessDenyException.NoStackTrace(context.getDefinition().getMessage()); + } + } + + + protected void handleRBAC(Authentication authentication, AuthorizeDefinition definition) { + + ResourcesDefinition resources = definition.getResources(); + + if (!resources.hasPermission(authentication)) { + throw new AccessDenyException.NoStackTrace(definition.getMessage(), definition.getDescription()); + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/UserAllowPermissionHandler.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/UserAllowPermissionHandler.java new file mode 100644 index 000000000..100134581 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/UserAllowPermissionHandler.java @@ -0,0 +1,83 @@ +package org.hswebframework.web.authorization.basic.handler; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.define.AuthorizingContext; +import org.hswebframework.web.authorization.define.HandleType; +import org.hswebframework.web.authorization.events.AuthorizingHandleBeforeEvent; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.event.EventListener; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.ClassUtils; +import org.springframework.util.PathMatcher; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + *
+ *     hsweb:
+ *        authorize:
+ *            allows:
+ *               user:
+ *                  admin: *
+ *                  guest: **.query*
+ *               role:
+ *                  admin: *
+ *
+ * 
+ * + * @author zhouhao + * @since 3.0.1 + */ +@ConfigurationProperties("hsweb.authorize") +public class UserAllowPermissionHandler { + + @Getter + @Setter + private Map> allows = new HashMap<>(); + + private final PathMatcher pathMatcher = new AntPathMatcher("."); + + @EventListener + public void handEvent(AuthorizingHandleBeforeEvent event) { + + if (allows.isEmpty() || event.getHandleType() == HandleType.DATA) { + return; + } + AuthorizingContext context = event.getContext(); + + // class full name.method + String path = ClassUtils.getUserClass(context.getParamContext() + .getTarget()) + .getName().concat(".") + .concat(context.getParamContext() + .getMethod().getName()); + + AtomicBoolean allow = new AtomicBoolean(); + for (Map.Entry> entry : allows.entrySet()) { + String dimension = entry.getKey(); + if ("user".equals(dimension)) { + String userId = context.getAuthentication().getUser().getId(); + allow.set(Optional.ofNullable(entry.getValue().get(userId)) + .filter(pattern -> "*".equals(pattern) || pathMatcher.match(pattern, path)) + .isPresent()); + } else { //其他维度 + for (Map.Entry confEntry : entry.getValue().entrySet()) { + context.getAuthentication() + .getDimension(dimension, confEntry.getKey()) + .ifPresent(dim -> { + String pattern = confEntry.getValue(); + allow.set("*".equals(pattern) || pathMatcher.match(confEntry.getValue(), path)); + }); + } + } + if (allow.get()) { + event.setAllow(true); + return; + } + } + + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DataAccessHandlerContext.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DataAccessHandlerContext.java new file mode 100644 index 000000000..7ecbf9dba --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DataAccessHandlerContext.java @@ -0,0 +1,63 @@ +package org.hswebframework.web.authorization.basic.handler.access; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.utils.ClassUtils; +import org.hswebframework.web.aop.MethodInterceptorContext; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; +import org.hswebframework.web.authorization.define.AuthorizingContext; +import org.hswebframework.web.crud.web.reactive.*; + +import java.util.List; + +@Getter +@Setter +public class DataAccessHandlerContext { + + private Class entityType; + + private ReactiveRepository repository; + + private Authentication authentication; + + private List dimensions; + + private MethodInterceptorContext paramContext; + + private AuthorizeDefinition definition; + + public static DataAccessHandlerContext of(AuthorizingContext context, String type) { + DataAccessHandlerContext requestContext = new DataAccessHandlerContext(); + Authentication authentication = context.getAuthentication(); + requestContext.setDimensions(authentication.getDimensions(type)); + requestContext.setAuthentication(context.getAuthentication()); + requestContext.setParamContext(context.getParamContext()); + requestContext.setDefinition(context.getDefinition()); + Object target = context.getParamContext().getTarget(); + Class entityType = ClassUtils.getGenericType(org.springframework.util.ClassUtils.getUserClass(target)); + if (entityType != Object.class) { + requestContext.setEntityType(entityType); + } + + if (target instanceof ReactiveQueryController) { + requestContext.setRepository(((ReactiveQueryController) target).getRepository()); + } else if (target instanceof ReactiveSaveController) { + requestContext.setRepository(((ReactiveSaveController) target).getRepository()); + } else if (target instanceof ReactiveDeleteController) { + requestContext.setRepository(((ReactiveDeleteController) target).getRepository()); + } else if (target instanceof ReactiveServiceQueryController) { + requestContext.setRepository(((ReactiveServiceQueryController) target).getService().getRepository()); + } else if (target instanceof ReactiveServiceSaveController) { + requestContext.setRepository(((ReactiveServiceSaveController) target).getService().getRepository()); + } else if (target instanceof ReactiveServiceDeleteController) { + requestContext.setRepository(((ReactiveServiceDeleteController) target).getService().getRepository()); + } + // TODO: 2019-11-18 not reactive implements + + return requestContext; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DefaultDataAccessController.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DefaultDataAccessController.java new file mode 100644 index 000000000..5c67912c5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DefaultDataAccessController.java @@ -0,0 +1,59 @@ +package org.hswebframework.web.authorization.basic.handler.access; + +import org.hswebframework.web.authorization.access.DataAccessConfig; +import org.hswebframework.web.authorization.access.DataAccessController; +import org.hswebframework.web.authorization.access.DataAccessHandler; +import org.hswebframework.web.authorization.define.AuthorizingContext; + +import java.util.LinkedList; +import java.util.List; + +/** + * 默认的行级权限控制.通过获取DataAccessHandler进行实际处理 + * + * @author zhouhao + * @see DataAccessHandler + * @since 3.0 + */ +public final class DefaultDataAccessController implements DataAccessController { + + private DataAccessController parent; + + private List handlers = new LinkedList<>(); + + public DefaultDataAccessController() { + this(null); + } + + public DefaultDataAccessController(DataAccessController parent) { + if (parent == this) { + throw new UnsupportedOperationException(); + } + this.parent = parent; + addHandler(new FieldFilterDataAccessHandler()) + .addHandler(new DimensionDataAccessHandler()); + } + + @Override + public boolean doAccess(DataAccessConfig access, AuthorizingContext context) { + if (parent != null) { + parent.doAccess(access, context); + } + return handlers.stream() + .filter(handler -> handler.isSupport(access)) + .allMatch(handler -> handler.handle(access, context)); + } + + public DefaultDataAccessController addHandler(DataAccessHandler handler) { + handlers.add(handler); + return this; + } + + public void setHandlers(List handlers) { + this.handlers = handlers; + } + + public List getHandlers() { + return handlers; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DimensionDataAccessHandler.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DimensionDataAccessHandler.java new file mode 100644 index 000000000..09aa2ed85 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/DimensionDataAccessHandler.java @@ -0,0 +1,440 @@ +package org.hswebframework.web.authorization.basic.handler.access; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.ezorm.core.param.Param; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.web.api.crud.entity.Entity; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Dimension; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.access.DataAccessConfig; +import org.hswebframework.web.authorization.access.DataAccessHandler; +import org.hswebframework.web.authorization.annotation.DimensionDataAccess; +import org.hswebframework.web.authorization.define.AuthorizingContext; +import org.hswebframework.web.authorization.define.Phased; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.simple.DimensionDataAccessConfig; +import org.hswebframework.web.bean.FastBeanCopier; +import org.reactivestreams.Publisher; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +public class DimensionDataAccessHandler implements DataAccessHandler { + @Override + public boolean isSupport(DataAccessConfig access) { + return access instanceof DimensionDataAccessConfig; + } + + @Override + public boolean handle(DataAccessConfig access, AuthorizingContext context) { + DimensionDataAccessConfig config = ((DimensionDataAccessConfig) access); + DataAccessHandlerContext requestContext = DataAccessHandlerContext.of(context, config.getScopeType()); + if (!checkSupported(config, requestContext)) { + return false; + } + switch (access.getAction()) { + case Permission.ACTION_QUERY: + case Permission.ACTION_GET: + return doHandleQuery(config, requestContext); + case Permission.ACTION_ADD: + case Permission.ACTION_SAVE: + case Permission.ACTION_UPDATE: + return doHandleUpdate(config, requestContext); + case Permission.ACTION_DELETE: + return doHandleDelete(config, requestContext); + default: + if (log.isDebugEnabled()) { + log.debug("data access [{}] not support for {}", config.getType().getId(), access.getAction()); + } + return true; + } + + } + + @SneakyThrows + protected String getProperty(DimensionDataAccessConfig cfg, + DataAccessHandlerContext ct) { + return Optional.ofNullable( + getMappingInfo(ct).get(cfg.getScopeType())) + .map(MappingInfo::getProperty) + .orElseGet(() -> { + log.warn("{} not supported dimension data access", ct.getParamContext().getMethod()); + return null; + }); + } + + protected boolean checkSupported(DimensionDataAccessConfig cfg, DataAccessHandlerContext ctx) { + Authentication authentication = ctx.getAuthentication(); + + /* + DataAccessHelper.assert() + */ + if (CollectionUtils.isEmpty(ctx.getDimensions())) { + log.warn("user:[{}] dimension not setup", authentication.getUser().getId()); + return false; + } + + if (!getMappingInfo(ctx).containsKey(cfg.getScopeType())) { + log.warn("{} not supported dimension data access.see annotation: @DimensionDataAccess", ctx.getParamContext().getMethod()); + return false; + } + + return true; + } + + protected boolean doHandleDelete(DimensionDataAccessConfig cfg, + DataAccessHandlerContext context) { + + + // TODO: 2019-11-18 + return doHandleUpdate(cfg, context); + + } + + @SuppressWarnings("all") + protected Object handleById(DimensionDataAccessConfig config, + DataAccessHandlerContext context, + MappingInfo mappingInfo, + Object id) { + + if (id instanceof Param || id instanceof Entity) { + + applyQueryParam(config, context, id); + return id; + } + + List dimensions = context.getDimensions(); + + Set scope = CollectionUtils.isNotEmpty(config.getScope()) ? + config.getScope() : + dimensions + .stream() + .map(Dimension::getId) + .collect(Collectors.toSet()); + + Function, Mono> reactiveCheck = obj -> context + .getRepository() + .findById(obj) + .doOnNext(r -> { + Object val = FastBeanCopier.copy(r, new HashMap<>(), FastBeanCopier.include(mappingInfo.getProperty())) + .get(mappingInfo.getProperty()); + if (!StringUtils.isEmpty(val) + && !scope.contains(val)) { + throw new AccessDenyException(); + } + }) + .then(); + if (id instanceof Publisher) { + if (id instanceof Mono) { + return ((Mono) id) + .flatMap(r -> { + if (r instanceof Param) { + applyQueryParam(config, context, r); + return Mono.just(r); + } + return reactiveCheck.apply(r instanceof Collection ? ((Collection) r) : Collections.singleton(r)); + + }) + .then((Mono) id); + } + if (id instanceof Flux) { + return ((Flux) id) + .filter(v -> { + if (v instanceof Param) { + applyQueryParam(config, context, v); + return false; + } + return true; + }) + .collectList() + .flatMap(reactiveCheck) + .thenMany((Flux) id); + } + } + Collection idVal = id instanceof Collection ? ((Collection) id) : Collections.singleton(id); + + Object result = context.getParamContext().getInvokeResult(); + if (result instanceof Mono) { + context.getParamContext() + .setInvokeResult(reactiveCheck.apply(idVal).then(((Mono) result))); + + } else if (result instanceof Flux) { + context.getParamContext() + .setInvokeResult(reactiveCheck.apply(idVal).thenMany(((Flux) result))); + } else { + // TODO: 2019-11-19 非响应式处理 + log.warn("unsupported handle data access by id :{}", context.getParamContext().getMethod()); + } + return id; + } + + protected boolean doHandleUpdate(DimensionDataAccessConfig cfg, + DataAccessHandlerContext context) { + MappingInfo info = getMappingInfo(context).get(cfg.getScopeType()); + if (info != null) { + if (info.idParamIndex != -1) { + Object param = context.getParamContext().getArguments()[info.idParamIndex]; + context.getParamContext().getArguments()[info.idParamIndex] = handleById(cfg, context, info, param); + return true; + } + } else { + return true; + } + + boolean reactive = context.getParamContext() + .handleReactiveArguments(publisher -> { + if (publisher instanceof Mono) { + return Mono.from(publisher) + .flatMap(payload -> applyReactiveUpdatePayload(cfg, info, Collections.singleton(payload), context) + .thenReturn(payload)); + } + if (publisher instanceof Flux) { + return Flux.from(publisher) + .collectList() + .flatMapMany(list -> + applyReactiveUpdatePayload(cfg, info, list, context) + .flatMapIterable(v -> list)); + } + + return publisher; + }); + + if (!reactive) { + applyUpdatePayload(cfg, info, Arrays + .stream(context.getParamContext().getArguments()) + .flatMap(obj -> { + if (obj instanceof Collection) { + return ((Collection) obj).stream(); + } + return Stream.of(obj); + }) + .filter(Entity.class::isInstance) + .collect(Collectors.toSet()), context); + + return true; + } + return true; + + } + + protected void applyUpdatePayload(DimensionDataAccessConfig config, + MappingInfo mappingInfo, + Collection payloads, + DataAccessHandlerContext context) { + List dimensions = context.getDimensions(); + + Set scope = CollectionUtils.isNotEmpty(config.getScope()) ? + config.getScope() : + dimensions + .stream() + .map(Dimension::getId) + .collect(Collectors.toSet()); + + for (Object payload : payloads) { + if (!(payload instanceof Entity)) { + continue; + } + if (payload instanceof Param) { + applyQueryParam(config, context, ((Param) payload)); + continue; + } + String property = mappingInfo.getProperty(); + Map map = FastBeanCopier.copy(payload, new HashMap<>(), FastBeanCopier.include(property)); + Object value = map.get(property); + if (StringUtils.isEmpty(value)) { + if (dimensions.size() == 1) { + map.put(property, dimensions.get(0).getId()); + FastBeanCopier.copy(map, payload, property); + } + continue; + } + if (CollectionUtils.isNotEmpty(scope)) { + if (!scope.contains(value)) { + throw new AccessDenyException(); + } + } + } + } + + protected Mono applyReactiveUpdatePayload(DimensionDataAccessConfig config, + MappingInfo info, + Collection payloads, + DataAccessHandlerContext context) { + + return Mono.fromRunnable(() -> applyUpdatePayload(config, info, payloads, context)); + } + + protected boolean hasAccessByProperty(Set scope, String property, Object payload) { + Map values = FastBeanCopier.copy(payload, new HashMap<>(), FastBeanCopier.include(property)); + Object val = values.get(property); + return val == null || scope.contains(val); + } + + @SuppressWarnings("all") + protected boolean doHandleQuery(DimensionDataAccessConfig cfg, DataAccessHandlerContext context) { + MappingInfo mappingInfo = getMappingInfo(context).get(cfg.getScopeType()); + + //根据结果控制 + if (context.getDefinition().getResources().getPhased() == Phased.after) { + Object result = context.getParamContext().getInvokeResult(); + Set scope = CollectionUtils.isNotEmpty(cfg.getScope()) ? + cfg.getScope() : + context.getDimensions() + .stream() + .map(Dimension::getId) + .collect(Collectors.toSet()); + String property = mappingInfo.getProperty(); + + if (result instanceof Mono) { + context.getParamContext() + .setInvokeResult(((Mono) result). + filter(data -> hasAccessByProperty(scope, property, data))); + return true; + } else if (result instanceof Flux) { + context.getParamContext() + .setInvokeResult(((Flux) result). + filter(data -> hasAccessByProperty(scope, property, data))); + return true; + } + return hasAccessByProperty(scope, property, result); + } + //根据id控制 + if (mappingInfo.getIdParamIndex() >= 0) { + Object param = context.getParamContext().getArguments()[mappingInfo.idParamIndex]; + context.getParamContext().getArguments()[mappingInfo.idParamIndex] = handleById(cfg, context, mappingInfo, param); + return true; + } + + //根据查询条件控制 + boolean reactive = context.getParamContext().handleReactiveArguments(publisher -> { + if (publisher instanceof Mono) { + return Mono + .from(publisher) + .flatMap(param -> this + .applyReactiveQueryParam(cfg, context, param) + .thenReturn(param)); + } + + return publisher; + }); + + if (!reactive) { + Object[] args = context.getParamContext().getArguments(); + this.applyQueryParam(cfg, context, args); + } + return true; + } + + protected String getTermType(DimensionDataAccessConfig cfg) { + return "in"; + } + + protected void applyQueryParam(DimensionDataAccessConfig cfg, + DataAccessHandlerContext requestContext, + Param param) { + Set scope = CollectionUtils.isNotEmpty(cfg.getScope()) ? + cfg.getScope() : + requestContext.getDimensions() + .stream() + .map(Dimension::getId) + .collect(Collectors.toSet()); + + QueryParamEntity entity = new QueryParamEntity(); + entity.setTerms(new ArrayList<>(param.getTerms())); + entity.toNestQuery(query -> + query.where( + getProperty(cfg, requestContext), + getTermType(cfg), + scope)); + param.setTerms(entity.getTerms()); + } + + protected void applyQueryParam(DimensionDataAccessConfig cfg, + DataAccessHandlerContext requestContext, + Object... params) { + for (Object param : params) { + if (param instanceof QueryParam) { + applyQueryParam(cfg, requestContext, (QueryParam) param); + } + } + } + + protected Mono applyReactiveQueryParam(DimensionDataAccessConfig cfg, + DataAccessHandlerContext requestContext, + Object... param) { + + + return Mono.fromRunnable(() -> applyQueryParam(cfg, requestContext, param)); + } + + private Map> cache = new ConcurrentHashMap<>(); + + + public Map getMappingInfo(DataAccessHandlerContext context) { + return getMappingInfo(ClassUtils.getUserClass(context.getParamContext().getTarget()), context.getParamContext().getMethod()); + + } + + private Set> ann = new HashSet<>(Arrays.asList(DimensionDataAccess.class, DimensionDataAccess.Mapping.class)); + + + private Map getMappingInfo(Class target, Method method) { + + return cache.computeIfAbsent(method, m -> { + Set methodAnnotation = AnnotatedElementUtils.findAllMergedAnnotations(method, ann); + Set classAnnotation = AnnotatedElementUtils.findAllMergedAnnotations(target, ann); + + + List all = new ArrayList<>(classAnnotation); + all.addAll(methodAnnotation); + if (CollectionUtils.isEmpty(all)) { + return Collections.emptyMap(); + } + Map mappingInfoMap = new HashMap<>(); + for (Annotation annotation : all) { + if (annotation instanceof DimensionDataAccess) { + for (DimensionDataAccess.Mapping mapping : ((DimensionDataAccess) annotation).mapping()) { + mappingInfoMap.put(mapping.dimensionType(), MappingInfo.of(mapping)); + } + } + if (annotation instanceof DimensionDataAccess.Mapping) { + mappingInfoMap.put(((DimensionDataAccess.Mapping) annotation).dimensionType(), MappingInfo.of(((DimensionDataAccess.Mapping) annotation))); + } + } + return mappingInfoMap; + }); + } + + @Getter + @Setter + @AllArgsConstructor + static class MappingInfo { + String dimension; + + String property; + + int idParamIndex; + + static MappingInfo of(DimensionDataAccess.Mapping mapping) { + return new MappingInfo(mapping.dimensionType(), mapping.property(), mapping.idParamIndex()); + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/FieldFilterDataAccessHandler.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/FieldFilterDataAccessHandler.java new file mode 100644 index 000000000..736dd60ac --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/FieldFilterDataAccessHandler.java @@ -0,0 +1,167 @@ +package org.hswebframework.web.authorization.basic.handler.access; + +import org.apache.commons.beanutils.BeanUtilsBean; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.access.DataAccessConfig; +import org.hswebframework.web.authorization.access.DataAccessHandler; +import org.hswebframework.web.authorization.access.FieldFilterDataAccessConfig; +import org.hswebframework.web.authorization.define.AuthorizingContext; +import org.hswebframework.web.authorization.define.Phased; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collection; +import java.util.Set; + +/** + * 数据权限字段过滤处理,目前仅支持deny. {@link DataAccessConfig.DefaultType#DENY_FIELDS} + * + * @author zhouhao + */ +public class FieldFilterDataAccessHandler implements DataAccessHandler { + private Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Override + public boolean isSupport(DataAccessConfig access) { + return access instanceof FieldFilterDataAccessConfig; + } + + @Override + public boolean handle(DataAccessConfig access, AuthorizingContext context) { + FieldFilterDataAccessConfig filterDataAccessConfig = ((FieldFilterDataAccessConfig) access); + + switch (access.getAction()) { + case Permission.ACTION_QUERY: + case Permission.ACTION_GET: + return doQueryAccess(filterDataAccessConfig, context); + case Permission.ACTION_ADD: + case Permission.ACTION_SAVE: + case Permission.ACTION_UPDATE: + return doUpdateAccess(filterDataAccessConfig, context); + default: + if (logger.isDebugEnabled()) { + logger.debug("field filter not support for {}", access.getAction()); + } + return true; + } + } + + protected void applyUpdateParam(FieldFilterDataAccessConfig config, Object... parameter) { + + for (Object data : parameter) { + for (String field : config.getFields()) { + try { + //设置值为null,跳过修改 + BeanUtilsBean.getInstance() + .getPropertyUtils() + .setProperty(data, field, null); + } catch (Exception e) { + logger.warn("can't set {} null", field, e); + } + } + } + } + + /** + * @param accesses 不可操作的字段 + * @param params 参数上下文 + * @return true + * @see BeanUtilsBean + * @see org.apache.commons.beanutils.PropertyUtilsBean + */ + protected boolean doUpdateAccess(FieldFilterDataAccessConfig accesses, AuthorizingContext params) { + + boolean reactive = params.getParamContext().handleReactiveArguments(publisher -> { + if (publisher instanceof Mono) { + return Mono.from(publisher) + .doOnNext(data -> applyUpdateParam(accesses, data)); + + } + if (publisher instanceof Flux) { + return Flux.from(publisher) + .doOnNext(data -> applyUpdateParam(accesses, data)); + + } + return publisher; + }); + if (reactive) { + return true; + } + + applyUpdateParam(accesses, params.getParamContext().getArguments()); + return true; + } + + @SuppressWarnings("all") + protected void applyQueryParam(FieldFilterDataAccessConfig config, Object param) { + if (param instanceof QueryParam) { + Set denyFields = config.getFields(); + ((QueryParam) param).excludes(denyFields.toArray(new String[0])); + return; + } + + Object r = InvokeResultUtils.convertRealResult(param); + if (r instanceof Collection) { + ((Collection) r).forEach(o -> setObjectPropertyNull(o, config.getFields())); + } else { + setObjectPropertyNull(r, config.getFields()); + } + + } + + @SuppressWarnings("all") + protected boolean doQueryAccess(FieldFilterDataAccessConfig access, AuthorizingContext context) { + if (context.getDefinition().getResources().getPhased() == Phased.before) { + + boolean reactive = context + .getParamContext() + .handleReactiveArguments(publisher -> { + if (publisher instanceof Mono) { + return Mono.from(publisher) + .doOnNext(param -> { + applyQueryParam(access, param); + }); + } + return publisher; + }); + + if (reactive) { + return true; + } + + for (Object argument : context.getParamContext().getArguments()) { + applyQueryParam(access, argument); + } + } else { + if (context.getParamContext().getInvokeResult() instanceof Publisher) { + context.getParamContext().setInvokeResult( + Flux.from((Publisher) context.getParamContext().getInvokeResult()) + .doOnNext(result -> { + applyQueryParam(access, result); + }) + ); + + return true; + } + applyQueryParam(access, context.getParamContext().getInvokeResult()); + } + return true; + } + + protected void setObjectPropertyNull(Object obj, Set fields) { + if (null == obj) { + return; + } + for (String field : fields) { + try { + BeanUtilsBean.getInstance().getPropertyUtils().setProperty(obj, field, null); + } catch (Exception ignore) { + + } + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/InvokeResultUtils.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/InvokeResultUtils.java new file mode 100644 index 000000000..11a292a54 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/handler/access/InvokeResultUtils.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.authorization.basic.handler.access; + +import org.springframework.http.ResponseEntity; + +public class InvokeResultUtils { + public static Object convertRealResult(Object result) { + if (result instanceof ResponseEntity) { + result = ((ResponseEntity) result).getBody(); + } +// if (result instanceof ResponseMessage) { +// result = ((ResponseMessage) result).getResult(); +// } +// if (result instanceof PagerResult) { +// result = ((PagerResult) result).getData(); +// } + return result; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java new file mode 100644 index 000000000..c76076d5f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java @@ -0,0 +1,54 @@ +package org.hswebframework.web.authorization.basic.twofactor; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.User; +import org.hswebframework.web.authorization.annotation.TwoFactor; +import org.hswebframework.web.authorization.exception.NeedTwoFactorException; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidator; +import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorManager; +import org.springframework.util.StringUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * @author zhouhao + * @since 3.0.4 + */ +@AllArgsConstructor +public class TwoFactorHandlerInterceptorAdapter extends HandlerInterceptorAdapter { + + private final TwoFactorValidatorManager validatorManager; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (handler instanceof HandlerMethod) { + HandlerMethod method = ((HandlerMethod) handler); + TwoFactor factor = method.getMethodAnnotation(TwoFactor.class); + if (factor == null || factor.ignore()) { + return true; + } + String userId = Authentication.current() + .map(Authentication::getUser) + .map(User::getId) + .orElse(null); + TwoFactorValidator validator = validatorManager.getValidator(userId, factor.value(), factor.provider()); + if (!validator.expired()) { + return true; + } + String code = request.getParameter(factor.parameter()); + if (code == null) { + code = request.getHeader(factor.parameter()); + } + if (StringUtils.isEmpty(code)) { + throw new NeedTwoFactorException("validation.need_two_factor_verify", factor.provider()); + } else if (!validator.verify(code, factor.timeout())) { + throw new NeedTwoFactorException(factor.message(), factor.provider()); + } + } + return super.preHandle(request, response, handler); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java new file mode 100644 index 000000000..8ac82b20b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java @@ -0,0 +1,144 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.authorization.basic.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.SneakyThrows; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.ReactiveAuthenticationHolder; +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.events.AuthorizationBeforeEvent; +import org.hswebframework.web.authorization.events.AuthorizationDecodeEvent; +import org.hswebframework.web.authorization.events.AuthorizationFailedEvent; +import org.hswebframework.web.authorization.events.AuthorizationSuccessEvent; +import org.hswebframework.web.authorization.exception.AuthenticationException; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.authorization.simple.PlainTextUsernamePasswordAuthenticationRequest; +import org.hswebframework.web.logging.AccessLogger; +import org.hswebframework.web.logging.AccessLoggerInfo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.function.Function; + +/** + * @author zhouhao + */ +@RestController +@RequestMapping("${hsweb.web.mappings.authorize:authorize}") +@Tag(name = "授权接口") +public class AuthorizationController { + + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Autowired + private ReactiveAuthenticationManager authenticationManager; + + @GetMapping("/me") + @Authorize + @Operation(summary = "当前登录用户权限信息") + public Mono me() { + return Authentication.currentReactive() + .switchIfEmpty(Mono.error(UnAuthorizedException::new)); + } + + @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) + @Authorize(ignore = true) + @AccessLogger(ignoreParameter = {"parameter"}) + @Operation(summary = "登录", description = "必要参数:username,password.根据配置不同,其他参数也不同,如:验证码等.") + public Mono> authorizeByJson(@Parameter(example = "{\"username\":\"admin\",\"password\":\"admin\"}") + @RequestBody Mono> parameter) { + return doLogin(parameter); + } + + /** + * + */ + @SneakyThrows + private Mono> doLogin(Mono> parameter) { + + return parameter.flatMap(parameters -> { + String username_ = String.valueOf(parameters.getOrDefault("username", "")); + String password_ = String.valueOf(parameters.getOrDefault("password", "")); + + Assert.hasLength(username_, "validation.username_must_not_be_empty"); + Assert.hasLength(password_, "validation.password_must_not_be_empty"); + + Function parameterGetter = parameters::get; + return Mono + .defer(() -> { + AuthorizationDecodeEvent decodeEvent = new AuthorizationDecodeEvent(username_, password_, parameterGetter); + return decodeEvent + .publish(eventPublisher) + .then(Mono.defer(() -> { + String username = decodeEvent.getUsername(); + String password = decodeEvent.getPassword(); + AuthorizationBeforeEvent beforeEvent = new AuthorizationBeforeEvent(username, password, parameterGetter); + return beforeEvent + .publish(eventPublisher) + .then(Mono.defer(() -> doAuthorize(beforeEvent) + .flatMap(auth -> { + //触发授权成功事件 + AuthorizationSuccessEvent event = new AuthorizationSuccessEvent(auth, parameterGetter); + event.getResult().put("userId", auth.getUser().getId()); + return event + .publish(eventPublisher) + .then(Mono.fromCallable(event::getResult)); + }))); + })); + }) + .onErrorResume(err -> { + AuthorizationFailedEvent failedEvent = new AuthorizationFailedEvent(username_, password_, parameterGetter); + failedEvent.setException(err); + return failedEvent + .publish(eventPublisher) + .then(Mono.error(failedEvent::getException)); + }); + }); + } + + private Mono doAuthorize(AuthorizationBeforeEvent event) { + Mono authenticationMono; + if (event.isAuthorized()) { + if (event.getAuthentication() != null) { + authenticationMono = Mono.just(event.getAuthentication()); + } else { + authenticationMono = ReactiveAuthenticationHolder + .get(event.getUserId()) + .switchIfEmpty(Mono.error(() -> new AuthenticationException.NoStackTrace(AuthenticationException.USER_DISABLED))); + } + } else { + authenticationMono = authenticationManager + .authenticate(Mono.just(new PlainTextUsernamePasswordAuthenticationRequest(event.getUsername(), event.getPassword()))) + .switchIfEmpty(Mono.error(() -> new AuthenticationException.NoStackTrace(AuthenticationException.ILLEGAL_PASSWORD))); + } + return authenticationMono; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizedToken.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizedToken.java new file mode 100644 index 000000000..af9db38e3 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizedToken.java @@ -0,0 +1,35 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.ParsedToken; + +/** + * 已完成认证的令牌,如果返回此令牌,将直接使用{@link AuthorizedToken#getUserId()}来绑定用户信息 + * + * @author zhouhao + */ +public interface AuthorizedToken extends ParsedToken { + + /** + * @return 令牌绑定的用户id + */ + String getUserId(); + + /** + * 获取认证权限信息 + * + * @return Authentication + * @since 4.0.17 + */ + default Authentication getAuthentication() { + return null; + } + + /** + * @return 令牌有效期,单位毫秒,-1为长期有效 + */ + default long getMaxInactiveInterval() { + return -1; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/BearerTokenParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/BearerTokenParser.java new file mode 100644 index 000000000..6a848b14a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/BearerTokenParser.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.token.ParsedToken; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public class BearerTokenParser implements ReactiveUserTokenParser { + @Override + public Mono parseToken(ServerWebExchange exchange) { + + String token = exchange + .getRequest() + .getHeaders() + .getFirst(HttpHeaders.AUTHORIZATION); + + if (token != null && token.startsWith("Bearer ")) { + return Mono.just(ParsedToken.ofBearer(token.substring(7))); + } + return Mono.empty(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/DefaultUserTokenGenPar.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/DefaultUserTokenGenPar.java new file mode 100644 index 000000000..685498503 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/DefaultUserTokenGenPar.java @@ -0,0 +1,70 @@ +package org.hswebframework.web.authorization.basic.web; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.id.IDGenerator; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Getter +@Setter +public class DefaultUserTokenGenPar implements ReactiveUserTokenGenerator, ReactiveUserTokenParser { + + private long timeout = TimeUnit.MINUTES.toMillis(30); + + @SuppressWarnings("all") + private String headerName = "X-Access-Token"; + + private String parameterName = ":X_Access_Token"; + + @Override + public String getTokenType() { + return "default"; + } + + @Override + public GeneratedToken generate(Authentication authentication) { + String token = IDGenerator.MD5.generate(); + + return new GeneratedToken() { + @Override + public Map getResponse() { + return Collections.singletonMap("expires", timeout); + } + + @Override + public String getToken() { + return token; + } + + @Override + public String getType() { + return getTokenType(); + } + + @Override + public long getTimeout() { + return timeout; + } + }; + } + + @Override + public Mono parseToken(ServerWebExchange exchange) { + String token = Optional.ofNullable(exchange.getRequest() + .getHeaders() + .getFirst(headerName)) + .orElseGet(() -> exchange.getRequest().getQueryParams().getFirst(parameterName)); + if (token == null) { + return Mono.empty(); + } + return Mono.just(ParsedToken.of(getTokenType(),token,(_header,_token)->_header.set(headerName,_token))); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/GeneratedToken.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/GeneratedToken.java new file mode 100644 index 000000000..8f8970650 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/GeneratedToken.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.authorization.basic.web; + +import java.io.Serializable; +import java.util.Map; + +/** + * 生成好的令牌信息 + * + * @author zhouhao + */ +public interface GeneratedToken extends Serializable { + /** + * 要响应的数据,可自定义想要的数据给调用者 + * + * @return {@link Map} + */ + Map getResponse(); + + /** + * @return 令牌字符串, 令牌具有唯一性, 不可逆, 不包含敏感信息 + */ + String getToken(); + + /** + * @return 令牌类型 + */ + String getType(); + + /** + * @return 令牌有效期(单位毫秒) + */ + long getTimeout(); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenController.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenController.java new file mode 100644 index 000000000..916f9c46e --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenController.java @@ -0,0 +1,165 @@ +package org.hswebframework.web.authorization.basic.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.QueryAction; +import org.hswebframework.web.authorization.annotation.Resource; +import org.hswebframework.web.authorization.annotation.SaveAction; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.authorization.token.TokenState; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.hswebframework.web.context.ContextKey; +import org.hswebframework.web.context.ContextUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping +@Authorize +@Resource(id = "user-token", name = "用户令牌信息管理") +@Tag(name = "用户令牌管理") +public class ReactiveUserTokenController { + private UserTokenManager userTokenManager; + + private ReactiveAuthenticationManager authenticationManager; + + @Autowired + @Lazy + public void setUserTokenManager(UserTokenManager userTokenManager) { + this.userTokenManager = userTokenManager; + } + + @Autowired + @Lazy + public void setAuthenticationManager(ReactiveAuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + } + + @GetMapping("/user-token/reset") + @Authorize(merge = false) + @Operation(summary = "重置当前用户的令牌") + public Mono resetToken() { + return Mono + .deferContextual(ctx -> Mono.justOrEmpty(ctx.getOrEmpty(ParsedToken.class))) + .flatMap(token -> userTokenManager.signOutByToken(token.getToken())) + .thenReturn(true); + } + + @PutMapping("/user-token/check") + @Operation(summary = "检查所有已过期的token并移除") + @SaveAction + public Mono checkExpiredToken() { + return userTokenManager + .checkExpiredToken() + .thenReturn(true); + } + + @GetMapping("/user-token/token/{token}") + @Operation(summary = "根据token获取令牌信息") + @QueryAction + public Mono getByToken(@PathVariable String token) { + return userTokenManager.getByToken(token); + } + + @GetMapping("/user-token/user/{userId}") + @Operation(summary = "根据用户ID获取全部令牌信息") + @QueryAction + public Flux getByUserId(@PathVariable String userId) { + return userTokenManager.getByUserId(userId); + } + + @GetMapping("/user-token/user/{userId}/logged") + @Operation(summary = "根据用户ID判断用户是否已经登录") + @QueryAction + public Mono userIsLoggedIn(@PathVariable String userId) { + return userTokenManager.userIsLoggedIn(userId); + } + + @GetMapping("/user-token/token/{token}/logged") + @Operation(summary = "根据令牌判断用户是否已经登录") + @QueryAction + public Mono tokenIsLoggedIn(@PathVariable String token) { + return userTokenManager.tokenIsLoggedIn(token); + } + + @GetMapping("/user-token/user/total") + @Operation(summary = "获取当前已经登录的用户数量") + @Authorize(merge = false) + public Mono totalUser() { + return userTokenManager.totalUser(); + } + + @GetMapping("/user-token/token/total") + @Operation(summary = "获取当前已经登录的令牌数量") + @Authorize(merge = false) + public Mono totalToken() { + return userTokenManager.totalToken(); + } + + @GetMapping("/user-token") + @Operation(summary = "获取全部用户令牌信息") + @QueryAction + public Flux allLoggedUser() { + return userTokenManager.allLoggedUser(); + } + + @DeleteMapping("/user-token/user/{userId}") + @Operation(summary = "根据用户id将用户踢下线") + @SaveAction + public Mono signOutByUserId(@PathVariable String userId) { + return userTokenManager.signOutByUserId(userId); + } + + @DeleteMapping("/user-token/token/{token}") + @Operation(summary = "根据令牌将用户踢下线") + @SaveAction + public Mono signOutByToken(@PathVariable String token) { + return userTokenManager.signOutByToken(token); + + } + + @SaveAction + @PutMapping("/user-token/user/{userId}/{state}") + @Operation(summary = "根据用户id更新用户令牌状态") + public Mono changeUserState(@PathVariable String userId, @PathVariable TokenState state) { + + return userTokenManager.changeUserState(userId, state); + } + + @PutMapping("/user-token/token/{token}/{state}") + @Operation(summary = "根据令牌更新用户令牌状态") + @SaveAction + public Mono changeTokenState(@PathVariable String token, @PathVariable TokenState state) { + return userTokenManager.changeTokenState(token, state); + } +// +// @PostMapping("/user-token/{token}/{type}/{userId}/{maxInactiveInterval}") +// @Operation(summary = "将用户设置为登录") +// @SaveAction +// public Mono signIn(@PathVariable String token, @PathVariable String type, @PathVariable String userId, @PathVariable long maxInactiveInterval) { +// return userTokenManager.signIn(token, type, userId, maxInactiveInterval); +// } + + @GetMapping("/user-token/{token}/touch") + @Operation(summary = "更新token有效期") + @SaveAction + public Mono touch(@PathVariable String token) { + return userTokenManager.touch(token); + } + + @GetMapping("/user-auth/{userId}") + @Operation(summary = "根据用户id获取权限信息") + @SaveAction + public Mono userAuthInfo(@PathVariable String userId) { + return authenticationManager.getByUserId(userId); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenGenerator.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenGenerator.java new file mode 100644 index 000000000..039f09542 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenGenerator.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.Authentication; + +public interface ReactiveUserTokenGenerator { + + String getTokenType(); + + GeneratedToken generate(Authentication authentication); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenParser.java new file mode 100644 index 000000000..148faadba --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ReactiveUserTokenParser.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.token.ParsedToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public interface ReactiveUserTokenParser { + Mono parseToken(ServerWebExchange exchange); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ServletUserTokenGenPar.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ServletUserTokenGenPar.java new file mode 100644 index 000000000..597c359ca --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/ServletUserTokenGenPar.java @@ -0,0 +1,66 @@ +package org.hswebframework.web.authorization.basic.web; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.id.IDGenerator; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Getter +@Setter +public class ServletUserTokenGenPar implements UserTokenParser, UserTokenGenerator { + private long timeout = TimeUnit.MINUTES.toMillis(30); + + private String headerName = "X-Access-Token"; + + @Override + public String getSupportTokenType() { + return "default"; + } + + + @Override + public GeneratedToken generate(Authentication authentication) { + String token = IDGenerator.MD5.generate(); + + return new GeneratedToken() { + @Override + public Map getResponse() { + return Collections.singletonMap("expires", timeout); + } + + @Override + public String getToken() { + return token; + } + + @Override + public String getType() { + return getSupportTokenType(); + } + + @Override + public long getTimeout() { + return timeout; + } + }; + } + + @Override + public ParsedToken parseToken(HttpServletRequest request) { + String token = Optional + .ofNullable(request.getHeader(headerName)) + .orElseGet(() -> request.getParameter(":X_Access_Token")); + if (StringUtils.hasText(token)) { + return ParsedToken.of(getSupportTokenType(), token); + } + return null; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/SessionIdUserTokenGenerator.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/SessionIdUserTokenGenerator.java new file mode 100644 index 000000000..aba64e931 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/SessionIdUserTokenGenerator.java @@ -0,0 +1,58 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.utils.WebUtils; + +import javax.servlet.http.HttpServletRequest; +import java.io.Serializable; +import java.util.Collections; +import java.util.Map; + +/** + * @author zhouhao + */ +public class SessionIdUserTokenGenerator implements UserTokenGenerator, Serializable { + + private static final long serialVersionUID = -9197243220777237431L; + + @Override + public String getSupportTokenType() { + return TOKEN_TYPE_SESSION_ID; + } + + @Override + public GeneratedToken generate(Authentication authentication) { + HttpServletRequest request = WebUtils.getHttpServletRequest(); + if (null == request) { + throw new UnsupportedOperationException(); + } + + int timeout = request.getSession().getMaxInactiveInterval() * 1000; + + String sessionId = request.getSession().getId(); + + return new GeneratedToken() { + private static final long serialVersionUID = 3964183451883410929L; + + @Override + public Map getResponse() { + return new java.util.HashMap<>(); + } + + @Override + public String getToken() { + return sessionId; + } + + @Override + public String getType() { + return TOKEN_TYPE_SESSION_ID; + } + + @Override + public long getTimeout() { + return timeout; + } + }; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/SessionIdUserTokenParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/SessionIdUserTokenParser.java new file mode 100644 index 000000000..d08d36996 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/SessionIdUserTokenParser.java @@ -0,0 +1,74 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.springframework.beans.factory.annotation.Autowired; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import static org.hswebframework.web.authorization.basic.web.UserTokenGenerator.TOKEN_TYPE_SESSION_ID; + +/** + * @author zhouhao + */ +public class SessionIdUserTokenParser implements UserTokenParser { + + + protected UserTokenManager userTokenManager; + + @Autowired + public void setUserTokenManager(UserTokenManager userTokenManager) { + this.userTokenManager = userTokenManager; + } + + @Override + public ParsedToken parseToken(HttpServletRequest request) { + + HttpSession session = request.getSession(false); + + if (session != null) { + String sessionId = session.getId(); + UserToken token = userTokenManager.getByToken(sessionId).block(); + long interval = session.getMaxInactiveInterval(); + //当前已登录token已失效但是session未失效 + if (token != null && token.isExpired()) { + String userId = token.getUserId(); + return new AuthorizedToken() { + @Override + public String getUserId() { + return userId; + } + + @Override + public String getToken() { + return sessionId; + } + + @Override + public String getType() { + return TOKEN_TYPE_SESSION_ID; + } + + @Override + public long getMaxInactiveInterval() { + return interval; + } + }; + } + return new ParsedToken() { + @Override + public String getToken() { + return session.getId(); + } + + @Override + public String getType() { + return TOKEN_TYPE_SESSION_ID; + } + }; + } + return null; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserOnSignIn.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserOnSignIn.java new file mode 100644 index 000000000..7143c2a87 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserOnSignIn.java @@ -0,0 +1,77 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.events.AuthorizationEvent; +import org.hswebframework.web.authorization.events.AuthorizationSuccessEvent; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.authorization.token.UserTokenHolder; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.EventListener; + +import java.util.ArrayList; +import java.util.List; + +/** + * 监听授权成功事件,授权成功后,生成token并注册到{@link UserTokenManager} + * + * @author zhouhao + * @see org.springframework.context.ApplicationEvent + * @see AuthorizationEvent + * @see UserTokenManager + * @see UserTokenGenerator + * @since 3.0 + */ +public class UserOnSignIn { + + /** + * 默认到令牌类型 + * + * @see UserToken#getType() + * @see SessionIdUserTokenGenerator#getSupportTokenType() + */ + private String defaultTokenType = "sessionId"; + + /** + * 令牌管理器 + */ + private UserTokenManager userTokenManager; + + private List userTokenGenerators = new ArrayList<>(); + + public UserOnSignIn(UserTokenManager userTokenManager) { + this.userTokenManager = userTokenManager; + } + + public void setDefaultTokenType(String defaultTokenType) { + this.defaultTokenType = defaultTokenType; + } + + @Autowired(required = false) + public void setUserTokenGenerators(List userTokenGenerators) { + this.userTokenGenerators = userTokenGenerators; + } + + @EventListener + public void onApplicationEvent(AuthorizationSuccessEvent event) { + UserToken token = UserTokenHolder.currentToken(); + String tokenType = (String) event.getParameter("token_type").orElse(defaultTokenType); + + if (token != null) { + //先退出已登陆的用户 + event.async(userTokenManager.signOutByToken(token.getToken())); + } + //创建token + GeneratedToken newToken = userTokenGenerators.stream() + .filter(generator -> generator.getSupportTokenType().equals(tokenType)) + .findFirst() + .orElseThrow(() -> new UnsupportedOperationException(tokenType)) + .generate(event.getAuthentication()); + //登入 + event.async(userTokenManager.signIn(newToken.getToken(), newToken.getType(), event.getAuthentication().getUser().getId(), newToken.getTimeout()).then()); + + //响应结果 + event.getResult().putAll(newToken.getResponse()); + + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserOnSignOut.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserOnSignOut.java new file mode 100644 index 000000000..51eaed077 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserOnSignOut.java @@ -0,0 +1,29 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.events.AuthorizationExitEvent; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.authorization.token.UserTokenHolder; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.EventListener; + +/** + * @author zhouhao + */ +public class UserOnSignOut { + private final UserTokenManager userTokenManager; + + public UserOnSignOut(UserTokenManager userTokenManager) { + this.userTokenManager = userTokenManager; + } + + private String geToken() { + UserToken token = UserTokenHolder.currentToken(); + return null != token ? token.getToken() : ""; + } + + @EventListener + public void onApplicationEvent(AuthorizationExitEvent event) { + event.async(userTokenManager.signOutByToken(geToken())); + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenForTypeParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenForTypeParser.java new file mode 100644 index 000000000..c845a75e7 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenForTypeParser.java @@ -0,0 +1,5 @@ +package org.hswebframework.web.authorization.basic.web; + +public interface UserTokenForTypeParser extends UserTokenParser { + String getTokenType(); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenGenerator.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenGenerator.java new file mode 100644 index 000000000..9a49b63c4 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenGenerator.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.Authentication; + +/** + * + * 用户令牌生产器,用于在用户进行授权后生成令牌 + * @author zhouhao + * + */ +public interface UserTokenGenerator { + String TOKEN_TYPE_SESSION_ID = "sessionId"; + + String TOKEN_TYPE_SIMPLE = "simple-token"; + + String getSupportTokenType(); + + GeneratedToken generate(Authentication authentication); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenParser.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenParser.java new file mode 100644 index 000000000..0015d20eb --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenParser.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.token.ParsedToken; + +import javax.servlet.http.HttpServletRequest; + +/** + * 令牌解析器,用于在接受到请求到时候,从请求中获取令牌 + * @author zhouhao + * @see 3.0 + * @see ParsedToken + * @see AuthorizedToken + */ +public interface UserTokenParser { + ParsedToken parseToken(HttpServletRequest request); +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenWebFilter.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenWebFilter.java new file mode 100644 index 000000000..82c9bd134 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/UserTokenWebFilter.java @@ -0,0 +1,104 @@ +package org.hswebframework.web.authorization.basic.web; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.authorization.events.AuthorizationSuccessEvent; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.hswebframework.web.context.ContextUtils; +import org.hswebframework.web.logger.ReactiveLogger; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +@Component +@Slf4j +@Order(1) +public class UserTokenWebFilter implements WebFilter, BeanPostProcessor { + + private final List parsers = new ArrayList<>(); + + private final Map tokenGeneratorMap = new HashMap<>(); + + @Autowired + private UserTokenManager userTokenManager; + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, WebFilterChain chain) { + + return Flux + .fromIterable(parsers) + .flatMap(parser -> parser.parseToken(exchange)) + .next() + .map(token -> chain + .filter(exchange) + .contextWrite(Context.of(ParsedToken.class, token))) + .defaultIfEmpty(chain.filter(exchange)) + .flatMap(Function.identity()) + .contextWrite(ReactiveLogger.start("requestId", exchange.getRequest().getId())); + + } + + @EventListener + public void handleUserSign(AuthorizationSuccessEvent event) { + ReactiveUserTokenGenerator generator = event + .getParameter("tokenType") + .map(tokenGeneratorMap::get) + .orElseGet(() -> tokenGeneratorMap.get("default")); + if (generator != null) { + GeneratedToken token = generator.generate(event.getAuthentication()); + event.getResult().putAll(token.getResponse()); + if (StringUtils.hasText(token.getToken())) { + event.getResult().put("token", token.getToken()); + long expires = event + .getParameter("expires") + .map(String::valueOf) + .map(Long::parseLong) + .orElse(token.getTimeout()); + + event.async( + userTokenManager + .signIn(token.getToken(), token.getType(), event + .getAuthentication() + .getUser() + .getId(), expires) + .doOnNext(t -> { + event.getResult().put("expires", t.getMaxInactiveInterval()); + log.debug("user [{}] sign in", t.getUserId()); + }) + .then()); + } + } + + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof ReactiveUserTokenGenerator) { + ReactiveUserTokenGenerator generator = ((ReactiveUserTokenGenerator) bean); + tokenGeneratorMap.put(generator.getTokenType(), generator); + } + if (bean instanceof ReactiveUserTokenParser) { + parsers.add(((ReactiveUserTokenParser) bean)); + } + return bean; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/WebUserTokenInterceptor.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/WebUserTokenInterceptor.java new file mode 100644 index 000000000..dc882e7e1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/WebUserTokenInterceptor.java @@ -0,0 +1,87 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.basic.aop.AopMethodAuthorizeDefinitionParser; +import org.hswebframework.web.authorization.define.AuthorizeDefinition; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.authorization.token.UserToken; +import org.hswebframework.web.authorization.token.UserTokenHolder; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 用户令牌拦截器,用于拦截用户请求并从中解析用户令牌信息 + * + * @author zhouhao + */ +public class WebUserTokenInterceptor extends HandlerInterceptorAdapter { + + private final UserTokenManager userTokenManager; + + private final List userTokenParser; + + private final AopMethodAuthorizeDefinitionParser parser; + + private final boolean enableBasicAuthorization; + + public WebUserTokenInterceptor(UserTokenManager userTokenManager, + List userTokenParser, + AopMethodAuthorizeDefinitionParser definitionParser) { + this.userTokenManager = userTokenManager; + this.userTokenParser = userTokenParser; + this.parser = definitionParser; + + enableBasicAuthorization = userTokenParser + .stream() + .filter(UserTokenForTypeParser.class::isInstance) + .anyMatch(parser -> "basic".equalsIgnoreCase(((UserTokenForTypeParser) parser).getTokenType())); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + List tokens = userTokenParser + .stream() + .map(parser -> parser.parseToken(request)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + if (tokens.isEmpty()) { + if (enableBasicAuthorization && handler instanceof HandlerMethod) { + HandlerMethod method = ((HandlerMethod) handler); + AuthorizeDefinition definition = parser.parse(method.getBeanType(), method.getMethod()); + if (null != definition) { + response.addHeader("WWW-Authenticate", " Basic realm=\"\""); + } + } + return true; + } + for (ParsedToken parsedToken : tokens) { + UserToken userToken = null; + String token = parsedToken.getToken(); + if (userTokenManager.tokenIsLoggedIn(token).blockOptional().orElse(false)) { + userToken = userTokenManager.getByToken(token).blockOptional().orElse(null); + } + if ((userToken == null || userToken.isExpired()) && parsedToken instanceof AuthorizedToken) { + //先踢出旧token + userTokenManager.signOutByToken(token).subscribe(); + + userToken = userTokenManager + .signIn(parsedToken.getToken(), parsedToken.getType(), ((AuthorizedToken) parsedToken).getUserId(), ((AuthorizedToken) parsedToken) + .getMaxInactiveInterval()) + .block(); + } + if (null != userToken) { + userTokenManager.touch(token).subscribe(); + UserTokenHolder.setCurrent(userToken); + } + } + return true; + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/hsweb-authorization/hsweb-authorization-basic/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000..8ff52ffec --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{ + "properties": [ + { + "name": "hsweb.authorize.auto-parse", + "type": "java.lang.Boolean", + "defaultValue": "false", + "description": "是否自动解析代码中的权限定义信息并触发AuthorizeDefinitionInitializedEvent事件." + } + ] +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/hsweb-authorization/hsweb-authorization-basic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..42849de36 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.hswebframework.web.authorization.basic.configuration.AuthorizingHandlerAutoConfiguration +org.hswebframework.web.authorization.basic.configuration.WebMvcAuthorizingConfiguration \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/AopAuthorizingControllerTest.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/AopAuthorizingControllerTest.java new file mode 100644 index 000000000..5c930e119 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/AopAuthorizingControllerTest.java @@ -0,0 +1,173 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.ezorm.core.CastUtil; +import org.hswebframework.ezorm.core.param.Param; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.web.authorization.*; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.simple.*; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.*; +import java.util.function.Function; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = TestApplication.class) +public class AopAuthorizingControllerTest { + + @Autowired + public TestController testController; + + @Test + public void testAccessDeny() { + + SimpleAuthentication authentication = new SimpleAuthentication(); + + authentication.setUser(SimpleUser.builder().id("test").username("test").build()); +// authentication.setPermissions(Arrays.asList(SimplePermission.builder().id("test").build())); + authentication.setPermissions(Collections.emptyList()); + ReactiveAuthenticationHolder.setSupplier(new ReactiveAuthenticationSupplier() { + @Override + public Mono get(String userId) { + return Mono.empty(); + } + + @Override + public Mono get() { + return Mono.just(authentication); + } + }); + + testController.getUser() + .map(User::getId) + .onErrorReturn(AccessDenyException.class, "403") + .as(StepVerifier::create) + .expectNext("403") + .verifyComplete(); + + testController.getUserAfter() + .map(User::getId) + .onErrorReturn(AccessDenyException.class, "403") + .as(StepVerifier::create) + .expectNext("403") + .verifyComplete(); + } +// +// @Test +// public void testFiledDeny() { +// SimpleAuthentication authentication = new SimpleAuthentication(); +// +// SimpleFieldFilterDataAccessConfig config = new SimpleFieldFilterDataAccessConfig(); +// config.setAction("query"); +// config.setFields(new HashSet<>(Arrays.asList("name"))); +// +// authentication.setUser(SimpleUser.builder().id("test").username("test").build()); +// authentication.setPermissions(Arrays.asList(SimplePermission.builder() +// .actions(Collections.singleton("query")) +// .dataAccesses(Collections.singleton(config)) +// .id("test").build())); +// +// ReactiveAuthenticationHolder.setSupplier(new ReactiveAuthenticationSupplier() { +// @Override +// public Mono get(String userId) { +// return Mono.empty(); +// } +// +// @Override +// public Mono get() { +// return Mono.just(authentication); +// } +// }); +// +// testController.queryUser(new QueryParam()) +// .map(Param::getExcludes) +// .as(StepVerifier::create) +// .expectNextMatches(f -> f.contains("name")) +// .verifyComplete(); +// +// testController.queryUser(Mono.just(new QueryParam())) +// .map(Param::getExcludes) +// .as(StepVerifier::create) +// .expectNextMatches(f -> f.contains("name")) +// .verifyComplete(); +// } +// +// @Test +// public void testDimensionDataAccess() { +// SimpleAuthentication authentication = new SimpleAuthentication(); +// +// DimensionDataAccessConfig config = new DimensionDataAccessConfig(); +// config.setAction("query"); +// config.setScopeType("role"); +// +// DimensionDataAccessConfig config2 = new DimensionDataAccessConfig(); +// config2.setAction("save"); +// config2.setScopeType("role"); +// ReactiveAuthenticationHolder.setSupplier(new ReactiveAuthenticationSupplier() { +// @Override +// public Mono get(String userId) { +// return Mono.empty(); +// } +// +// @Override +// public Mono get() { +// return Mono.just(authentication); +// } +// }); +// +// authentication.setUser(SimpleUser.builder().id("test").username("test").build()); +// authentication.setPermissions(Arrays.asList(SimplePermission.builder() +// .actions(new HashSet<>(Arrays.asList("query", "save"))) +// .dataAccesses(new HashSet<>(Arrays.asList(config, config2))) +// .id("test").build())); +// authentication.setDimensions(Collections.singletonList(Dimension.of("test", "test", DefaultDimensionType.role))); +// +// testController.queryUserByDimension(Mono.just(new QueryParam())) +// .map(Param::getTerms) +// .flatMapIterable(Function.identity()) +// .next() +// .map(Term::getValue) +// .map(CastUtil::>cast) +// .flatMapIterable(Function.identity()) +// .next() +// .as(StepVerifier::create) +// .expectNextMatches("test"::equals) +// .verifyComplete(); +// +// TestEntity testEntity = new TestEntity(); +// testEntity.setRoleId("123"); +// +// testController.save(Mono.just(testEntity)) +// .as(StepVerifier::create) +// .expectError(AccessDenyException.class) +// .verify(); +// +// testController.add(Mono.just(testEntity)) +// .as(StepVerifier::create) +// .expectNextCount(1) +// .verifyComplete(); +// +// testController.update(testEntity.getId(),Mono.just(testEntity)) +// .as(StepVerifier::create) +// .expectError(AccessDenyException.class) +// .verify(); +// +// testEntity = new TestEntity(); +// testEntity.setRoleId("test"); +// +// testController.save(Mono.just(testEntity)) +// .as(StepVerifier::create) +// .expectNextCount(1) +// .verifyComplete(); +// +// +// } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/FluxTestController.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/FluxTestController.java new file mode 100644 index 000000000..26b619ae6 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/FluxTestController.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/test") +public class FluxTestController { + + @GetMapping + public Mono getUser() { + + return Authentication + .currentReactive() + .switchIfEmpty(Mono.error(UnAuthorizedException::new)); + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestApplication.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestApplication.java new file mode 100644 index 000000000..012138c0a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestApplication.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.web.authorization.basic.configuration.EnableAopAuthorize; +import org.hswebframework.web.crud.annotation.EnableEasyormRepository; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; + +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@EnableAopAuthorize +@EnableEasyormRepository("org.hswebframework.web.authorization.basic.aop") +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class,args); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestController.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestController.java new file mode 100644 index 000000000..601790a8b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestController.java @@ -0,0 +1,72 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.User; +import org.hswebframework.web.authorization.annotation.*; +import org.hswebframework.web.authorization.define.Phased; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.crud.web.reactive.ReactiveCrudController; +import org.hswebframework.web.crud.web.reactive.ReactiveQueryController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@Resource(id = "test", name = "测试") +public class TestController implements ReactiveCrudController { + + @QueryAction + public Mono getUser() { + return Authentication.currentReactive() + .switchIfEmpty(Mono.error(new UnAuthorizedException())) + .map(Authentication::getUser); + } + + @QueryAction + public Mono getUserAfter() { + return Authentication.currentReactive() + .switchIfEmpty(Mono.error(new UnAuthorizedException())) + .map(Authentication::getUser); + } + + @QueryAction + @FieldDataAccess + @DimensionDataAccess(ignore = true) + public Mono queryUser(QueryParam queryParam) { + return Mono.just(queryParam); + } + + @QueryAction + @FieldDataAccess + public Mono queryUser(Mono queryParam) { + return queryParam; + } + + @QueryAction + @TestDataAccess + public Mono queryUserByDimension(Mono queryParam) { + return queryParam; + } + + @SaveAction + @TestDataAccess + public Mono save(Mono param) { + return param; + } + + @Override + @TestDataAccess(idParamIndex = 0,phased = Phased.after) + public Mono update(String id, Mono payload) { + return ReactiveCrudController.super.update(id, payload); + } + + @Autowired + ReactiveRepository reactiveRepository; + + @Override + public ReactiveRepository getRepository() { + return reactiveRepository; + } +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestDataAccess.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestDataAccess.java new file mode 100644 index 000000000..79b14502b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestDataAccess.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.authorization.basic.aop; + +import org.hswebframework.web.authorization.annotation.DimensionDataAccess; +import org.hswebframework.web.authorization.define.Phased; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) +@DimensionDataAccess +@DimensionDataAccess.Mapping(dimensionType = "role", property = "roleId") +public @interface TestDataAccess { + + @AliasFor(annotation = DimensionDataAccess.Mapping.class) + int idParamIndex() default -1; + + @AliasFor(annotation = DimensionDataAccess.class) + Phased phased() default Phased.before; + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestEntity.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestEntity.java new file mode 100644 index 000000000..c4038b57f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/aop/TestEntity.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.authorization.basic.aop; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.api.crud.entity.GenericEntity; +import reactor.core.publisher.Mono; + +import javax.persistence.Column; +import javax.persistence.Table; + +@Getter +@Setter +@Table(name = "test_entity") +public class TestEntity extends GenericEntity { + + @Column + private String roleId; + + +} diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinitionTest.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinitionTest.java new file mode 100644 index 000000000..43d562167 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinitionTest.java @@ -0,0 +1,62 @@ +package org.hswebframework.web.authorization.basic.define; + +import lombok.SneakyThrows; +import org.hswebframework.web.authorization.annotation.*; +import org.hswebframework.web.authorization.define.AopAuthorizeDefinition; +import org.hswebframework.web.authorization.define.ResourceDefinition; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; + +public class DefaultBasicAuthorizeDefinitionTest { + + + @Test + @SneakyThrows + public void testCustomAnn() { + AopAuthorizeDefinition definition = + DefaultBasicAuthorizeDefinition.from(TestController.class, TestController.class.getMethod("test")); + + ResourceDefinition resource = definition.getResources() + .getResource("test").orElseThrow(NullPointerException::new); + + Assert.assertNotNull(resource); + + Assert.assertTrue(resource.hasAction(Arrays.asList("add"))); + + Assert.assertTrue(resource.getAction("add") + .map(act->act.getDataAccess().getType("user_own_data")) + .isPresent()); + } + + @Test + @SneakyThrows + public void testNoMerge() { + AopAuthorizeDefinition definition = + DefaultBasicAuthorizeDefinition.from(TestController.class, TestController.class.getMethod("noMerge")); + Assert.assertTrue(definition.getResources().isEmpty()); + } + + + @Resource(id = "test", name = "测试") + public class TestController implements GenericController { + + @Authorize(merge = false) + public void noMerge(){ + + } + + } + + public interface GenericController { + + @CreateAction + @UserOwnData + default void test(){ + + } + } + + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/web/CompositeReactiveAuthenticationManagerTest.java b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/web/CompositeReactiveAuthenticationManagerTest.java new file mode 100644 index 000000000..c3d3df3af --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/java/org/hswebframework/web/authorization/basic/web/CompositeReactiveAuthenticationManagerTest.java @@ -0,0 +1,56 @@ +package org.hswebframework.web.authorization.basic.web; + +import org.hswebframework.web.authorization.*; +import org.hswebframework.web.authorization.simple.CompositeReactiveAuthenticationManager; +import org.hswebframework.web.authorization.simple.PlainTextUsernamePasswordAuthenticationRequest; +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import org.hswebframework.web.authorization.simple.SimpleUser; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Arrays; + + +public class CompositeReactiveAuthenticationManagerTest { + + @Test + public void test() { + CompositeReactiveAuthenticationManager manager = new CompositeReactiveAuthenticationManager( + Arrays.asList( + new ReactiveAuthenticationManagerProvider() { + @Override + public Mono authenticate(Mono request) { + return Mono.error(new IllegalArgumentException("密码错误")); + } + + @Override + public Mono getByUserId(String userId) { + return Mono.empty(); + } + }, + new ReactiveAuthenticationManagerProvider() { + @Override + public Mono authenticate(Mono request) { + SimpleAuthentication authentication = new SimpleAuthentication(); + authentication.setUser(SimpleUser.builder().id("test").build()); + + return Mono.just(authentication); + } + + @Override + public Mono getByUserId(String userId) { + return Mono.empty(); + } + } + ) + ); + + manager.authenticate(Mono.just(new PlainTextUsernamePasswordAuthenticationRequest())) + .map(Authentication::getUser) + .map(User::getId) + .as(StepVerifier::create) + .expectNext("test") + .verifyComplete(); + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/test/resources/application.yml b/hsweb-authorization/hsweb-authorization-basic/src/test/resources/application.yml new file mode 100644 index 000000000..7851f791a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-basic/src/test/resources/application.yml @@ -0,0 +1,15 @@ +hsweb: + auth: + users: + admin: + username: admin + password: admin + permissions-simple: + user-token: + - get + - update +easyorm: + dialect: h2 +logging: + level: + org.hswebframework: debug \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/pom.xml b/hsweb-authorization/hsweb-authorization-oauth2/pom.xml new file mode 100644 index 000000000..f7f7d7ba3 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/pom.xml @@ -0,0 +1,52 @@ + + + + hsweb-authorization + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-authorization-oauth2 + + + + org.hswebframework.web + hsweb-authorization-api + ${project.version} + + + + io.projectreactor + reactor-core + + + + org.springframework + spring-webflux + true + + + + org.springframework.data + spring-data-redis + true + + + + io.lettuce + lettuce-core + test + + + + org.hswebframework.web + hsweb-authorization-basic + ${project.version} + true + + + + \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/ErrorType.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/ErrorType.java new file mode 100644 index 000000000..92bba479d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/ErrorType.java @@ -0,0 +1,108 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.oauth2; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; + +public enum ErrorType { + ILLEGAL_CODE(1001), //错误的授权码 + ILLEGAL_ACCESS_TOKEN(1002), //错误的access_token + ILLEGAL_CLIENT_ID(1003),//客户端信息错误 + ILLEGAL_CLIENT_SECRET(1004),//客户端密钥错误 + ILLEGAL_GRANT_TYPE(1005), //错误的授权方式 + ILLEGAL_RESPONSE_TYPE(1006),//response_type 错误 + ILLEGAL_AUTHORIZATION(1007),//Authorization 错误 + ILLEGAL_REFRESH_TOKEN(1008),//refresh_token 错误 + ILLEGAL_REDIRECT_URI(1009), //redirect_url 错误 + ILLEGAL_SCOPE(1010), //scope 错误 + ILLEGAL_USERNAME(1011), //username 错误 + ILLEGAL_PASSWORD(1012), //password 错误 + + SCOPE_OUT_OF_RANGE(2010), //scope超出范围 + + UNAUTHORIZED_CLIENT(4010), //无权限 + EXPIRED_TOKEN(4011), //TOKEN过期 + INVALID_TOKEN(4012), //TOKEN已失效 + UNSUPPORTED_GRANT_TYPE(4013), //不支持的认证类型 + UNSUPPORTED_RESPONSE_TYPE(4014), //不支持的响应类型 + + EXPIRED_CODE(4015), //AUTHORIZATION_CODE过期 + EXPIRED_REFRESH_TOKEN(4020), //REFRESH_TOKEN过期 + + CLIENT_DISABLED(4016),//客户端已被禁用 + + CLIENT_NOT_EXIST(4040),//客户端不存在 + + USER_NOT_EXIST(4041),//客户端不存在 + + STATE_ERROR(4042), //stat错误 + + ACCESS_DENIED(503), //访问被拒绝 + + OTHER(5001), //其他错误 ; + + PARSE_RESPONSE_ERROR(5002),//解析返回结果错误 + + SERVICE_ERROR(5003); //服务器返回错误信息 + + + private final String message; + private final int code; + static final Map codeMapping = Arrays.stream(ErrorType.values()) + .collect(Collectors.toMap(ErrorType::code, type -> type)); + + ErrorType(int code) { + this.code = code; + message = this.name().toLowerCase(); + } + + ErrorType(int code, String message) { + this.message = message; + this.code = code; + } + + public String message() { + if (message == null) { + return this.name(); + } + return message; + } + + public int code() { + return code; + } + + public T throwThis(Function errorTypeFunction) { + throw errorTypeFunction.apply(this); + } + + public T throwThis(BiFunction errorTypeFunction, String message) { + throw errorTypeFunction.apply(this, message); + } + + public static Optional fromCode(int code) { + return Optional.ofNullable(codeMapping.get(code)); + } + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/GrantType.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/GrantType.java new file mode 100644 index 000000000..d8d25ac1b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/GrantType.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.oauth2; + +/** + * + * @author zhouhao + */ +public interface GrantType { + String authorization_code = "authorization_code"; + String implicit = "implicit"; + @SuppressWarnings("all") + String password = "password"; + String client_credentials = "client_credentials"; + String refresh_token = "refresh_token"; +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Constants.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Constants.java new file mode 100644 index 000000000..86c986de5 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Constants.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.oauth2; + +/** + * @author zhouhao + */ +public interface OAuth2Constants { + String access_token = "access_token"; + String refresh_token = "refresh_token"; + String grant_type = "grant_type"; + String scope = "scope"; + String client_id = "client_id"; + String client_secret = "client_secret"; + String authorization = "Authorization"; + String redirect_uri = "redirect_uri"; + String response_type = "response_type"; + String state = "state"; + String code = "code"; + String username = "username"; + + @SuppressWarnings("all") + String password = "password"; + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java new file mode 100644 index 000000000..8a87b07b6 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java @@ -0,0 +1,38 @@ +package org.hswebframework.web.oauth2; + +import lombok.Getter; +import org.hswebframework.web.exception.BusinessException; +import org.hswebframework.web.exception.I18nSupportException; + +@Getter +public class OAuth2Exception extends BusinessException { + private final ErrorType type; + + public OAuth2Exception(ErrorType type) { + super(type.message(), type.name(), type.code(), (Object[]) null); + this.type = type; + } + + public OAuth2Exception(String message, Throwable cause, ErrorType type) { + super(message, cause); + this.type = type; + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends OAuth2Exception { + public NoStackTrace(ErrorType type) { + super(type); + } + + public NoStackTrace(String message, Throwable cause, ErrorType type) { + super(message, cause, type); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/ResponseType.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/ResponseType.java new file mode 100644 index 000000000..72fb5918c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/ResponseType.java @@ -0,0 +1,29 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.oauth2; + +/** + * TODO 完成注释 + * + * @author zhouhao + */ +public interface ResponseType { + String code = "code"; + String token = "token"; +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/AccessToken.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/AccessToken.java new file mode 100644 index 000000000..a4c1956c8 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/AccessToken.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.oauth2.server; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString +public class AccessToken extends OAuth2Response { + + private static final long serialVersionUID = -6849794470754667710L; + + @Schema(name="access_token") + @JsonProperty("access_token") + private String accessToken; + + @Schema(name="refresh_token") + @JsonProperty("refresh_token") + private String refreshToken; + + @Schema(name="expires_in") + @JsonProperty("expires_in") + private int expiresIn; + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/AccessTokenManager.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/AccessTokenManager.java new file mode 100644 index 000000000..d6669c6e2 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/AccessTokenManager.java @@ -0,0 +1,60 @@ +package org.hswebframework.web.oauth2.server; + +import org.hswebframework.web.authorization.Authentication; +import reactor.core.publisher.Mono; + +/** + * OAuth2 AccessToken管理器,用于创建,刷新token以及根据token获取权限信息 + * + * @author zhouhao + * @since 4.0.7 + */ +public interface AccessTokenManager { + + /** + * 根据token获取权限信息 + * + * @param accessToken accessToken + * @return 权限信息 + */ + Mono getAuthenticationByToken(String accessToken); + + /** + * 根据ClientId以及权限信息创建token + * + * @param clientId clientId {@link OAuth2Client#getClientId()} + * @param authentication 权限信息 + * @param singleton 是否单例,如果为true,重复创建token将返回首次创建的token + * @return AccessToken + */ + Mono createAccessToken(String clientId, + Authentication authentication, + boolean singleton); + + /** + * 刷新token + * + * @param clientId clientId {@link OAuth2Client#getClientId()} + * @param refreshToken refreshToken + * @return 新的token + */ + Mono refreshAccessToken(String clientId, String refreshToken); + + /** + * 移除token + * + * @param clientId clientId + * @param token token + * @return void + */ + Mono removeToken(String clientId, String token); + + /** + * 取消对用户的授权 + * + * @param clientId clientId + * @param userId 用户ID + * @return void + */ + Mono cancelGrant(String clientId, String userId); +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Client.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Client.java new file mode 100644 index 000000000..c3205c28c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Client.java @@ -0,0 +1,45 @@ +package org.hswebframework.web.oauth2.server; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.oauth2.ErrorType; +import org.hswebframework.web.oauth2.OAuth2Exception; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +import javax.validation.constraints.NotBlank; + +@Getter +@Setter +public class OAuth2Client { + + @NotBlank + private String clientId; + + @NotBlank + private String clientSecret; + + @NotBlank + private String name; + + private String description; + + @NotBlank + private String redirectUrl; + + //client 所属用户 + private String userId; + + public void validateRedirectUri(String redirectUri) { + if (ObjectUtils.isEmpty(redirectUri) || (!redirectUri.startsWith(this.redirectUrl))) { + throw new OAuth2Exception(ErrorType.ILLEGAL_REDIRECT_URI); + } + } + + public void validateSecret(String secret) { + if (ObjectUtils.isEmpty(secret) || (!secret.equals(this.clientSecret))) { + throw new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_SECRET); + } + } + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ClientManager.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ClientManager.java new file mode 100644 index 000000000..eb6d5b9df --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ClientManager.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.oauth2.server; + +import reactor.core.publisher.Mono; + +public interface OAuth2ClientManager { + + Mono getClient(String clientId); + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2GrantService.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2GrantService.java new file mode 100644 index 000000000..e49508173 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2GrantService.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.oauth2.server; + + +import org.hswebframework.web.oauth2.server.code.AuthorizationCodeGranter; +import org.hswebframework.web.oauth2.server.credential.ClientCredentialGranter; +import org.hswebframework.web.oauth2.server.refresh.RefreshTokenGranter; + +public interface OAuth2GrantService { + + AuthorizationCodeGranter authorizationCode(); + + ClientCredentialGranter clientCredential(); + + RefreshTokenGranter refreshToken(); +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Granter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Granter.java new file mode 100644 index 000000000..e9ee66b1b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Granter.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.oauth2.server; + +public interface OAuth2Granter { + + + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Properties.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Properties.java new file mode 100644 index 000000000..8956781c1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Properties.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.oauth2.server; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "hsweb.oauth2") +@Getter +@Setter +public class OAuth2Properties { + + //token有效期 + private Duration tokenExpireIn = Duration.ofSeconds(7200); + + //refreshToken有效期 + private Duration refreshTokenIn = Duration.ofDays(30); + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Request.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Request.java new file mode 100644 index 000000000..c0e86ceb0 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Request.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.oauth2.server; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Getter +@Setter +@AllArgsConstructor +public class OAuth2Request { + + private Map parameters; + + + public Optional getParameter(String key) { + return Optional.ofNullable(parameters) + .map(params -> params.get(key)); + } + + public OAuth2Request with(String parameter, String key) { + if (parameters == null) { + parameters = new HashMap<>(); + } + parameters.put(parameter, key); + return this; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Response.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Response.java new file mode 100644 index 000000000..a6b80098c --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Response.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.oauth2.server; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +@Getter +@Setter +public class OAuth2Response implements Serializable { + @Hidden + private Map parameters; + + public OAuth2Response with(String parameter, Object key) { + if (parameters == null) { + parameters = new HashMap<>(); + } + parameters.put(parameter, key); + return this; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ServerAutoConfiguration.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ServerAutoConfiguration.java new file mode 100644 index 000000000..4fdfa28e0 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ServerAutoConfiguration.java @@ -0,0 +1,108 @@ +package org.hswebframework.web.oauth2.server; + +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.hswebframework.web.authorization.basic.web.ReactiveUserTokenParser; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.hswebframework.web.oauth2.server.code.AuthorizationCodeGranter; +import org.hswebframework.web.oauth2.server.code.DefaultAuthorizationCodeGranter; +import org.hswebframework.web.oauth2.server.credential.ClientCredentialGranter; +import org.hswebframework.web.oauth2.server.credential.DefaultClientCredentialGranter; +import org.hswebframework.web.oauth2.server.impl.CompositeOAuth2GrantService; +import org.hswebframework.web.oauth2.server.impl.RedisAccessTokenManager; +import org.hswebframework.web.oauth2.server.refresh.DefaultRefreshTokenGranter; +import org.hswebframework.web.oauth2.server.refresh.RefreshTokenGranter; +import org.hswebframework.web.oauth2.server.web.OAuth2AuthorizeController; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisOperations; + +@AutoConfiguration +@EnableConfigurationProperties(OAuth2Properties.class) +public class OAuth2ServerAutoConfiguration { + + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ReactiveUserTokenParser.class) + static class ReactiveOAuth2AccessTokenParserConfiguration { + +// @Bean +// @ConditionalOnBean(AccessTokenManager.class) +// public ReactiveOAuth2AccessTokenParser reactiveOAuth2AccessTokenParser(AccessTokenManager accessTokenManager) { +// ReactiveOAuth2AccessTokenParser parser = new ReactiveOAuth2AccessTokenParser(accessTokenManager); +// ReactiveAuthenticationHolder.addSupplier(parser); +// return parser; +// } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + static class ReactiveOAuth2ServerAutoConfiguration { + + + @Bean + @ConditionalOnMissingBean + public AccessTokenManager accessTokenManager(ReactiveRedisOperations redis, + UserTokenManager tokenManager, + OAuth2Properties properties) { + @SuppressWarnings("all") + RedisAccessTokenManager manager = new RedisAccessTokenManager((ReactiveRedisOperations) redis, tokenManager); + manager.setTokenExpireIn((int) properties.getTokenExpireIn().getSeconds()); + manager.setRefreshExpireIn((int) properties.getRefreshTokenIn().getSeconds()); + return manager; + } + + @Bean + @ConditionalOnMissingBean + public ClientCredentialGranter clientCredentialGranter(ReactiveAuthenticationManager authenticationManager, + AccessTokenManager accessTokenManager, + ApplicationEventPublisher eventPublisher) { + return new DefaultClientCredentialGranter(authenticationManager, accessTokenManager,eventPublisher); + } + + @Bean + @ConditionalOnMissingBean + public AuthorizationCodeGranter authorizationCodeGranter(AccessTokenManager tokenManager, + ApplicationEventPublisher eventPublisher, + ReactiveRedisConnectionFactory redisConnectionFactory) { + return new DefaultAuthorizationCodeGranter(tokenManager,eventPublisher, redisConnectionFactory); + } + + @Bean + @ConditionalOnMissingBean + public RefreshTokenGranter refreshTokenGranter(AccessTokenManager tokenManager) { + return new DefaultRefreshTokenGranter(tokenManager); + } + + @Bean + @ConditionalOnMissingBean + public OAuth2GrantService oAuth2GrantService(ObjectProvider codeProvider, + ObjectProvider credentialProvider, + ObjectProvider refreshProvider) { + CompositeOAuth2GrantService grantService = new CompositeOAuth2GrantService(); + grantService.setAuthorizationCodeGranter(codeProvider.getIfAvailable()); + grantService.setClientCredentialGranter(credentialProvider.getIfAvailable()); + grantService.setRefreshTokenGranter(refreshProvider.getIfAvailable()); + + return grantService; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(OAuth2ClientManager.class) + public OAuth2AuthorizeController oAuth2AuthorizeController(OAuth2GrantService grantService, + OAuth2ClientManager clientManager) { + return new OAuth2AuthorizeController(grantService, clientManager); + } + + } + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/ScopePredicate.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/ScopePredicate.java new file mode 100644 index 000000000..ca55e43da --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/ScopePredicate.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.oauth2.server; + +import java.util.function.BiPredicate; + +@FunctionalInterface +public interface ScopePredicate extends BiPredicate { + + boolean test(String permission, String... actions); + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/auth/ReactiveOAuth2AccessTokenParser.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/auth/ReactiveOAuth2AccessTokenParser.java new file mode 100644 index 000000000..b25b5025d --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/auth/ReactiveOAuth2AccessTokenParser.java @@ -0,0 +1,54 @@ +package org.hswebframework.web.oauth2.server.auth; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.ReactiveAuthenticationSupplier; +import org.hswebframework.web.authorization.basic.web.ReactiveUserTokenParser; +import org.hswebframework.web.authorization.token.ParsedToken; +import org.hswebframework.web.oauth2.server.AccessTokenManager; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@AllArgsConstructor +public class ReactiveOAuth2AccessTokenParser implements ReactiveUserTokenParser, ReactiveAuthenticationSupplier { + + private final AccessTokenManager accessTokenManager; + + @Override + public Mono parseToken(ServerWebExchange exchange) { + + String token = exchange.getRequest().getQueryParams().getFirst("access_token"); + if (!StringUtils.hasText(token)) { + token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(token)) { + String[] typeAndToken = token.split("[ ]"); + if (typeAndToken.length == 2 && typeAndToken[0].equalsIgnoreCase("bearer")) { + token = typeAndToken[1]; + } + } + } + + if (StringUtils.hasText(token)) { + return Mono.just(ParsedToken.of("oauth2", token)); + } + + return Mono.empty(); + } + + @Override + public Mono get(String userId) { + return Mono.empty(); + } + + @Override + public Mono get() { + return Mono + .deferContextual(context -> context + .getOrEmpty(ParsedToken.class) + .filter(token -> "oauth2".equals(token.getType())) + .map(t -> accessTokenManager.getAuthenticationByToken(t.getToken())) + .orElse(Mono.empty())); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeCache.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeCache.java new file mode 100644 index 000000000..0c7a3c4bb --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeCache.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.oauth2.server.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; + +import java.io.Serializable; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class AuthorizationCodeCache implements Serializable { + private static final long serialVersionUID = -6849794470754667710L; + + private String clientId; + + private String code; + + private Authentication authentication; + + private String scope; + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeGranter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeGranter.java new file mode 100644 index 000000000..ba0edbfec --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeGranter.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.oauth2.server.code; + +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.OAuth2Granter; +import reactor.core.publisher.Mono; + +/** + * 授权码模式认证 + * + * @author zhouhao + * @since 4.0.7 + */ +public interface AuthorizationCodeGranter extends OAuth2Granter { + + /** + * 申请授权码 + * + * @param request 请求 + * @return 授权码信息 + */ + Mono requestCode(AuthorizationCodeRequest request); + + /** + * 根据授权码获取token + * + * @param request 请求 + * @return token + */ + Mono requestToken(AuthorizationCodeTokenRequest request); + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeRequest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeRequest.java new file mode 100644 index 000000000..11b7e3196 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeRequest.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.oauth2.server.code; + + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.OAuth2Request; + +import java.util.Map; + +@Getter +@Setter +public class AuthorizationCodeRequest extends OAuth2Request { + private OAuth2Client client; + + private Authentication authentication; + + + public AuthorizationCodeRequest(OAuth2Client client, + Authentication authentication, + Map parameters) { + super(parameters); + this.client = client; + this.authentication = authentication; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeResponse.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeResponse.java new file mode 100644 index 000000000..13e72e780 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeResponse.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.oauth2.server.code; + + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hswebframework.web.oauth2.OAuth2Constants; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.OAuth2Request; +import org.hswebframework.web.oauth2.server.OAuth2Response; + +import java.util.HashMap; + +@Getter +@Setter +@ToString +public class AuthorizationCodeResponse extends OAuth2Response { + private String code; + + public AuthorizationCodeResponse(String code) { + this.code = code; + with(OAuth2Constants.code, code); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeTokenRequest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeTokenRequest.java new file mode 100644 index 000000000..896981b5a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeTokenRequest.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.oauth2.server.code; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.oauth2.OAuth2Constants; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.OAuth2Request; + +import java.util.Map; +import java.util.Optional; + + +@Getter +@Setter +public class AuthorizationCodeTokenRequest extends OAuth2Request { + + private OAuth2Client client; + + public AuthorizationCodeTokenRequest(OAuth2Client client, Map parameters) { + super(parameters); + this.client = client; + } + + public Optional code() { + return getParameter(OAuth2Constants.code).map(String::valueOf); + } + + public Optional scope() { + return getParameter(OAuth2Constants.scope).map(String::valueOf); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranter.java new file mode 100644 index 000000000..d63b56883 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranter.java @@ -0,0 +1,112 @@ +package org.hswebframework.web.oauth2.server.code; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.id.IDGenerator; +import org.hswebframework.web.oauth2.ErrorType; +import org.hswebframework.web.oauth2.GrantType; +import org.hswebframework.web.oauth2.OAuth2Constants; +import org.hswebframework.web.oauth2.OAuth2Exception; +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.AccessTokenManager; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.ScopePredicate; +import org.hswebframework.web.oauth2.server.event.OAuth2GrantedEvent; +import org.hswebframework.web.oauth2.server.utils.OAuth2ScopeUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@AllArgsConstructor +public class DefaultAuthorizationCodeGranter implements AuthorizationCodeGranter { + + private final AccessTokenManager accessTokenManager; + + private final ApplicationEventPublisher eventPublisher; + + private final ReactiveRedisOperations redis; + + @SuppressWarnings("all") + public DefaultAuthorizationCodeGranter(AccessTokenManager accessTokenManager, + ApplicationEventPublisher eventPublisher, + ReactiveRedisConnectionFactory connectionFactory) { + this(accessTokenManager, eventPublisher, new ReactiveRedisTemplate<>(connectionFactory, RedisSerializationContext + .newSerializationContext() + .key((RedisSerializer) RedisSerializer.string()) + .value(RedisSerializer.java()) + .hashKey(RedisSerializer.string()) + .hashValue(RedisSerializer.java()) + .build() + )); + } + + @Override + public Mono requestCode(AuthorizationCodeRequest request) { + OAuth2Client client = request.getClient(); + Authentication authentication = request.getAuthentication(); + AuthorizationCodeCache codeCache = new AuthorizationCodeCache(); + String code = IDGenerator.MD5.generate(); + request.getParameter(OAuth2Constants.scope).map(String::valueOf).ifPresent(codeCache::setScope); + codeCache.setCode(code); + codeCache.setClientId(client.getClientId()); + + ScopePredicate permissionPredicate = OAuth2ScopeUtils.createScopePredicate(codeCache.getScope()); + + Authentication copy = authentication.copy( + (permission, action) -> permissionPredicate.test(permission.getId(), action), + dimension -> permissionPredicate.test(dimension.getType().getId(), dimension.getId())); + + copy.setAttribute("scope", codeCache.getScope()); + + codeCache.setAuthentication(copy); + + + return redis + .opsForValue() + .set(getRedisKey(code), codeCache, Duration.ofMinutes(5)) + .thenReturn(new AuthorizationCodeResponse(code)); + } + + + private String getRedisKey(String code) { + return "oauth2-code:" + code; + } + + @Override + public Mono requestToken(AuthorizationCodeTokenRequest request) { + + return Mono + .justOrEmpty(request.code()) + .map(this::getRedisKey) + .flatMap(redis.opsForValue()::get) + .switchIfEmpty(Mono.error(() -> new OAuth2Exception(ErrorType.ILLEGAL_CODE))) + //移除code + .flatMap(cache -> redis.opsForValue().delete(getRedisKey(cache.getCode())).thenReturn(cache)) + .flatMap(cache -> { + if (!request.getClient().getClientId().equals(cache.getClientId())) { + return Mono.error(new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_ID)); + } + return accessTokenManager + .createAccessToken(cache.getClientId(), cache.getAuthentication(), false) + .flatMap(token -> new OAuth2GrantedEvent(request.getClient(), + token, + cache.getAuthentication(), + cache.getScope(), + GrantType.authorization_code, + request.getParameters()) + .publish(eventPublisher) + .onErrorResume(err -> accessTokenManager + .removeToken(cache.getClientId(), token.getAccessToken()) + .then(Mono.error(err))) + .thenReturn(token)); + }) + ; + + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/ClientCredentialGranter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/ClientCredentialGranter.java new file mode 100644 index 000000000..140421bcb --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/ClientCredentialGranter.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.oauth2.server.credential; + +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.OAuth2Granter; +import reactor.core.publisher.Mono; + +public interface ClientCredentialGranter extends OAuth2Granter { + + /** + * 申请token + * + * @param request 请求 + * @return token + */ + Mono requestToken(ClientCredentialRequest request); + + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/ClientCredentialRequest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/ClientCredentialRequest.java new file mode 100644 index 000000000..5203baf88 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/ClientCredentialRequest.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.oauth2.server.credential; + +import lombok.Getter; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.OAuth2Request; + +import java.util.Map; + +@Getter +public class ClientCredentialRequest extends OAuth2Request { + + private final OAuth2Client client; + + public ClientCredentialRequest(OAuth2Client client, Map parameters) { + super(parameters); + this.client = client; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/DefaultClientCredentialGranter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/DefaultClientCredentialGranter.java new file mode 100644 index 000000000..08a78cf29 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/credential/DefaultClientCredentialGranter.java @@ -0,0 +1,44 @@ +package org.hswebframework.web.oauth2.server.credential; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.authorization.ReactiveAuthenticationManager; +import org.hswebframework.web.oauth2.GrantType; +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.AccessTokenManager; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.event.OAuth2GrantedEvent; +import org.springframework.context.ApplicationEventPublisher; +import reactor.core.publisher.Mono; + +@AllArgsConstructor +public class DefaultClientCredentialGranter implements ClientCredentialGranter { + + private final ReactiveAuthenticationManager authenticationManager; + + private final AccessTokenManager accessTokenManager; + + private final ApplicationEventPublisher eventPublisher; + + @Override + public Mono requestToken(ClientCredentialRequest request) { + + OAuth2Client client = request.getClient(); + + return authenticationManager + .getByUserId(client.getUserId()) + .flatMap(auth -> accessTokenManager + .createAccessToken(client.getClientId(), auth, true) + .flatMap(token -> new OAuth2GrantedEvent(client, + token, + auth, + "*", + GrantType.client_credentials, + request.getParameters()) + .publish(eventPublisher) + .onErrorResume(err -> accessTokenManager + .removeToken(client.getClientId(), token.getAccessToken()) + .then(Mono.error(err))) + .thenReturn(token)) + ); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/event/OAuth2GrantedEvent.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/event/OAuth2GrantedEvent.java new file mode 100644 index 000000000..38b341dcb --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/event/OAuth2GrantedEvent.java @@ -0,0 +1,32 @@ +package org.hswebframework.web.oauth2.server.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.event.DefaultAsyncEvent; +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.OAuth2Client; + +import java.util.Map; + +/** + * OAuth2授权成功事件 + * + * @author zhouhao + * @since 4.0.15 + */ +@Getter +@AllArgsConstructor +public class OAuth2GrantedEvent extends DefaultAsyncEvent { + private final OAuth2Client client; + + private final AccessToken accessToken; + + private final Authentication authentication; + + private final String scope; + + private final String grantType; + + private final Map parameters; +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/CompositeOAuth2GrantService.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/CompositeOAuth2GrantService.java new file mode 100644 index 000000000..bf9a1ee70 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/CompositeOAuth2GrantService.java @@ -0,0 +1,34 @@ +package org.hswebframework.web.oauth2.server.impl; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.oauth2.server.credential.ClientCredentialGranter; +import org.hswebframework.web.oauth2.server.OAuth2GrantService; +import org.hswebframework.web.oauth2.server.code.AuthorizationCodeGranter; +import org.hswebframework.web.oauth2.server.refresh.RefreshTokenGranter; + +@Getter +@Setter +public class CompositeOAuth2GrantService implements OAuth2GrantService { + + private AuthorizationCodeGranter authorizationCodeGranter; + + private ClientCredentialGranter clientCredentialGranter; + + private RefreshTokenGranter refreshTokenGranter; + + @Override + public AuthorizationCodeGranter authorizationCode() { + return authorizationCodeGranter; + } + + @Override + public ClientCredentialGranter clientCredential() { + return clientCredentialGranter; + } + + @Override + public RefreshTokenGranter refreshToken() { + return refreshTokenGranter; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/RedisAccessToken.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/RedisAccessToken.java new file mode 100644 index 000000000..d5eaeaac8 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/RedisAccessToken.java @@ -0,0 +1,47 @@ +package org.hswebframework.web.oauth2.server.impl; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.oauth2.server.AccessToken; + +import java.io.Serializable; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class RedisAccessToken implements Serializable { + + private String clientId; + + private String accessToken; + + private String refreshToken; + + private long createTime; + + private Authentication authentication; + + private boolean singleton; + + public boolean storeAuth() { + boolean allowAllScope = authentication + .getAttribute("scope") + .map("*"::equals) + .orElse(false); + + //不是单例,并且没有授予全部权限 + return !singleton && !allowAllScope; + } + + public AccessToken toAccessToken(int expiresIn) { + AccessToken token = new AccessToken(); + token.setAccessToken(accessToken); + token.setRefreshToken(refreshToken); + token.setExpiresIn(expiresIn); + return token; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/RedisAccessTokenManager.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/RedisAccessTokenManager.java new file mode 100644 index 000000000..ff4dc98ae --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/impl/RedisAccessTokenManager.java @@ -0,0 +1,251 @@ +package org.hswebframework.web.oauth2.server.impl; + +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.codec.digest.DigestUtils; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.token.AuthenticationUserToken; +import org.hswebframework.web.authorization.token.UserTokenManager; +import org.hswebframework.web.authorization.token.redis.RedisUserTokenManager; +import org.hswebframework.web.oauth2.ErrorType; +import org.hswebframework.web.oauth2.OAuth2Exception; +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.AccessTokenManager; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.UUID; + +public class RedisAccessTokenManager implements AccessTokenManager { + + private final ReactiveRedisOperations tokenRedis; + + private final UserTokenManager userTokenManager; + + @Getter + @Setter + private int tokenExpireIn = 7200;//2小时 + + @Getter + @Setter + private int refreshExpireIn = 2592000; //30天 + + public RedisAccessTokenManager(ReactiveRedisOperations tokenRedis, + UserTokenManager userTokenManager) { + this.tokenRedis = tokenRedis; + this.userTokenManager = userTokenManager; + } + + @SuppressWarnings("all") + public RedisAccessTokenManager(ReactiveRedisConnectionFactory connectionFactory) { + ReactiveRedisTemplate redis = new ReactiveRedisTemplate<>(connectionFactory, RedisSerializationContext + .newSerializationContext() + .key((RedisSerializer) RedisSerializer.string()) + .value(RedisSerializer.java()) + .hashKey(RedisSerializer.string()) + .hashValue(RedisSerializer.java()) + .build()); + this.tokenRedis = redis; + this.userTokenManager = new RedisUserTokenManager(redis); + } + + + @Override + public Mono getAuthenticationByToken(String accessToken) { + return userTokenManager + .getByToken(accessToken) + .filter(token -> token instanceof AuthenticationUserToken) + .map(t -> ((AuthenticationUserToken) t).getAuthentication()); + } + + private String createTokenRedisKey(String clientId, String token) { + return "oauth2-token:" + clientId + ":" + token; + } + + private String createUserTokenRedisKey(RedisAccessToken token) { + return createUserTokenRedisKey(token.getClientId(), token.getAuthentication().getUser().getId()); + } + + private String createUserTokenRedisKey(String clientId, String userId) { + return "oauth2-user-tokens:" + clientId + ":" + userId; + } + + + private String createRefreshTokenRedisKey(String clientId, String token) { + return "oauth2-refresh-token:" + clientId + ":" + token; + } + + private String createSingletonTokenRedisKey(String clientId) { + return "oauth2-" + clientId + "-token"; + } + + private Mono doCreateAccessToken(String clientId, Authentication authentication, boolean singleton) { + String token = DigestUtils.md5Hex(UUID.randomUUID().toString()); + String refresh = DigestUtils.md5Hex(UUID.randomUUID().toString()); + RedisAccessToken accessToken = new RedisAccessToken(clientId, token, refresh, System.currentTimeMillis(), authentication, singleton); + + return storeToken(accessToken).thenReturn(accessToken); + } + + private Mono storeAuthToken(RedisAccessToken token) { + //保存独立的权限信息,通常是用户指定了特定的授权范围时生效. + if (token.storeAuth()) { + return userTokenManager + .signIn(token.getAccessToken(), + createTokenType(token.getClientId()), + token.getAuthentication().getUser().getId(), + tokenExpireIn * 1000L, + token.getAuthentication()) + .then(); + + } else { + return userTokenManager + .signIn(token.getAccessToken(), + createTokenType(token.getClientId()), + token.getAuthentication().getUser().getId(), + tokenExpireIn * 1000L) + .then(); + } + } + + private Mono storeToken(RedisAccessToken token) { + + return Flux + .merge(storeAuthToken(token), + tokenRedis + .opsForValue() + .set(createUserTokenRedisKey(token), token, Duration.ofSeconds(tokenExpireIn)), + tokenRedis + .opsForValue() + .set(createTokenRedisKey(token.getClientId(), + token.getAccessToken()), token, Duration.ofSeconds(tokenExpireIn)), + tokenRedis + .opsForValue() + .set(createRefreshTokenRedisKey(token.getClientId(), + token.getRefreshToken()), token, Duration.ofSeconds(refreshExpireIn))) + .then(); + } + + private Mono doCreateSingletonAccessToken(String clientId, Authentication authentication) { + String redisKey = createSingletonTokenRedisKey(clientId); + return tokenRedis + .opsForValue() + .get(redisKey) + .filterWhen(token -> userTokenManager.tokenIsLoggedIn(token.getAccessToken())) + .flatMap(token -> tokenRedis + .getExpire(redisKey) + .map(duration -> token.toAccessToken((int) (duration.toMillis() / 1000)))) + .switchIfEmpty(Mono.defer(() -> doCreateAccessToken(clientId, authentication, true) + .flatMap(redisAccessToken -> tokenRedis + .opsForValue() + .set(redisKey, redisAccessToken, Duration.ofSeconds(tokenExpireIn)) + .thenReturn(redisAccessToken.toAccessToken(tokenExpireIn)))) + ); + } + + @Override + public Mono createAccessToken(String clientId, + Authentication authentication, + boolean singleton) { + return singleton + ? doCreateSingletonAccessToken(clientId, authentication) + : doCreateAccessToken(clientId, authentication, false).map(token -> token.toAccessToken(tokenExpireIn)); + } + + @Override + public Mono refreshAccessToken(String clientId, String refreshToken) { + String redisKey = createRefreshTokenRedisKey(clientId, refreshToken); + + return tokenRedis + .opsForValue() + .get(redisKey) + .switchIfEmpty(Mono.error(() -> new OAuth2Exception(ErrorType.EXPIRED_REFRESH_TOKEN))) + .flatMap(token -> { + if (!token.getClientId().equals(clientId)) { + return Mono.error(new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_ID)); + } + //生成新token + String accessToken = DigestUtils.md5Hex(UUID.randomUUID().toString()); + token.setAccessToken(accessToken); + token.setCreateTime(System.currentTimeMillis()); + return storeToken(token) + .as(result -> { + // 单例token + if (token.isSingleton()) { + return userTokenManager + .signOutByToken(token.getAccessToken()) + .then( + tokenRedis + .opsForValue() + .set(createSingletonTokenRedisKey(clientId), token, Duration.ofSeconds(tokenExpireIn)) + .then(result) + ) + ; + } + return result; + }) + .thenReturn(token.toAccessToken(tokenExpireIn)); + }); + + } + + @Override + public Mono removeToken(String clientId, String token) { + + return Flux + .merge(userTokenManager.signOutByToken(token), + tokenRedis.delete(createSingletonTokenRedisKey(clientId)), + tokenRedis.delete(createTokenRedisKey(clientId, token))) + .then(); + } + + @Override + public Mono cancelGrant(String clientId, String userId) { + //删除最新的refresh_token + Mono removeRefreshToken = tokenRedis + .opsForValue() + .get(createUserTokenRedisKey(clientId, userId)) + .flatMap(t -> tokenRedis + .opsForValue() + .delete(createRefreshTokenRedisKey(t.getClientId(), t.getRefreshToken()))) + .then(); + + //删除access_token + Mono removeAccessToken = userTokenManager + .getByUserId(userId) + .flatMap(token -> { + //其他类型的token 忽略 + if (!(createTokenType(clientId)).equals(token.getType())) { + return Mono.empty(); + } + return tokenRedis + .opsForValue() + .get(createTokenRedisKey(clientId, token.getToken())) + .flatMap(t -> { + //移除token + return tokenRedis + .delete(createTokenRedisKey(t.getClientId(), t.getAccessToken())) + //移除token对应的refresh_token + .then(tokenRedis + .opsForValue() + .delete(createRefreshTokenRedisKey(t.getClientId(), t.getRefreshToken()))); + }) + .then(userTokenManager.signOutByToken(token.getToken())); + }) + .then(); + + return Flux + .merge(removeRefreshToken, removeAccessToken) + .then(); + } + + private String createTokenType(String clientId) { + return "oauth2-" + clientId; + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/DefaultRefreshTokenGranter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/DefaultRefreshTokenGranter.java new file mode 100644 index 000000000..5bdc9a56f --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/DefaultRefreshTokenGranter.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.oauth2.server.refresh; + +import lombok.AllArgsConstructor; +import org.hswebframework.web.oauth2.ErrorType; +import org.hswebframework.web.oauth2.OAuth2Exception; +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.AccessTokenManager; +import reactor.core.publisher.Mono; + +@AllArgsConstructor +public class DefaultRefreshTokenGranter implements RefreshTokenGranter { + + private final AccessTokenManager accessTokenManager; + + @Override + public Mono requestToken(RefreshTokenRequest request) { + + return accessTokenManager + .refreshAccessToken( + request.getClient().getClientId(), + request.refreshToken().orElseThrow(()->new OAuth2Exception(ErrorType.ILLEGAL_REFRESH_TOKEN)) + ); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/RefreshTokenGranter.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/RefreshTokenGranter.java new file mode 100644 index 000000000..b09b44535 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/RefreshTokenGranter.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.oauth2.server.refresh; + +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.credential.ClientCredentialRequest; +import reactor.core.publisher.Mono; + +public interface RefreshTokenGranter { + + /** + * 刷新token + * + * @param request 请求 + * @return token + */ + Mono requestToken(RefreshTokenRequest request); + + +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/RefreshTokenRequest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/RefreshTokenRequest.java new file mode 100644 index 000000000..a9b4c5074 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/refresh/RefreshTokenRequest.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.oauth2.server.refresh; + +import lombok.Getter; +import org.hswebframework.web.oauth2.OAuth2Constants; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.OAuth2Request; + +import java.util.Map; +import java.util.Optional; + +@Getter +public class RefreshTokenRequest extends OAuth2Request { + private final OAuth2Client client; + + public RefreshTokenRequest(OAuth2Client client, Map parameters) { + super(parameters); + this.client = client; + } + + public Optional refreshToken(){ + return getParameter(OAuth2Constants.refresh_token); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/utils/OAuth2ScopeUtils.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/utils/OAuth2ScopeUtils.java new file mode 100644 index 000000000..40806f974 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/utils/OAuth2ScopeUtils.java @@ -0,0 +1,39 @@ +package org.hswebframework.web.oauth2.server.utils; + +import org.hswebframework.web.oauth2.server.ScopePredicate; +import org.springframework.util.StringUtils; + +import java.util.*; + +/** + *
{@code
+ *   role:* user:* device-manager:*
+ * }
+ * + * @author zhouhao + * @since 4.0.8 + */ +public class OAuth2ScopeUtils { + + public static ScopePredicate createScopePredicate(String scopeStr) { + if (StringUtils.isEmpty(scopeStr)) { + return ((permission, action) -> false); + } + String[] scopes = scopeStr.split("[ ,\n]"); + Map> actions = new HashMap<>(); + for (String scope : scopes) { + String[] permissions = scope.split("[:]"); + String per = permissions[0]; + Set acts = actions.computeIfAbsent(per, k -> new HashSet<>()); + acts.addAll(Arrays.asList(permissions).subList(1, permissions.length)); + } + //全部授权 + if (actions.containsKey("*")) { + return ((permission, action) -> true); + } + return ((permission, action) -> Optional + .ofNullable(actions.get(permission)) + .map(acts -> action.length == 0 || acts.contains("*") || acts.containsAll(Arrays.asList(action))) + .orElse(false)); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeController.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeController.java new file mode 100644 index 000000000..123ab20f9 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeController.java @@ -0,0 +1,196 @@ +package org.hswebframework.web.oauth2.server.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.codec.binary.Base64; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.oauth2.ErrorType; +import org.hswebframework.web.oauth2.OAuth2Exception; +import org.hswebframework.web.oauth2.server.AccessToken; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.OAuth2ClientManager; +import org.hswebframework.web.oauth2.server.OAuth2GrantService; +import org.hswebframework.web.oauth2.server.code.AuthorizationCodeRequest; +import org.hswebframework.web.oauth2.server.code.AuthorizationCodeTokenRequest; +import org.hswebframework.web.oauth2.server.credential.ClientCredentialRequest; +import org.hswebframework.web.oauth2.server.event.OAuth2GrantedEvent; +import org.hswebframework.web.oauth2.server.refresh.RefreshTokenRequest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/oauth2") +@AllArgsConstructor +@Tag(name = "OAuth2认证") +public class OAuth2AuthorizeController { + + private final OAuth2GrantService oAuth2GrantService; + + private final OAuth2ClientManager clientManager; + + @GetMapping(value = "/authorize", params = "response_type=code") + @Operation(summary = "申请授权码,并获取重定向地址", parameters = { + @Parameter(name = "client_id", required = true), + @Parameter(name = "redirect_uri", required = true), + @Parameter(name = "state"), + @Parameter(name = "response_type", description = "固定值为code") + }) + public Mono authorizeByCode(ServerWebExchange exchange) { + Map param = new HashMap<>(exchange.getRequest().getQueryParams().toSingleValueMap()); + + return Authentication + .currentReactive() + .switchIfEmpty(Mono.error(UnAuthorizedException::new)) + .flatMap(auth -> this + .getOAuth2Client(param.get("client_id")) + .flatMap(client -> { + String redirectUri = param.getOrDefault("redirect_uri", client.getRedirectUrl()); + client.validateRedirectUri(redirectUri); + return oAuth2GrantService + .authorizationCode() + .requestCode(new AuthorizationCodeRequest(client, auth, param)) + .doOnNext(response -> { + Optional + .ofNullable(param.get("state")) + .ifPresent(state -> response.with("state", state)); + }) + .map(response -> buildRedirect(redirectUri, response.getParameters())); + })); + } + + @GetMapping(value = "/token") + @Operation(summary = "(GET)申请token", parameters = { + @Parameter(name = "client_id"), + @Parameter(name = "client_secret"), + @Parameter(name = "code", description = "grantType为authorization_code时不能为空"), + @Parameter(name = "grant_type", schema = @Schema(implementation = GrantType.class)) + }) + @Authorize(ignore = true) + public Mono> requestTokenByCode( + @RequestParam("grant_type") GrantType grantType, + ServerWebExchange exchange) { + Map params = exchange.getRequest().getQueryParams().toSingleValueMap(); + Tuple2 clientIdAndSecret = getClientIdAndClientSecret(params,exchange); + return this + .getOAuth2Client(clientIdAndSecret.getT1()) + .doOnNext(client -> client.validateSecret(clientIdAndSecret.getT2())) + .flatMap(client -> grantType.requestToken(oAuth2GrantService, client, new HashMap<>(params))) + .map(ResponseEntity::ok); + } + + + @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @Operation(summary = "(POST)申请token", parameters = { + @Parameter(name = "client_id"), + @Parameter(name = "client_secret"), + @Parameter(name = "code", description = "grantType为authorization_code时不能为空"), + @Parameter(name = "grant_type", schema = @Schema(implementation = GrantType.class)) + }) + @Authorize(ignore = true) + public Mono> requestTokenByCode(ServerWebExchange exchange) { + return exchange + .getFormData() + .map(MultiValueMap::toSingleValueMap) + .flatMap(params -> { + Tuple2 clientIdAndSecret = getClientIdAndClientSecret(params,exchange); + GrantType grantType = GrantType.of(params.get("grant_type")); + return this + .getOAuth2Client(clientIdAndSecret.getT1()) + .doOnNext(client -> client.validateSecret(clientIdAndSecret.getT2())) + .flatMap(client -> grantType.requestToken(oAuth2GrantService, client, new HashMap<>(params))) + .map(ResponseEntity::ok); + }); + } + + private Tuple2 getClientIdAndClientSecret(Map params, ServerWebExchange exchange) { + String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authorization != null && authorization.startsWith("Basic ")) { + String[] arr = new String(Base64.decodeBase64(authorization.substring(5))).split(":"); + if (arr.length >= 2) { + return Tuples.of(arr[0], arr[1]); + } + return Tuples.of(arr[0], arr[0]); + } + return Tuples.of(params.getOrDefault("client_id",""),params.getOrDefault("client_secret","")); + } + + public enum GrantType { + authorization_code { + @Override + Mono requestToken(OAuth2GrantService service, OAuth2Client client, Map param) { + return service + .authorizationCode() + .requestToken(new AuthorizationCodeTokenRequest(client, param)); + } + }, + client_credentials { + @Override + Mono requestToken(OAuth2GrantService service, OAuth2Client client, Map param) { + return service + .clientCredential() + .requestToken(new ClientCredentialRequest(client, param)); + } + }, + refresh_token { + @Override + Mono requestToken(OAuth2GrantService service, OAuth2Client client, Map param) { + return service + .refreshToken() + .requestToken(new RefreshTokenRequest(client, param)); + } + }; + + abstract Mono requestToken(OAuth2GrantService service, OAuth2Client client, Map param); + + static GrantType of(String name) { + try { + return GrantType.valueOf(name); + } catch (Throwable e) { + throw new OAuth2Exception(ErrorType.UNSUPPORTED_GRANT_TYPE); + } + } + } + + @SneakyThrows + public static String urlEncode(String url) { + return URLEncoder.encode(url, "utf-8"); + } + + static String buildRedirect(String redirectUri, Map params) { + String paramsString = params.entrySet() + .stream() + .map(e -> e.getKey() + "=" + urlEncode(String.valueOf(e.getValue()))) + .collect(Collectors.joining("&")); + if (redirectUri.contains("?")) { + return redirectUri + "&" + paramsString; + } + return redirectUri + "?" + paramsString; + } + + private Mono getOAuth2Client(String id) { + return clientManager + .getClient(id) + .switchIfEmpty(Mono.error(() -> new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_ID))); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/hsweb-authorization/hsweb-authorization-oauth2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..42c207559 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.hswebframework.web.oauth2.server.OAuth2ServerAutoConfiguration \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/OAuth2ClientTest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/OAuth2ClientTest.java new file mode 100644 index 000000000..698c4040b --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/OAuth2ClientTest.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.oauth2.server; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class OAuth2ClientTest { + + @Test + public void test(){ + OAuth2Client client=new OAuth2Client(); + + client.setRedirectUrl("http://hsweb.me/callback"); + + client.validateRedirectUri("http://hsweb.me/callback"); + + client.validateRedirectUri("http://hsweb.me/callback?a=1&n=1"); + + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/RedisHelper.java b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/RedisHelper.java new file mode 100644 index 000000000..c5237bc20 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/RedisHelper.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.oauth2.server; + +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +public class RedisHelper { + + public static LettuceConnectionFactory factory; + + static { + factory = new LettuceConnectionFactory(new RedisStandaloneConfiguration("127.0.0.1")); + factory.afterPropertiesSet(); + } +} diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranterTest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranterTest.java new file mode 100644 index 000000000..286d06eb6 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranterTest.java @@ -0,0 +1,50 @@ +package org.hswebframework.web.oauth2.server.code; + +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import org.hswebframework.web.authorization.simple.SimpleUser; +import org.hswebframework.web.oauth2.server.OAuth2Client; +import org.hswebframework.web.oauth2.server.RedisHelper; +import org.hswebframework.web.oauth2.server.impl.RedisAccessTokenManager; +import org.junit.Ignore; +import org.junit.Test; +import org.springframework.context.support.StaticApplicationContext; +import reactor.test.StepVerifier; + +import java.util.Collections; + +@Ignore +public class DefaultAuthorizationCodeGranterTest { + + @Test + public void testRequestToken() { + + StaticApplicationContext context = new StaticApplicationContext(); + context.refresh(); + context.start(); + + DefaultAuthorizationCodeGranter codeGranter = new DefaultAuthorizationCodeGranter( + new RedisAccessTokenManager(RedisHelper.factory), context, RedisHelper.factory + ); + + OAuth2Client client = new OAuth2Client(); + client.setClientId("test"); + client.setClientSecret("test"); + SimpleAuthentication authentication = new SimpleAuthentication(); + authentication.setUser(SimpleUser + .builder() + .id("test") + .build()); + + codeGranter + .requestCode(new AuthorizationCodeRequest(client, authentication, Collections.emptyMap())) + .doOnNext(System.out::println) + .flatMap(response -> codeGranter + .requestToken(new AuthorizationCodeTokenRequest(client, Collections.singletonMap("code", response.getCode())))) + .doOnNext(System.out::println) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + } + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/impl/RedisAccessTokenManagerTest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/impl/RedisAccessTokenManagerTest.java new file mode 100644 index 000000000..a118207ef --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/impl/RedisAccessTokenManagerTest.java @@ -0,0 +1,71 @@ +package org.hswebframework.web.oauth2.server.impl; + +import org.hswebframework.web.authorization.simple.SimpleAuthentication; +import org.hswebframework.web.authorization.simple.SimpleUser; +import org.hswebframework.web.oauth2.server.RedisHelper; +import org.junit.Ignore; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.Assert.*; + +@Ignore +public class RedisAccessTokenManagerTest { + + @Test + public void testCreateAccessToken() { + RedisAccessTokenManager tokenManager = new RedisAccessTokenManager(RedisHelper.factory); + + SimpleAuthentication authentication = new SimpleAuthentication(); + authentication.setUser(SimpleUser.builder() + .id("test") + .build()); + tokenManager.createAccessToken("test", authentication, false) + .doOnNext(System.out::println) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + } + + @Test + public void testRefreshToken() { + RedisAccessTokenManager tokenManager = new RedisAccessTokenManager(RedisHelper.factory); + + SimpleAuthentication authentication = new SimpleAuthentication(); + authentication.setUser(SimpleUser.builder() + .id("test") + .build()); + tokenManager + .createAccessToken("test", authentication, false) + .zipWhen(token -> tokenManager.refreshAccessToken("test", token.getRefreshToken())) + .as(StepVerifier::create) + .expectNextMatches(tp2 -> { + return tp2.getT1().getRefreshToken().equals(tp2.getT2().getRefreshToken()); + }) + ; + + } + + @Test + public void testCreateSingletonAccessToken() { + RedisAccessTokenManager tokenManager = new RedisAccessTokenManager(RedisHelper.factory); + + SimpleAuthentication authentication = new SimpleAuthentication(); + authentication.setUser(SimpleUser.builder() + .id("test") + .build()); + Flux + .concat(tokenManager + .createAccessToken("test", authentication, true), + tokenManager + .createAccessToken("test", authentication, true)) + .doOnNext(System.out::println) + .as(StepVerifier::create) + .expectNextCount(2) + .verifyComplete(); + + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/utils/OAuth2ScopeUtilsTest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/utils/OAuth2ScopeUtilsTest.java new file mode 100644 index 000000000..83cb9787a --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/utils/OAuth2ScopeUtilsTest.java @@ -0,0 +1,35 @@ +package org.hswebframework.web.oauth2.server.utils; + +import org.hswebframework.web.oauth2.server.ScopePredicate; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class OAuth2ScopeUtilsTest { + + + @Test + public void testEmpty() { + ScopePredicate predicate = OAuth2ScopeUtils.createScopePredicate(null); + assertFalse(predicate.test("basic")); + } + + @Test + public void testScope() { + ScopePredicate predicate = OAuth2ScopeUtils.createScopePredicate("basic user:info device:query"); + + assertTrue(predicate.test("basic")); + { + + assertTrue(predicate.test("user", "info")); + assertFalse(predicate.test("user", "info2")); + } + + { + assertTrue(predicate.test("device", "query")); + assertFalse(predicate.test("device", "query2")); + } + + } +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeControllerTest.java b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeControllerTest.java new file mode 100644 index 000000000..5778117a3 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeControllerTest.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.oauth2.server.web; + +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.*; + +public class OAuth2AuthorizeControllerTest { + + @Test + public void testBuildRedirect() { + String url = OAuth2AuthorizeController.buildRedirect("http://hsweb.me/callback", Collections.singletonMap("code", "1234")); + + assertEquals(url,"http://hsweb.me/callback?code=1234"); + } + + @Test + public void testBuildRedirectParam() { + String url = OAuth2AuthorizeController.buildRedirect("http://hsweb.me/callback?a=b", Collections.singletonMap("code", "1234")); + + assertEquals(url,"http://hsweb.me/callback?a=b&code=1234"); + } + +} \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/test/resources/logback.xml b/hsweb-authorization/hsweb-authorization-oauth2/src/test/resources/logback.xml new file mode 100644 index 000000000..fbdf2f230 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + +   + +   +   + + + %-4relative [%thread] %-5level %logger{35} - %msg %n + + + + + + + \ No newline at end of file diff --git a/hsweb-authorization/pom.xml b/hsweb-authorization/pom.xml new file mode 100644 index 000000000..ee6b2965d --- /dev/null +++ b/hsweb-authorization/pom.xml @@ -0,0 +1,21 @@ + + + + hsweb-framework + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-authorization + pom + + hsweb-authorization-api + hsweb-authorization-basic + hsweb-authorization-oauth2 + + + + \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/pom.xml b/hsweb-commons/hsweb-commons-api/pom.xml new file mode 100644 index 000000000..cd8a6e8dd --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/pom.xml @@ -0,0 +1,59 @@ + + + + hsweb-commons + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-commons-api + + + + org.hswebframework + hsweb-easy-orm-rdb + + + org.springframework + spring-context + + + org.hswebframework.web + hsweb-core + ${project.version} + + + org.hibernate.javax.persistence + hibernate-jpa-2.1-api + + + io.swagger.core.v3 + swagger-annotations + + + org.hibernate.validator + hibernate-validator + + + + com.google.code.findbugs + jsr305 + compile + + + + commons-codec + commons-codec + + + + org.springframework.boot + spring-boot-autoconfigure + + + + + \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/Entity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/Entity.java new file mode 100644 index 000000000..b49ce9df9 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/Entity.java @@ -0,0 +1,102 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + + +import org.hswebframework.ezorm.core.StaticMethodReferenceColumn; +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.validator.ValidatorUtils; + +import java.io.Serializable; + +/** + * 实体总接口,所有实体需实现此接口 + * + * @author zhouhao + * @since 3.0 + */ +public interface Entity extends Serializable { + + /** + * 使用jsr303对当前实体类进行验证,如果未通过验证则会抛出{@link org.hswebframework.web.exception.ValidationException}异常 + * + * @param groups 分组 + * @see org.hswebframework.web.exception.ValidationException + */ + default void tryValidate(Class... groups) { + ValidatorUtils.tryValidate(this, groups); + } + + /** + * 使用jsr303对当前实体类的指定属性进行验证,如果未通过验证则会抛出{@link org.hswebframework.web.exception.ValidationException}异常 + * + * @param groups 分组 + * @see org.hswebframework.web.exception.ValidationException + */ + default void tryValidate(String property, Class... groups) { + ValidatorUtils.tryValidate(this, property, groups); + } + + /** + * 使用jsr303对当前实体类的指定属性进行验证,如果未通过验证则会抛出{@link org.hswebframework.web.exception.ValidationException}异常 + * + * @param groups 分组 + * @see org.hswebframework.web.exception.ValidationException + */ + default void tryValidate(StaticMethodReferenceColumn property, Class... groups) { + tryValidate(property.getColumn(), groups); + } + + /** + * 将当前实体类复制到指定其他类型中,类型将会被自动实例化,在类型明确时,建议使用{@link Entity#copyFrom(Object, String...)}. + * + * @param target 目标类型 + * @param ignoreProperties 忽略复制的属性 + * @param 类型 + * @return 复制结果 + */ + default T copyTo(Class target, String... ignoreProperties) { + return FastBeanCopier.copy(this, target, ignoreProperties); + } + + /** + * 将当前实体类复制到其他对象中 + * + * @param target 目标实体 + * @param ignoreProperties 忽略复制的属性 + * @param 类型 + * @return 复制结果 + */ + default T copyTo(T target, String... ignoreProperties) { + return FastBeanCopier.copy(this, target, ignoreProperties); + } + + /** + * 从其他对象复制属性到当前对象 + * + * @param target 其他对象 + * @param ignoreProperties 忽略复制的属性 + * @param 类型 + * @return 当前对象 + */ + @SuppressWarnings("all") + default T copyFrom(Object target, String... ignoreProperties) { + return (T) FastBeanCopier.copy(target, this, ignoreProperties); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactory.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactory.java new file mode 100644 index 000000000..2eea1163f --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactory.java @@ -0,0 +1,136 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + + +import java.util.function.Supplier; + +/** + * 实体工厂接口,系统各个地方使用此接口来创建实体,在实际编码中也应该使用此接口来创建实体,而不是使用new方式来创建 + * + * @author zhouhao + * @since 3.0 + */ +public interface EntityFactory { + /** + * 根据类型创建实例 + *

+ * e.g. + *

+     *  entityFactory.newInstance(UserEntity.class);
+     * 
+ * + * @param entityClass 要创建的class + * @param 类型 + * @return 创建结果 + */ + T newInstance(Class entityClass); + + + /** + * 根据类型创建实例,如果类型无法创建,则使用默认类型进行创建 + *

+ * e.g. + *

+     *  entityFactory.newInstance(UserEntity.class,SimpleUserEntity.class);
+     * 
+ * + * @param entityClass 要创建的class + * @param defaultClass 默认class,当{@code entityClass}无法创建时使用此类型进行创建 + * @param 类型 + * @return 实例 + */ + T newInstance(Class entityClass, Class defaultClass); + + /** + * 根据类型创建实例,如果类型无法创建,则使用默认类型进行创建 + *

+ * e.g. + *

+     *  entityFactory.newInstance(UserEntity.class,SimpleUserEntity::new);
+     * 
+ * + * @param entityClass 要创建的class + * @param defaultFactory 默认实体创建工厂 + * @param 类型 + * @return 实例 + */ + T newInstance(Class entityClass, Supplier defaultFactory); + + /** + * 创建实体并设置默认的属性 + * + * @param entityClass 实体类型 + * @param defaultProperties 默认属性 + * @param 默认属性的类型 + * @param 实体类型 + * @return 创建结果 + * @see EntityFactory#copyProperties(Object, Object) + */ + @Deprecated + default T newInstance(Class entityClass, S defaultProperties) { + return copyProperties(defaultProperties, newInstance(entityClass)); + } + + /** + * 创建实体并设置默认的属性 + * + * @param entityClass 实体类型 + * @param defaultClass 默认class + * @param defaultProperties 默认属性 + * @param 默认属性的类型 + * @param 实体类型 + * @return 创建结果 + * @see EntityFactory#copyProperties(Object, Object) + */ + @Deprecated + default T newInstance(Class entityClass, Class defaultClass, S defaultProperties) { + return copyProperties(defaultProperties, newInstance(entityClass, defaultClass)); + } + + + /** + * 根据类型获取实体的真实的实体类型, + * 可通过此方法获取获取已拓展的实体类型,如:
+ * + * factory.getInstanceType(MyBeanInterface.class); + * + * + * @param entityClass 类型 + * @param 泛型 + * @return 实体类型 + */ + default Class getInstanceType(Class entityClass) { + return getInstanceType(entityClass, false); + } + + Class getInstanceType(Class entityClass, boolean autoRegister); + + /** + * 拷贝对象的属性 + * + * @param source 要拷贝到的对象 + * @param target 被拷贝的对象 + * @param 要拷贝对象的类型 + * @param 被拷贝对象的类型 + * @return 被拷贝的对象 + */ + @Deprecated + T copyProperties(S source, T target); +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactoryHolder.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactoryHolder.java new file mode 100644 index 000000000..2f19399e0 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactoryHolder.java @@ -0,0 +1,38 @@ +package org.hswebframework.web.api.crud.entity; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.function.Supplier; + +@Component +@Slf4j +public final class EntityFactoryHolder { + + static EntityFactory FACTORY; + + public static EntityFactory get() { + if (FACTORY == null) { + throw new IllegalStateException("EntityFactory Not Ready Yet"); + } + return FACTORY; + } + + + public static Class getMappedType(Class type) { + if (FACTORY != null) { + return FACTORY.getInstanceType(type); + } + return type; + } + + public static T newInstance(Class type, + Supplier mapper) { + if (FACTORY != null) { + return FACTORY.newInstance(type,mapper); + } + return mapper.get(); + } + +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactoryHolderConfiguration.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactoryHolderConfiguration.java new file mode 100644 index 000000000..a39b2e534 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/EntityFactoryHolderConfiguration.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.api.crud.entity; + +import org.springframework.beans.BeansException; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@AutoConfiguration +public class EntityFactoryHolderConfiguration { + + + @Bean + public ApplicationContextAware entityFactoryHolder() { + return context -> { + try { + EntityFactoryHolder.FACTORY = context.getBean(EntityFactory.class); + } catch (BeansException ignore) { + + } + }; + } + +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ExtendableEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ExtendableEntity.java new file mode 100644 index 000000000..146c99b18 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ExtendableEntity.java @@ -0,0 +1,69 @@ +package org.hswebframework.web.api.crud.entity; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.core.Extendable; + +import java.util.Collections; +import java.util.Map; + +/** + * 可扩展的实体类 + *

+ *

    + *
  • + * 实体类继承此类,或者实现{@link Extendable}接口. + *
  • + *
  • + * 使用{@link org.hswebframework.web.crud.configuration.TableMetadataCustomizer}自定义表结构 + *
  • + *
  • + * json序列化时,默认会将拓展字段平铺到json中. + *
  • + *
+ * + * @param 主键类型 + * @see JsonAnySetter + * @see JsonAnyGetter + * @since 4.0.18 + */ +@Getter +@Setter +public class ExtendableEntity extends GenericEntity implements Extendable { + + private Map extensions; + + /** + * 默认不序列化扩展属性,会由{@link ExtendableEntity#extensions()},{@link JsonAnyGetter}平铺到json中. + * + * @return 扩展属性 + */ + @JsonIgnore + public Map getExtensions() { + return extensions; + } + + @Override + @JsonAnyGetter + public Map extensions() { + return extensions == null ? Collections.emptyMap() : extensions; + } + + @Override + public Object getExtension(String property) { + Map ext = this.extensions; + return ext == null ? null : ext.get(property); + } + + @Override + @JsonAnySetter + public synchronized void setExtension(String property, Object value) { + if (extensions == null) { + extensions = new java.util.HashMap<>(); + } + extensions.put(property, value); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ExtendableTreeSortSupportEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ExtendableTreeSortSupportEntity.java new file mode 100644 index 000000000..b67580e47 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ExtendableTreeSortSupportEntity.java @@ -0,0 +1,69 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.Length; +import org.hswebframework.ezorm.rdb.mapping.annotation.Comment; + +import javax.persistence.Column; + +/** + * 支持树形结构,排序的实体类,要使用树形结构,排序功能的实体类直接继承该类 + */ +@Getter +@Setter +public abstract class ExtendableTreeSortSupportEntity extends ExtendableEntity + implements TreeSortSupportEntity { + /** + * 父级类别 + */ + @Column(name = "parent_id", length = 64) + @Comment("父级ID") + @Schema(description = "父节点ID") + private PK parentId; + + /** + * 树结构编码,用于快速查找, 每一层由4位字符组成,用-分割 + * 如第一层:0001 第二层:0001-0001 第三层:0001-0001-0001 + */ + @Column(name = "path", length = 128) + @Comment("树路径") + @Schema(description = "树结构路径") + @Length(max = 128, message = "目录层级太深") + private String path; + + /** + * 排序索引 + */ + @Column(name = "sort_index", precision = 32) + @Comment("排序序号") + @Schema(description = "排序序号") + private Long sortIndex; + + @Column(name = "_level", precision = 32) + @Comment("树层级") + @Schema(description = "树层级") + private Integer level; + + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericEntity.java new file mode 100644 index 000000000..84dc9b87a --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericEntity.java @@ -0,0 +1,54 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.bean.ToString; + +import javax.persistence.Column; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import java.util.Map; + + +/** + * @author zhouhao + * @since 4.0 + */ +@Getter +@Setter +public class GenericEntity implements Entity { + + @Column(length = 64, updatable = false) + @Id + @GeneratedValue(generator = "default_id") + @Schema(description = "id") + private PK id; + + public String toString(String... ignoreProperty) { + return ToString.toString(this, ignoreProperty); + } + + @Override + public String toString() { + return ToString.toString(this); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericI18nEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericI18nEntity.java new file mode 100644 index 000000000..40f60eaf9 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericI18nEntity.java @@ -0,0 +1,43 @@ +package org.hswebframework.web.api.crud.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.ezorm.rdb.mapping.annotation.ColumnType; +import org.hswebframework.ezorm.rdb.mapping.annotation.JsonCodec; +import org.hswebframework.web.i18n.I18nSupportEntity; + +import javax.persistence.Column; +import java.sql.JDBCType; +import java.util.Collections; +import java.util.Map; + +@Getter +@Setter +public class GenericI18nEntity extends GenericEntity implements I18nSupportEntity { + + /** + * map key为标识,如: name , description. value为国际化信息 + * + *
{@code
+     *   {
+     *       "name":{"zh":"名称","en":"name"},
+     *       "description":{"zh":"描述","en":"description"}
+     *   }
+     * }
+ */ + @Schema(title = "国际化信息定义") + @Column + @JsonCodec + @ColumnType(jdbcType = JDBCType.LONGVARCHAR, javaType = String.class) + private Map> i18nMessages; + + @Override + public Map getI18nMessages(String key) { + if (MapUtils.isEmpty(i18nMessages)) { + return Collections.emptyMap(); + } + return i18nMessages.getOrDefault(key, Collections.emptyMap()); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericTreeSortSupportEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericTreeSortSupportEntity.java new file mode 100644 index 000000000..8a79dbc5a --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/GenericTreeSortSupportEntity.java @@ -0,0 +1,69 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.Length; +import org.hswebframework.ezorm.rdb.mapping.annotation.Comment; + +import javax.persistence.Column; + +/** + * 支持树形结构,排序的实体类,要使用树形结构,排序功能的实体类直接继承该类 + */ +@Getter +@Setter +public abstract class GenericTreeSortSupportEntity extends GenericEntity + implements TreeSortSupportEntity { + /** + * 父级类别 + */ + @Column(name = "parent_id", length = 64) + @Comment("父级ID") + @Schema(description = "父节点ID") + private PK parentId; + + /** + * 树结构编码,用于快速查找, 每一层由4位字符组成,用-分割 + * 如第一层:0001 第二层:0001-0001 第三层:0001-0001-0001 + */ + @Column(name = "path", length = 128) + @Comment("树路径") + @Schema(description = "树结构路径") + @Length(max = 128, message = "目录层级太深") + private String path; + + /** + * 排序索引 + */ + @Column(name = "sort_index", precision = 32) + @Comment("排序序号") + @Schema(description = "排序序号") + private Long sortIndex; + + @Column(name = "_level", precision = 32) + @Comment("树层级") + @Schema(description = "树层级") + private Integer level; + + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ImplementFor.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ImplementFor.java new file mode 100644 index 000000000..725f6fb3a --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/ImplementFor.java @@ -0,0 +1,14 @@ +package org.hswebframework.web.api.crud.entity; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface ImplementFor { + + Class value(); + + Class idType() default Void.class; +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/PagerResult.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/PagerResult.java new file mode 100644 index 000000000..17992f567 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/PagerResult.java @@ -0,0 +1,112 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.core.param.QueryParam; + +import java.io.*; +import java.util.ArrayList; +import java.util.List; + +/** + * 分页查询结果,用于在分页查询时,定义查询结果.如果需要拓展此类,例如自定义json序列化,请使用spi方式定义拓展实现类型: + *
{@code
+ * ---resources
+ * -----|--META-INF
+ * -----|----services
+ * -----|------org.hswebframework.web.api.crud.entity.PagerResult
+ * }
+ *

+ * + * @param 结果类型 + * @author zhouhao + * @since 4.0.0 + */ +@Getter +@Setter +public class PagerResult implements Serializable { + private static final long serialVersionUID = -6171751136953308027L; + + /** + * 创建一个空结果 + * + * @param 结果类型 + * @return PagerResult + */ + public static PagerResult empty() { + return of(0, new ArrayList<>()); + } + + /** + * 创建一个分页结果 + * + * @param total 总数据量 + * @param list 当前页数据列表 + * @param 结果类型 + * @return PagerResult + */ + @SuppressWarnings("all") + public static PagerResult of(int total, List list) { + PagerResult result; + result = EntityFactoryHolder.newInstance(PagerResult.class, PagerResult::new); + result.setTotal(total); + result.setData(list); + return result; + } + + /** + * 创建一个分页结果,并将查询参数中的分页索引等信息填充到分页结果中 + * + * @param total 总数据量 + * @param list 当前页数据列表 + * @param entity 查询参数 + * @param 结果类型 + * @return PagerResult + */ + public static PagerResult of(int total, List list, QueryParam entity) { + PagerResult pagerResult = of(total, list); + pagerResult.setPageIndex(entity.getThinkPageIndex()); + pagerResult.setPageSize(entity.getPageSize()); + return pagerResult; + } + + @Schema(description = "页码") + private int pageIndex; + + @Schema(description = "每页数据量") + private int pageSize; + + @Schema(description = "数据总量") + private int total; + + @Schema(description = "数据列表") + private List data; + + public PagerResult() { + } + + public PagerResult(int total, List data) { + this.total = total; + this.data = data; + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryNoPagingOperation.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryNoPagingOperation.java new file mode 100644 index 000000000..f24aafb69 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryNoPagingOperation.java @@ -0,0 +1,180 @@ +package org.hswebframework.web.api.crud.entity; + + +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.servers.Server; +import org.hswebframework.ezorm.core.param.Term; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.METHOD; + +/** + * 使用注解继承来对swagger接口文档注解的拓展,用来标识接口不支持分页查询参数. + * + * + *

{@code
+ * @GetMapping
+ * @QueryNoPagingOperation(summary="接口说明")
+ * public Flux handleRequest(@Parameter(hidden = true) QueryParamEntity query){
+ *  return service.query(query);
+ * }
+ *
+ * }
+ * + * 注意在参数上注解 {@code @Parameter(hidden=true)} + * @author zhouhao + * @since 4.0.5 + * @see QueryNoPagingOperation#parameters() + */ +@Target({METHOD, ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Operation +public @interface QueryNoPagingOperation { + + /** + * The HTTP method for this operation. + * + * @return the HTTP method of this operation + **/ + @AliasFor(annotation = Operation.class) + String method() default ""; + + /** + * Tags can be used for logical grouping of operations by resources or any other qualifier. + * + * @return the list of tags associated with this operation + **/ + @AliasFor(annotation = Operation.class) + String[] tags() default {}; + + /** + * Provides a brief description of this operation. Should be 120 characters or less for proper visibility in Swagger-UI. + * + * @return a summary of this operation + **/ + @AliasFor(annotation = Operation.class) + String summary() default ""; + + /** + * A verbose description of the operation. + * + * @return a description of this operation + **/ + @AliasFor(annotation = Operation.class) + String description() default ""; + + /** + * Request body associated to the operation. + * + * @return a request body. + */ + @AliasFor(annotation = Operation.class) + RequestBody requestBody() default @RequestBody(); + + /** + * Additional external documentation for this operation. + * + * @return additional documentation about this operation + **/ + @AliasFor(annotation = Operation.class) + ExternalDocumentation externalDocs() default @ExternalDocumentation(); + + /** + * The operationId is used by third-party tools to uniquely identify this operation. + * + * @return the ID of this operation + **/ + @AliasFor(annotation = Operation.class) + String operationId() default ""; + + /** + * An optional array of parameters which will be added to any automatically detected parameters in the method itself. + * + * @return the list of parameters for this operation + **/ + @AliasFor(annotation = Operation.class) + Parameter[] parameters() default { + @Parameter(name = "where", description = "条件表达式,和terms参数冲突", example = "id = 1", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "orderBy", description = "排序表达式,和sorts参数冲突", example = "id desc", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "includes", description = "指定要查询的列,多列使用逗号分隔", example = "id", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "excludes", description = "指定不查询的列,多列使用逗号分隔", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "terms[0].column", description = "指定条件字段", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "terms[0].termType", description = "条件类型", schema = @Schema(implementation = String.class), example = "like", in = ParameterIn.QUERY), + @Parameter(name = "terms[0].type", description = "多个条件组合方式", schema = @Schema(implementation = Term.Type.class), in = ParameterIn.QUERY), + @Parameter(name = "terms[0].value", description = "条件值", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "sorts[0].name", description = "排序字段", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "sorts[0].order", description = "顺序,asc或者desc", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + }; + + /** + * The list of possible responses as they are returned from executing this operation. + * + * @return the list of responses for this operation + **/ + @AliasFor(annotation = Operation.class) + ApiResponse[] responses() default {}; + + /** + * Allows an operation to be marked as deprecated. Alternatively use the @Deprecated annotation + * + * @return whether or not this operation is deprecated + **/ + @AliasFor(annotation = Operation.class) + boolean deprecated() default false; + + /** + * A declaration of which security mechanisms can be used for this operation. + * + * @return the array of security requirements for this Operation + */ + @AliasFor(annotation = Operation.class) + SecurityRequirement[] security() default {}; + + /** + * An alternative server array to service this operation. + * + * @return the list of servers hosting this operation + **/ + @AliasFor(annotation = Operation.class) + Server[] servers() default {}; + + /** + * The list of optional extensions + * + * @return an optional array of extensions + */ + @AliasFor(annotation = Operation.class) + Extension[] extensions() default {}; + + /** + * Allows this operation to be marked as hidden + * + * @return whether or not this operation is hidden + */ + @AliasFor(annotation = Operation.class) + boolean hidden() default false; + + /** + * Ignores JsonView annotations while resolving operations and types. + * + * @return whether or not to ignore JsonView annotations + */ + @AliasFor(annotation = Operation.class) + boolean ignoreJsonView() default false; + +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryOperation.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryOperation.java new file mode 100644 index 000000000..3dd75ab47 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryOperation.java @@ -0,0 +1,181 @@ +package org.hswebframework.web.api.crud.entity; + + +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.servers.Server; +import org.hswebframework.ezorm.core.param.Term; +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.METHOD; + +/** + * 使用注解继承来对swagger接口文档注解的拓展,用来标识接口支持分页查询参数. + * + *
{@code
+ * @GetMapping
+ * @QueryOperation(summary="接口说明")
+ * public Flux handleRequest(@Parameter(hidden = true) QueryParamEntity query){
+ *  return service.query(query);
+ * }
+ *
+ * }
+ * + * @author zhouhao + * @see QueryOperation#parameters() + * @since 4.0.5 + */ +@Target({METHOD, ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Operation +public @interface QueryOperation { + + /** + * The HTTP method for this operation. + * + * @return the HTTP method of this operation + **/ + @AliasFor(annotation = Operation.class) + String method() default ""; + + /** + * Tags can be used for logical grouping of operations by resources or any other qualifier. + * + * @return the list of tags associated with this operation + **/ + @AliasFor(annotation = Operation.class) + String[] tags() default {}; + + /** + * Provides a brief description of this operation. Should be 120 characters or less for proper visibility in Swagger-UI. + * + * @return a summary of this operation + **/ + @AliasFor(annotation = Operation.class) + String summary() default ""; + + /** + * A verbose description of the operation. + * + * @return a description of this operation + **/ + @AliasFor(annotation = Operation.class) + String description() default ""; + + /** + * Request body associated to the operation. + * + * @return a request body. + */ + @AliasFor(annotation = Operation.class) + RequestBody requestBody() default @RequestBody(); + + /** + * Additional external documentation for this operation. + * + * @return additional documentation about this operation + **/ + @AliasFor(annotation = Operation.class) + ExternalDocumentation externalDocs() default @ExternalDocumentation(); + + /** + * The operationId is used by third-party tools to uniquely identify this operation. + * + * @return the ID of this operation + **/ + @AliasFor(annotation = Operation.class) + String operationId() default ""; + + /** + * An optional array of parameters which will be added to any automatically detected parameters in the method itself. + * + * @return the list of parameters for this operation + **/ + @AliasFor(annotation = Operation.class) + Parameter[] parameters() default { + @Parameter(name = "pageSize", description = "每页数量", schema = @Schema(implementation = Integer.class), in = ParameterIn.QUERY), + @Parameter(name = "pageIndex", description = "页码", schema = @Schema(implementation = Integer.class), in = ParameterIn.QUERY), + @Parameter(name = "total", description = "设置了此值后将不重复执行count查询总数", schema = @Schema(implementation = Integer.class), in = ParameterIn.QUERY), + @Parameter(name = "where", description = "条件表达式,和terms参数冲突", example = "id = 1", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "orderBy", description = "排序表达式,和sorts参数冲突", example = "id desc", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "includes", description = "指定要查询的列,多列使用逗号分隔", example = "id", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "excludes", description = "指定不查询的列,多列使用逗号分隔", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "terms[0].column", description = "指定条件字段", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "terms[0].termType", description = "条件类型", schema = @Schema(implementation = String.class), example = "like", in = ParameterIn.QUERY), + @Parameter(name = "terms[0].type", description = "多个条件组合方式", schema = @Schema(implementation = Term.Type.class), in = ParameterIn.QUERY), + @Parameter(name = "terms[0].value", description = "条件值", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "sorts[0].name", description = "排序字段", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + @Parameter(name = "sorts[0].order", description = "顺序,asc或者desc", schema = @Schema(implementation = String.class), in = ParameterIn.QUERY), + }; + + /** + * The list of possible responses as they are returned from executing this operation. + * + * @return the list of responses for this operation + **/ + @AliasFor(annotation = Operation.class) + ApiResponse[] responses() default {}; + + /** + * Allows an operation to be marked as deprecated. Alternatively use the @Deprecated annotation + * + * @return whether or not this operation is deprecated + **/ + @AliasFor(annotation = Operation.class) + boolean deprecated() default false; + + /** + * A declaration of which security mechanisms can be used for this operation. + * + * @return the array of security requirements for this Operation + */ + @AliasFor(annotation = Operation.class) + SecurityRequirement[] security() default {}; + + /** + * An alternative server array to service this operation. + * + * @return the list of servers hosting this operation + **/ + @AliasFor(annotation = Operation.class) + Server[] servers() default {}; + + /** + * The list of optional extensions + * + * @return an optional array of extensions + */ + @AliasFor(annotation = Operation.class) + Extension[] extensions() default {}; + + /** + * Allows this operation to be marked as hidden + * + * @return whether or not this operation is hidden + */ + @AliasFor(annotation = Operation.class) + boolean hidden() default false; + + /** + * Ignores JsonView annotations while resolving operations and types. + * + * @return whether or not to ignore JsonView annotations + */ + @AliasFor(annotation = Operation.class) + boolean ignoreJsonView() default false; + +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryParamEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryParamEntity.java new file mode 100644 index 000000000..edaa3e755 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/QueryParamEntity.java @@ -0,0 +1,271 @@ +package org.hswebframework.web.api.crud.entity; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.ezorm.core.NestConditional; +import org.hswebframework.ezorm.core.dsl.Query; +import org.hswebframework.ezorm.core.param.Param; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.ezorm.core.param.TermType; +import org.hswebframework.web.bean.FastBeanCopier; +import org.springframework.util.StringUtils; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * 查询参数实体,使用easyorm进行动态查询参数构建
+ * 可通过静态方法创建:
+ * 如: + *
+ * {@code
+ *      QueryParamEntity.of("id",id);
+ * }
+ * 
+ *

+ * 或者使用DSL方式来构造: + *

{@code
+ *  QueryParamEntity
+ *  .newQuery()
+ *  .where("id",1)
+ *  .execute(service::query)
+ * }
+ * + * @author zhouhao + * @see QueryParam + * @since 3.0 + */ +@Getter +@Slf4j +public class QueryParamEntity extends QueryParam { + + private static final long serialVersionUID = 8097500947924037523L; + + @Schema(description = "where条件表达式,与terms参数不能共存.语法: name = 张三 and age > 16") + private String where; + + @Schema(description = "orderBy条件表达式,与sorts参数不能共存.语法: age asc,createTime desc") + private String orderBy; + + //总数,设置了此值时,在分页查询的时候将不执行count. + @Setter + @Schema(description = "设置了此值后将不重复执行count查询总数") + private Integer total; + + /** + * @see TermExpressionParser#parse(Map) + * @since 4.0.17 + */ + @Getter + @Schema(description = "使用map方式传递查询条件.与terms参数不能共存.格式: {\"name$like\":\"张三\"}") + private Map filter; + + @Setter + @Schema(description = "是否进行并行分页") + private boolean parallelPager = false; + + @Override + @Hidden + public boolean isForUpdate() { + return super.isForUpdate(); + } + + @Override + @Hidden + public int getThinkPageIndex() { + return super.getThinkPageIndex(); + } + + @Override + @Hidden + public int getPageIndexTmp() { + return super.getPageIndexTmp(); + } + + @Override + @Schema(description = "指定要查询的列") + @Nonnull + public Set getIncludes() { + return super.getIncludes(); + } + + @Override + @Schema(description = "指定不查询的列") + @Nonnull + public Set getExcludes() { + return super.getExcludes(); + } + + /** + * 基于另外一个条件参数来创建查询条件实体 + * + * @param param 参数 + * @return 新的查询条件 + * @since 4.0.14 + */ + public static QueryParamEntity of(Param param) { + if (param instanceof QueryParamEntity) { + return ((QueryParamEntity) param).clone(); + } + return FastBeanCopier.copy(param, new QueryParamEntity()); + } + + /** + * 创建一个空的查询参数实体,该实体无任何参数. + * + * @return 无条件的参数实体 + */ + public static QueryParamEntity of() { + return new QueryParamEntity(); + } + + + /** + * @see QueryParamEntity#of(String, Object) + */ + public static QueryParamEntity of(String field, Object value) { + return of().and(field, TermType.eq, value); + } + + /** + * @since 3.0.4 + */ + public static Query newQuery() { + return Query.of(new QueryParamEntity()); + } + + /** + * @since 3.0.4 + */ + public Query toQuery() { + return Query.of(this); + } + + /** + * 将已有的条件包装到一个嵌套的条件里,并返回一个Query对象.例如: + *
+     *     entity.toNestQuery().and("userId",userId);
+     * 
+ *

+ * 原有条件: name=? or type=? + *

+ * 执行后条件: (name=? or type=?) and userId=? + * + * @see QueryParamEntity#toNestQuery(Consumer) + * @since 3.0.4 + */ + public Query toNestQuery() { + return toNestQuery(null); + } + + /** + * 将已有的条件包装到一个嵌套的条件里,并返回一个Query对象.例如: + *

+     *     entity.toNestQuery(query->query.and("userId",userId));
+     * 
+ *

+ * 原有条件: name=? or type=? + *

+ * 执行后条件: userId=? (name=? or type=?) + * + * @param before 在包装之前执行,将条件包装到已有条件之前 + * @since 3.0.4 + */ + public Query toNestQuery(Consumer> before) { + List terms = getTerms(); + setTerms(new ArrayList<>()); + Query query = toQuery(); + if (null != before) { + before.accept(query); + } + if (terms.isEmpty()) { + return query; + } + return query + .nest() + .each(terms, NestConditional::accept) + .end(); + } + + + /** + * 表达式方式排序 + * + * @param orderBy 表达式 + * @since 4.0.1 + */ + public void setOrderBy(String orderBy) { + this.orderBy = orderBy; + if (!StringUtils.hasText(orderBy)) { + return; + } + setSorts(TermExpressionParser.parseOrder(orderBy)); + } + + /** + * 表达式查询条件,没有SQL注入问题,放心使用 + * + * @param where 表达式 + * @since 4.0.1 + */ + public void setWhere(String where) { + this.where = where; + if (!StringUtils.hasText(where)) { + return; + } + setTerms(TermExpressionParser.parse(where)); + } + + /** + * 设置map格式的过滤条件 + * + * @param filter 过滤条件 + * @see TermExpressionParser#parse(Map) + * @since 4.0.17 + */ + public void setFilter(Map filter) { + this.filter = filter; + if (MapUtils.isNotEmpty(filter)) { + setTerms(TermExpressionParser.parse(filter)); + } + } + + @Override + @Nonnull + public List getTerms() { + List terms = super.getTerms(); + if (CollectionUtils.isEmpty(terms) && StringUtils.hasText(where)) { + setTerms(terms = TermExpressionParser.parse(where)); + } + if (CollectionUtils.isEmpty(terms) && MapUtils.isNotEmpty(filter)) { + setTerms(terms = TermExpressionParser.parse(filter)); + } + return terms; + } + + @SuppressWarnings("unchecked") + public QueryParamEntity noPaging() { + setPaging(false); + return this; + } + + public QueryParamEntity doNotSort() { + this.setSorts(new ArrayList<>()); + return this; + } + + @Override + public QueryParamEntity clone() { + return (QueryParamEntity) super.clone(); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/RecordCreationEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/RecordCreationEntity.java new file mode 100644 index 000000000..69c9d7272 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/RecordCreationEntity.java @@ -0,0 +1,67 @@ +package org.hswebframework.web.api.crud.entity; + + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * 记录创建信息的实体类,包括创建人和创建时间。 + * 此实体类与行级权限控制相关联:只能操作自己创建的数据 + * + * @author zhouhao + * @since 3.0 + */ +public interface RecordCreationEntity extends Entity { + + /** + * @return 创建者ID + */ + String getCreatorId(); + + /** + * 设置创建者ID + * + * @param creatorId 创建者ID + */ + void setCreatorId(String creatorId); + + /** + * 创建时间,UTC时间戳 + * + * @return 创建时间 + * @see System#currentTimeMillis() + */ + Long getCreateTime(); + + /** + * 设置创建时间 ,UTC时间戳 + * + * @param createTime 创建时间 + * @see System#currentTimeMillis() + */ + void setCreateTime(Long createTime); + + /** + * 设置创建者名字,为了兼容,默认不支持记录创建者名字,由具体的实现类进行实现 + * + * @param name 创建者名字 + */ + default void setCreatorName(String name) { + + } + + /** + * 设置创建时间为当前时间 + */ + default void setCreateTimeNow() { + setCreateTime(System.currentTimeMillis()); + } + + /** + * @deprecated 已弃用, 在4.1版本中移除 + */ + @JsonIgnore + @Deprecated + default String getCreatorIdProperty() { + return "creatorId"; + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/RecordModifierEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/RecordModifierEntity.java new file mode 100644 index 000000000..685758856 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/RecordModifierEntity.java @@ -0,0 +1,90 @@ +package org.hswebframework.web.api.crud.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +/** + * 记录修改信息的实体类,包括修改人和修改时间。 + * + * @author zhouhao + * @since 3.0.6 + */ +public interface RecordModifierEntity extends Entity { + + String modifierId = "modifierId"; + String modifyTime = "modifyTime"; + + /** + * 修改人ID + * + * @return 修改人ID + */ + String getModifierId(); + + /** + * 设置修改人ID + * + * @param modifierId 修改人ID + */ + void setModifierId(String modifierId); + + /** + * 设置修改人名字,为了兼容,默认不支持记录修改人名字,由具体的实现类进行实现 + * + * @param modifierName 修改人名字 + */ + default void setModifierName(String modifierName) { + + } + + /** + * @return 修改时间 + */ + Long getModifyTime(); + + /** + * 设置修改时间,UTC时间戳 + * + * @param modifyTime 修改时间 + * @see System#currentTimeMillis() + */ + void setModifyTime(Long modifyTime); + + /** + * 设置修改时间为当前时间 + */ + default void setModifyTimeNow() { + setModifyTime(System.currentTimeMillis()); + } + + /** + * @deprecated 已弃用, 4.1版本中移除 + */ + @JsonIgnore + default String getModifierIdProperty() { + return modifierId; + } + + /** + * 标记不自动更新修改人相关内容 + * + * @param ctx 上下文 + * @return 上下文 + */ + static Context markDoNotUpdate(Context ctx) { + return ctx.put(RecordModifierEntity.class, true); + } + + /** + * 判断上下文是否不更新修改人相关内容 + * + * @param ctx 上下文 + * @return 上下文 + */ + static boolean isDoNotUpdate(ContextView ctx) { + return Boolean.TRUE.equals( + ctx.getOrDefault(RecordModifierEntity.class, false) + ); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/SortSupportEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/SortSupportEntity.java new file mode 100644 index 000000000..657946c8e --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/SortSupportEntity.java @@ -0,0 +1,47 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + +import javax.annotation.Nonnull; + +/** + * 支持排序的实体 + * + * @author zhouhao + * @since 4.0.0 + */ +public interface SortSupportEntity extends Comparable, Entity { + + /** + * @return 排序序号 + */ + Long getSortIndex(); + + /** + * 设置排序序号 + * + * @param sortIndex 排序序号 + */ + void setSortIndex(Long sortIndex); + + @Override + default int compareTo(@Nonnull SortSupportEntity support) { + return Long.compare(getSortIndex() == null ? 0 : getSortIndex(), support.getSortIndex() == null ? 0 : support.getSortIndex()); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TermExpressionParser.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TermExpressionParser.java new file mode 100644 index 000000000..3bbe57073 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TermExpressionParser.java @@ -0,0 +1,278 @@ +package org.hswebframework.web.api.crud.entity; + +import lombok.SneakyThrows; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.ezorm.core.NestConditional; +import org.hswebframework.ezorm.core.dsl.Query; +import org.hswebframework.ezorm.core.param.Sort; +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.ezorm.core.param.TermType; + +import java.net.URLDecoder; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 动态条件表达式解析器 + * name=测试 and age=test + * + * @author zhouhao + * @since 3.0.10 + */ +public class TermExpressionParser { + + /** + * 解析Map为动态条件,map中的key为条件列,value为条件值,如果列以$or$开头则表示or查询. + * + *

{@code
+     *   {
+     *       "name$like":"测试",
+     *       //OR
+     *       "$or$status$in":[1,2,3],
+     *       //嵌套
+     *       "$nest":{
+     *           "age$gt":10,
+     *       }
+     *   }
+     * }
+ * + * @param map map + * @return 条件 + */ + public static List parse(Map map) { + if (MapUtils.isEmpty(map)) { + return Collections.emptyList(); + } + + List terms = new ArrayList<>(map.size()); + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + boolean isOr = false; + Term term = new Term(); + + //嵌套 + if (key.startsWith("$nest") || + (isOr = key.startsWith("$orNest"))) { + @SuppressWarnings("all") + List nest = value instanceof Map ? parse(((Map) value)) : parse(String.valueOf(value)); + term.setTerms(nest); + } + //普通 + else { + if (key.startsWith("$or$")) { + isOr = true; + key = key.substring(4); + } + term.setColumn(key); + term.setValue(value); + } + + if (isOr) { + term.setType(Term.Type.or); + } + terms.add(term); + } + + return terms; + + } + + @SneakyThrows + public static List parse(String expression) { + try { + expression = URLDecoder.decode(expression, "utf-8"); + } catch (Throwable ignore) { + + } + Query conditional = QueryParamEntity.newQuery(); + + NestConditional nest = null; + + // 字符容器 + char[] buf = new char[128]; + // 记录词项的长度, Arrays.copyOf使用 + byte len = 0; + // 空格数量? + byte spaceLen = 0; + // 当前列 + char[] currentColumn = null; + // 当前列对应的值 + char[] currentValue = null; + // 当前条件类型 eq btw in ... + String currentTermType = null; + // 当前链接类型 and / or + String currentType = "and"; + // 是否是引号, 单引号 / 双引号 + byte quotationMarks = 0; + // 表达式字符数组 + char[] all = expression.toCharArray(); + + for (char c : all) { + + if (c == '\'' || c == '"') { + if (quotationMarks != 0) { + // 碰到(结束的)单/双引号, 标志归零, 跳过 + quotationMarks = 0; + continue; + } + // 碰到(开始的)单/双引号, 做记录, 跳过 + quotationMarks++; + continue; + } else if (c == '(') { + nest = (nest == null ? + (currentType.equals("or") ? conditional.orNest() : conditional.nest()) : + (currentType.equals("or") ? nest.orNest() : nest.nest())); + len = 0; + continue; + } else if (c == ')') { + if (nest == null) { + continue; + } + if (null != currentColumn) { + currentValue = Arrays.copyOf(buf, len); + nest.accept(new String(currentColumn), convertTermType(currentTermType), new String(currentValue)); + currentColumn = null; + currentTermType = null; + } + Object end = nest.end(); + nest = end instanceof NestConditional ? ((NestConditional) end) : null; + len = 0; + spaceLen++; + continue; + } else if (c == '=' || c == '>' || c == '<') { + if (currentTermType != null) { + currentTermType += String.valueOf(c); + //spaceLen--; + } else { + currentTermType = String.valueOf(c); + } + + if (currentColumn == null) { + currentColumn = Arrays.copyOf(buf, len); + } + spaceLen++; + len = 0; + continue; + } else if (c == ' ') { + if (len == 0) { + continue; + } + if (quotationMarks != 0) { + // 如果当前字符是空格,并且前面迭代时碰到过单/双引号, 不处理并且添加到buf中 + buf[len++] = c; + continue; + } + spaceLen++; + if (currentColumn == null && (spaceLen == 1 || spaceLen % 5 == 0)) { + currentColumn = Arrays.copyOf(buf, len); + len = 0; + continue; + } + if (null != currentColumn) { + if (null == currentTermType) { + currentTermType = new String(Arrays.copyOf(buf, len)); + len = 0; + continue; + } + currentValue = Arrays.copyOf(buf, len); + if (nest != null) { + nest.accept(new String(currentColumn), convertTermType(currentTermType), new String(currentValue)); + } else { + conditional.accept(new String(currentColumn), convertTermType(currentTermType), new String(currentValue)); + } + currentColumn = null; + currentTermType = null; + len = 0; + continue; + } else if (len == 2 || len == 3) { + String type = new String(Arrays.copyOf(buf, len)); + if (type.equalsIgnoreCase("or")) { + currentType = "or"; + if (nest != null) { + nest.or(); + } else { + conditional.or(); + } + len = 0; + continue; + } else if (type.equalsIgnoreCase("and")) { + currentType = "and"; + if (nest != null) { + nest.and(); + } else { + conditional.and(); + } + len = 0; + continue; + } else { + currentColumn = Arrays.copyOf(buf, len); + len = 0; + spaceLen++; + } + } else { + currentColumn = Arrays.copyOf(buf, len); + len = 0; + spaceLen++; + } + continue; + } + + buf[len++] = c; + } + if (null != currentColumn) { + currentValue = Arrays.copyOf(buf, len); + if (nest != null) { + nest.accept(new String(currentColumn), convertTermType(currentTermType), new String(currentValue)); + } else { + conditional.accept(new String(currentColumn), convertTermType(currentTermType), new String(currentValue)); + } + } + return conditional.getParam().getTerms(); + } + + /** + * 解析排序表达式 + *
+     *     age asc,score desc
+     * 
+ * + * @param expression 表达式 + * @return 排序集合 + * @since 4.0.1 + */ + public static List parseOrder(String expression) { + return Stream.of(expression.split("[,]")) + .map(str -> str.split("[ ]")) + .map(arr -> { + Sort sort = new Sort(); + sort.setName(arr[0]); + if (arr.length > 1 && "desc".equalsIgnoreCase(arr[1])) { + sort.desc(); + } + return sort; + }).collect(Collectors.toList()); + } + + private static String convertTermType(String termType) { + if (termType == null) { + return TermType.eq; + } + switch (termType) { + case "=": + return TermType.eq; + case ">": + return TermType.gt; + case "<": + return TermType.lt; + case ">=": + return TermType.gte; + case "<=": + return TermType.lte; + default: + return termType; + } + + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TransactionManagers.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TransactionManagers.java new file mode 100644 index 000000000..694347d77 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TransactionManagers.java @@ -0,0 +1,15 @@ +package org.hswebframework.web.api.crud.entity; + +public interface TransactionManagers { + + /** + * 响应式的事务管理器 + */ + String reactiveTransactionManager = "connectionFactoryTransactionManager"; + + /** + * JDBC事务管理器 + */ + String jdbcTransactionManager = "transactionManager"; + +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeSortSupportEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeSortSupportEntity.java new file mode 100644 index 000000000..abd07ae63 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeSortSupportEntity.java @@ -0,0 +1,24 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + +/** + * 支持树形结构,排序的实体类,要使用树形结构,排序功能的实体类直接继承该类 + */ +public interface TreeSortSupportEntity extends TreeSupportEntity, SortSupportEntity { +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeSupportEntity.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeSupportEntity.java new file mode 100644 index 000000000..c38a8e6b3 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeSupportEntity.java @@ -0,0 +1,302 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.api.crud.entity; + + +import org.hswebframework.utils.RandomUtil; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.id.IDGenerator; +import org.springframework.util.CollectionUtils; + +import java.util.*; +import java.util.function.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 支持树结构的实体类 + * + * @param 主键类型 + * @author zhouhao + * @since 4.0 + */ +@SuppressWarnings("all") +public interface TreeSupportEntity extends Entity { + + /** + * 获取主键 + * + * @return ID + */ + PK getId(); + + /** + * 设置主键 + * + * @param id ID + */ + void setId(PK id); + + /** + * 获取树路径,树路径表示当前节点所在位置 + * 格式通常为: aBcD-EfgH-iJkl,以-分割,一个分割表示一级. + * 比如: aBcD-EfgH-iJkl表示 当前节点在第三级,上一个节点为EfgH. + * + * @return 树路径 + */ + String getPath(); + + /** + * 设置路径,此值通常不需要手动设置,在进行保存时,由service自动进行分配. + * + * @param path 路径 + * @see TreeSupportEntity#expandTree2List(TreeSupportEntity, IDGenerator) + */ + void setPath(String path); + + /** + * 获取上级ID + * + * @return 上级ID + */ + PK getParentId(); + + /** + * 设置上级节点ID + * + * @param parentId + */ + void setParentId(PK parentId); + + /** + * 获取节点层级 + * + * @return 节点层级 + */ + Integer getLevel(); + + /** + * 设置节点层级 + * + * @return 节点层级 + */ + void setLevel(Integer level); + + /** + * 获取所有子节点,默认情况下此字段只会返回null.可以使用{@link TreeSupportEntity#list2tree(Collection, BiConsumer)}将 + * 列表结构转为树形结构 + * + * @param 当前实体类型 + * @return 自己节点 + */ + > List getChildren(); + + @Override + default void tryValidate(Class... groups) { + Entity.super.tryValidate(groups); + if (getId() != null && Objects.equals(getId(), getParentId())) { + throw new ValidationException("parentId", "子节点ID不能与父节点ID相同"); + } + } + + /** + * 根据path获取父节点的path + * + * @param path path + * @return 父节点path + */ + static String getParentPath(String path) { + if (path == null || path.length() < 4) { + return null; + } + return path.substring(0, path.length() - 5); + } + + static void forEach(Collection list, Consumer consumer) { + Queue queue = new LinkedList<>(list); + Set all = new HashSet<>(); + for (T node = queue.poll(); node != null; node = queue.poll()) { + long hash = System.identityHashCode(node); + if (all.contains(hash)) { + continue; + } + all.add(hash); + consumer.accept(node); + if (!CollectionUtils.isEmpty(node.getChildren())) { + queue.addAll(node.getChildren()); + } + } + } + + static , PK> List expandTree2List(T parent, IDGenerator idGenerator) { + List list = new LinkedList<>(); + expandTree2List(parent, list, idGenerator); + + return list; + } + + static , PK> void expandTree2List(T parent, List target, IDGenerator idGenerator) { + expandTree2List(parent, target, idGenerator, null); + } + + + /** + * 将树形结构转为列表结构,并填充对应的数据。
+ * 如树结构数据: {name:'父节点',children:[{name:'子节点1'},{name:'子节点2'}]}
+ * 解析后:[{id:'id1',name:'父节点',path:'aoSt'},{id:'id2',name:'子节点1',path:'aoSt-oS5a'},{id:'id3',name:'子节点2',path:'aoSt-uGpM'}] + * + * @param root 树结构的根节点 + * @param target 目标集合,转换后的数据将直接添加({@link List#add(Object)})到这个集合. + * @param 继承{@link TreeSupportEntity}的类型 + * @param idGenerator ID生成策略 + * @param 主键类型 + */ + static , PK> void expandTree2List(T root, List target, IDGenerator idGenerator, BiConsumer> childConsumer) { + //尝试设置树路径path + if (root.getPath() == null) { + root.setPath(RandomUtil.randomChar(4)); + } + if (root.getPath() != null) { + root.setLevel(root.getPath().split("[-]").length); + } + //尝试设置排序 + if (root instanceof SortSupportEntity) { + SortSupportEntity sortableRoot = ((SortSupportEntity) root); + Long index = sortableRoot.getSortIndex(); + if (null == index) { + sortableRoot.setSortIndex(1L); + } + } + + //尝试设置id + PK parentId = root.getId(); + if (parentId == null) { + parentId = idGenerator.generate(); + root.setId(parentId); + } + + if (CollectionUtils.isEmpty(root.getChildren())) { + target.add(root); + return; + } + + //所有节点处理队列 + Queue queue = new LinkedList<>(); + queue.add(root); + //已经处理过的节点过滤器 + Set filter = new HashSet<>(); + + for (T parent = queue.poll(); parent != null; parent = queue.poll()) { + if (!filter.add(parent)) { + continue; + } + + //处理子节点 + if (!CollectionUtils.isEmpty(parent.getChildren())) { + long index = 1; + for (TreeSupportEntity child : parent.getChildren()) { + if (child.getId() == null) { + child.setId(idGenerator.generate()); + } + child.setParentId(parent.getId()); + child.setPath(parent.getPath() + "-" + RandomUtil.randomChar(4)); + child.setLevel(child.getPath().split("[-]").length); + + //子节点排序 + if (child instanceof SortSupportEntity && parent instanceof SortSupportEntity) { + SortSupportEntity sortableParent = ((SortSupportEntity) parent); + SortSupportEntity sortableChild = ((SortSupportEntity) child); + if (sortableChild.getSortIndex() == null) { + sortableChild.setSortIndex(sortableParent.getSortIndex() * 100 + index++); + } + } + queue.add((T) child); + } + } + if (childConsumer != null) { + childConsumer.accept(parent, new ArrayList<>()); + } + target.add(parent); + } + } + + /** + * 集合转为树形结构,返回根节点集合 + * + * @param dataList 需要转换的集合 + * @param childConsumer 设置子节点回调 + * @param 树节点类型 + * @param 主键类型 + * @return 树形结构集合 + */ + static , PK> List list2tree(Collection dataList, BiConsumer> childConsumer) { + return list2tree(dataList, childConsumer, (Function, Predicate>) predicate -> node -> node == null || predicate + .getNode(node.getParentId()) == null); + } + + static , PK> List list2tree(Collection dataList, + BiConsumer> childConsumer, + Predicate rootNodePredicate) { + return list2tree(dataList, childConsumer, (Function, Predicate>) predicate -> rootNodePredicate); + } + + /** + * 列表结构转为树结构,并返回根节点集合 + * + * @param dataList 数据集合 + * @param childConsumer 子节点消费接口,用于设置子节点 + * @param predicateFunction 根节点判断函数,传入helper,获取一个判断是否为跟节点的函数 + * @param 元素类型 + * @param 主键类型 + * @return 根节点集合 + */ + static , PK> List list2tree(final Collection dataList, + final BiConsumer> childConsumer, + final Function, Predicate> predicateFunction) { + return TreeUtils.list2tree(dataList, + TreeSupportEntity::getId, + TreeSupportEntity::getParentId, + childConsumer, + (helper, node) -> predicateFunction.apply(helper).test(node)); + } + + /** + * 树结构Helper + * + * @param 节点类型 + * @param 主键类型 + */ + interface TreeHelper { + /** + * 根据主键获取子节点 + * + * @param parentId 节点ID + * @return 子节点集合 + */ + List getChildren(PK parentId); + + /** + * 根据id获取节点 + * + * @param id 节点ID + * @return 节点 + */ + T getNode(PK id); + } +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeUtils.java b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeUtils.java new file mode 100644 index 000000000..2c3e7f123 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/java/org/hswebframework/web/api/crud/entity/TreeUtils.java @@ -0,0 +1,143 @@ +package org.hswebframework.web.api.crud.entity; + +import com.google.common.collect.Maps; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.util.ObjectUtils; + +import java.util.*; +import java.util.function.*; +import java.util.stream.Collectors; + +public class TreeUtils { + + /** + * 树结构转为List + * + * @param nodeList List + * @param children 子节点获取函数 + * @param 节点类型 + * @return List + */ + public static List treeToList(Collection nodeList, + Function> children) { + List list = new ArrayList<>(nodeList.size()); + flatTree(nodeList, children, list::add); + return list; + } + + /** + * 平铺树结构 + * + * @param nodeList 树结构list + * @param children 子节点获取函数 + * @param handler 平铺节点接收函数 + * @param 节点类型 + */ + public static void flatTree(Collection nodeList, + Function> children, + Consumer handler) { + Queue queue = new LinkedList<>(nodeList); + Set distinct = new HashSet<>(); + + while (!queue.isEmpty()) { + N node = queue.poll(); + + if (!distinct.add(node)) { + continue; + } + + Collection childrenList = children.apply(node); + if (CollectionUtils.isNotEmpty(childrenList)) { + queue.addAll(childrenList); + } + + handler.accept(node); + } + + } + + /** + * 列表结构转为树结构,并返回根节点集合. + *

+ * 根节点判断逻辑: parentId为空或者对应的节点数据没有在list中 + * + * @param dataList 数据集合 + * @param childConsumer 子节点消费接口,用于设置子节点 + * @param 元素类型 + * @param 主键类型 + * @return 根节点集合 + */ + public static List list2tree(Collection dataList, + Function idGetter, + Function parentIdGetter, + BiConsumer> childConsumer) { + return list2tree(dataList, + idGetter, + parentIdGetter, + childConsumer, + (helper, node) -> { + PK parentId = parentIdGetter.apply(node); + return ObjectUtils.isEmpty(parentId) + || helper.getNode(parentId) == null; + }); + } + + /** + * 列表结构转为树结构,并返回根节点集合 + * + * @param dataList 数据集合 + * @param childConsumer 子节点消费接口,用于设置子节点 + * @param rootPredicate 根节点判断函数,传入helper,获取一个判断是否为根节点的函数 + * @param 元素类型 + * @param 主键类型 + * @return 根节点集合 + */ + public static List list2tree(Collection dataList, + Function idGetter, + Function parentIdGetter, + BiConsumer> childConsumer, + BiPredicate, N> rootPredicate) { + Objects.requireNonNull(dataList, "source list can not be null"); + Objects.requireNonNull(childConsumer, "child consumer can not be null"); + Objects.requireNonNull(rootPredicate, "root predicate function can not be null"); + int size = dataList.size(); + if (size == 0) { + return new ArrayList<>(0); + } + // id,node + Map cache = Maps.newLinkedHashMapWithExpectedSize(size); + // parentId,children + Map> treeCache = dataList + .stream() + .peek(node -> cache.put(idGetter.apply(node), node)) + .filter(e -> parentIdGetter.apply(e) != null) + .collect(Collectors.groupingBy(parentIdGetter)); + + TreeSupportEntity.TreeHelper helper = new TreeSupportEntity.TreeHelper() { + @Override + public List getChildren(PK parentId) { + return treeCache.get(parentId); + } + + @Override + public N getNode(PK id) { + return cache.get(id); + } + }; + + List list = new ArrayList<>(treeCache.size()); + + for (N node : cache.values()) { + //设置每个节点的子节点 + childConsumer.accept(node, treeCache.get(idGetter.apply(node))); + + //获取根节点 + if (rootPredicate.test(helper, node)) { + list.add(node); + } + } + return list; + } + + +} diff --git a/hsweb-commons/hsweb-commons-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/hsweb-commons/hsweb-commons-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..8e62f1085 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.hswebframework.web.api.crud.entity.EntityFactoryHolderConfiguration \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/ExtendableEntityTest.java b/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/ExtendableEntityTest.java new file mode 100644 index 000000000..82634d3f8 --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/ExtendableEntityTest.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.api.crud.entity; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ExtendableEntityTest { + + + @Test + @SneakyThrows + public void testJson() { + ExtendableEntity entity = new ExtendableEntity<>(); + entity.setId("test"); + entity.setExtension("extName", "test"); + + ObjectMapper mapper = new ObjectMapper(); + + String json = mapper.writerFor(ExtendableEntity.class).writeValueAsString(entity); + + System.out.println(json); + ExtendableEntity decoded = mapper.readerFor(ExtendableEntity.class).readValue(json); + assertNotNull(decoded.getId()); + + assertEquals(entity.getId(), decoded.getId()); + + assertNotNull(decoded.getExtension("extName")); + + assertEquals(entity.getExtension("extName"), decoded.getExtension("extName")); + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/TermExpressionParserTest.java b/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/TermExpressionParserTest.java new file mode 100644 index 000000000..8d7eb276c --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/TermExpressionParserTest.java @@ -0,0 +1,118 @@ +package org.hswebframework.web.api.crud.entity; + +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.ezorm.core.param.TermType; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class TermExpressionParserTest { + + @Test + public void testUrl(){ + List terms = TermExpressionParser.parse("type=email%20and%20provider=test"); + + assertEquals(terms.get(0).getTermType(), TermType.eq); + assertEquals(terms.get(0).getColumn(), "type"); + assertEquals(terms.get(0).getValue(), "email"); + + assertEquals(terms.get(1).getTermType(), TermType.eq); + assertEquals(terms.get(1).getColumn(), "provider"); + assertEquals(terms.get(1).getValue(), "test"); + + } + + @Test + public void testChinese() { + { + List terms = TermExpressionParser.parse("name = 我"); + + assertEquals(terms.get(0).getTermType(), TermType.eq); + assertEquals(terms.get(0).getValue(),"我"); + + } + + { + List terms = TermExpressionParser.parse("name like %我%"); + + assertEquals(terms.get(0).getTermType(), TermType.like); + assertEquals(terms.get(0).getValue(),"%我%"); + + } + } + @Test + public void testMap(){ + Map map = new LinkedHashMap<>(); + map.put("name$like","我"); + + map.put("$or$name","你"); + + map.put("$nest","age = 10"); + + + List terms = TermExpressionParser.parse(map); + + assertEquals(3,terms.size()); + assertEquals("like",terms.get(0).getTermType()); + assertEquals("name",terms.get(0).getColumn()); + assertEquals("我",terms.get(0).getValue()); + + assertEquals(Term.Type.or,terms.get(1).getType()); + assertEquals("name",terms.get(1).getColumn()); + assertEquals("你",terms.get(1).getValue()); + + assertEquals(1,terms.get(2).getTerms().size()); + + assertEquals("age",terms.get(2).getTerms().get(0).getColumn()); + + } + + + @Test + public void test() { + { + List terms = TermExpressionParser.parse("name = 1"); + + assertEquals(terms.get(0).getTermType(), TermType.eq); + + } + +// { +// List terms = TermExpressionParser.parse("name = 1"); +// +// assertEquals(terms.get(0).getTermType(), TermType.not); +// +// } + { + List terms = TermExpressionParser.parse("name > 1"); + + assertEquals(terms.get(0).getTermType(), TermType.gt); + } + + { + List terms = TermExpressionParser.parse("name >= 1"); + + assertEquals(terms.get(0).getTermType(), TermType.gte); + } + + { + List terms = TermExpressionParser.parse("name gte 1 and name not 1"); + + assertEquals(terms.get(0).getTermType(), TermType.gte); + assertEquals(terms.get(1).getTermType(), TermType.not); + } + + { + List terms = TermExpressionParser.parse("name gte 1 and (name not 1 or age gt 0)"); + + assertEquals(terms.get(0).getTermType(), TermType.gte); + assertEquals(terms.get(1).getTerms().get(0).getTermType(), TermType.not); + assertEquals(terms.get(1).getTerms().get(1).getTermType(), TermType.gt); + } + } + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/TreeUtilsTest.java b/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/TreeUtilsTest.java new file mode 100644 index 000000000..3f6fc995c --- /dev/null +++ b/hsweb-commons/hsweb-commons-api/src/test/java/org/hswebframework/web/api/crud/entity/TreeUtilsTest.java @@ -0,0 +1,80 @@ +package org.hswebframework.web.api.crud.entity; + +import com.google.common.collect.Collections2; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class TreeUtilsTest { + + + @Test + public void testTreeToList() { + + Node node1 = new Node(); + node1.setChildren(Arrays.asList(new Node(), new Node())); + + List nodes = TreeUtils.treeToList(Collections.singletonList(node1), + Node::getChildren); + + + assertNotNull(nodes); + assertEquals(3, nodes.size()); + + } + + @Test + public void testListToTree() { + int size = 5; + List nodes = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + Node node = new Node(); + node.setId(String.valueOf(i)); + node.setParenTId(i == 0 ? null : String.valueOf(i - 1)); + nodes.add(node); + } + // 打乱顺序 + Collections.shuffle(nodes); + + // 并发执行,并且创建新的节点 + List tree = TreeUtils + .list2tree(Collections2.transform(nodes, e -> { + Node copy = new Node(); + copy.setId(e.id); + copy.setParenTId(e.parenTId); + copy.setChildren(e.children); + return copy; + }), + Node::getId, + Node::getParenTId, + Node::setChildren, + // 自定义根节点判断 + (helper, e) -> "2".contains(e.getId())); + assertNotNull(tree); + Node children = tree.get(0); + assertNotNull(children); + while (CollectionUtils.isNotEmpty(children.getChildren())) { + children = children.getChildren().get(0); + } + assertEquals("4", children.getId()); + } + + @Getter + @Setter + static class Node { + private String id; + + private String parenTId; + + private List children; + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/pom.xml b/hsweb-commons/hsweb-commons-crud/pom.xml new file mode 100644 index 000000000..b2eb669b5 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/pom.xml @@ -0,0 +1,154 @@ + + + + hsweb-commons + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-commons-crud + + + + + org.hswebframework.web + hsweb-authorization-api + ${project.version} + + + + org.springframework + spring-webflux + true + + + + org.hswebframework.web + hsweb-concurrent-cache + ${project.version} + + + + io.projectreactor + reactor-core + + + + org.hswebframework + hsweb-easy-orm-rdb + + + + org.springframework + spring-tx + + + + org.hswebframework.web + hsweb-core + ${project.version} + + + + org.hibernate.javax.persistence + hibernate-jpa-2.1-api + + + + org.hibernate.validator + hibernate-validator + + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.hswebframework.web + hsweb-datasource-api + ${project.version} + + + + org.springframework + spring-jdbc + true + + + + io.r2dbc + r2dbc-spi + true + + + + org.springframework.data + spring-data-r2dbc + compile + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.google.guava + guava + test + + + + io.r2dbc + r2dbc-h2 + test + + + + com.h2database + h2 + test + + + + org.springframework.boot + spring-boot-starter-data-r2dbc + test + + + + org.springframework + spring-aspects + + + + org.hswebframework.web + hsweb-commons-api + ${project.version} + + + + io.swagger.core.v3 + swagger-annotations + + + + org.springframework + spring-webmvc + true + + + + com.github.jsqlparser + jsqlparser + 4.6 + + + + \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/DDL.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/DDL.java new file mode 100644 index 000000000..e3231652a --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/DDL.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.crud.annotation; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface DDL { + + boolean value() default true; + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/EnableEasyormRepository.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/EnableEasyormRepository.java new file mode 100644 index 000000000..5e2e820e8 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/EnableEasyormRepository.java @@ -0,0 +1,53 @@ +package org.hswebframework.web.crud.annotation; + +import org.hswebframework.web.crud.configuration.EasyormRepositoryRegistrar; +import org.springframework.context.annotation.Import; + +import javax.persistence.Table; +import java.lang.annotation.*; + +/** + * 在启动类上注解,标识开启自动注册实体通用增删改查接口到spring上下文中. + * 在spring中,可直接进行泛型注入使用: + *

{@code
+ *   @Autowire
+ *   ReactiveRepository repository;
+ * }
+ * + * @see org.hswebframework.ezorm.rdb.mapping.ReactiveRepository + * @see org.hswebframework.ezorm.rdb.mapping.SyncRepository + * @since 4.0.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Import({EasyormRepositoryRegistrar.class}) +public @interface EnableEasyormRepository { + + /** + * 实体类包名: + *
+     *     com.company.project.entity
+     * 
+ */ + String[] value(); + + /** + * @see org.hswebframework.ezorm.rdb.mapping.jpa.JpaEntityTableMetadataParser + */ + Class[] annotation() default Table.class; + + /** + * @return 是否开启响应式, 默认开启 + */ + boolean reactive() default true; + + /** + * 是否开启非响应式操作,在使用WebFlux时,不建议开启 + * + * @return 开启非响应式 + * @see org.hswebframework.ezorm.rdb.mapping.SyncRepository + */ + boolean nonReactive() default false; + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/EnableEntityEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/EnableEntityEvent.java new file mode 100644 index 000000000..da724df40 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/EnableEntityEvent.java @@ -0,0 +1,51 @@ +package org.hswebframework.web.crud.annotation; + +import org.hswebframework.web.crud.events.EntityEventType; + +import java.lang.annotation.*; + +//import static org.hswebframework.web.crud.annotation.EnableEntityEvent.Feature.*; + +/** + * 在实体类上添加此注解,表示开启实体操作事件,当实体类发生类修改,更新,删除等操作时,会触发事件。 + * 可以通过spring event监听事件: + *
+ *     @EventListener
+ *     public void handleEvent(EntitySavedEvent<UserEntity> event){
+ *         event
+ *         .async( //组合响应式操作
+ *              deleteByUser(event.getEntity())
+ *         )
+ *     }
+ * 
+ * + * @see org.hswebframework.web.crud.events.EntityModifyEvent + * @see org.hswebframework.web.crud.events.EntityDeletedEvent + * @see org.hswebframework.web.crud.events.EntityCreatedEvent + * @see org.hswebframework.web.crud.events.EntitySavedEvent + * @see org.hswebframework.web.crud.events.EntityBeforeSaveEvent + * @see org.hswebframework.web.crud.events.EntityBeforeModifyEvent + * @see org.hswebframework.web.crud.events.EntityBeforeDeleteEvent + * @see org.hswebframework.web.crud.events.EntityBeforeCreateEvent + * @see org.hswebframework.web.crud.events.EntityBeforeQueryEvent + * @see org.hswebframework.web.crud.events.EntityEventListenerCustomizer + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface EnableEntityEvent { + + /** + * 指定开启的事件类型,也可以通过{@link org.hswebframework.web.crud.events.EntityEventListenerCustomizer}进行自定义 + * @return 事件类型 + * @see org.hswebframework.web.crud.events.EntityEventListenerCustomizer + */ + EntityEventType[] value() default { + EntityEventType.create, + EntityEventType.delete, + EntityEventType.modify, + EntityEventType.save + }; + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/Reactive.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/Reactive.java new file mode 100644 index 000000000..aa7982b48 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/annotation/Reactive.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.crud.annotation; + +import java.lang.annotation.*; + +/** + * 在实体类上注解,标记是否开启响应式仓库 + * + * @author zhouhao + * @see org.hswebframework.ezorm.rdb.mapping.ReactiveRepository + * @since 4.0.0 + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Reactive { + boolean enable() default true; +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/AutoDDLProcessor.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/AutoDDLProcessor.java new file mode 100644 index 000000000..65f4fd4eb --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/AutoDDLProcessor.java @@ -0,0 +1,127 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.metadata.RDBSchemaMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.ddl.CreateTableSqlBuilder; +import org.hswebframework.web.api.crud.entity.EntityFactory; +import org.hswebframework.web.crud.annotation.DDL; +import org.hswebframework.web.crud.entity.factory.MapperEntityFactory; +import org.hswebframework.web.crud.events.EntityDDLEvent; +import org.hswebframework.web.event.GenericsPayloadApplicationEvent; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.annotation.AnnotatedElementUtils; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +@Setter +@Slf4j +public class AutoDDLProcessor implements InitializingBean { + + private Set entities = new HashSet<>(); + + @Autowired + private DatabaseOperator operator; + + @Autowired + private EasyormProperties properties; + + @Autowired + private EntityTableMetadataResolver resolver; + + @Autowired + private EntityFactory entityFactory; + + @Autowired + private ApplicationEventPublisher eventPublisher; + + private boolean reactive; + + @Override + @SneakyThrows + public void afterPropertiesSet() { + + List> readyToDDL = new ArrayList<>(this.entities.size()); + List> nonDDL = new ArrayList<>(); + + for (EntityInfo entity : this.entities) { + Class type = entityFactory.getInstanceType(entity.getRealType(), true); + DDL ddl = AnnotatedElementUtils.findMergedAnnotation(type, DDL.class); + if (properties.isAutoDdl() && (ddl == null || ddl.value())) { + readyToDDL.add(entity.getEntityType()); + } else { + nonDDL.add(entity.getEntityType()); + } + } + + if (!readyToDDL.isEmpty()) { + //加载全部表信息 + if (reactive) { + Flux.fromIterable(readyToDDL) + .doOnNext(type -> log.trace("auto ddl for {}", type)) + .map(type -> { + RDBTableMetadata metadata = resolver.resolve(type); + EntityDDLEvent event = new EntityDDLEvent<>(this, type, metadata); + eventPublisher.publishEvent(new GenericsPayloadApplicationEvent<>(this, event, type)); + return metadata; + }) + .flatMap(meta -> operator + .ddl() + .createOrAlter(meta) + .autoLoad(false) + .commit() + .reactive() + .subscribeOn(Schedulers.boundedElastic()), + 8,8) + .doOnError((err) -> log.error(err.getMessage(), err)) + .then() + .block(Duration.ofMinutes(5)); + } else { + for (Class type : readyToDDL) { + log.trace("auto ddl for {}", type); + try { + RDBTableMetadata metadata = resolver.resolve(type); + EntityDDLEvent event = new EntityDDLEvent<>(this, type, metadata); + eventPublisher.publishEvent(new GenericsPayloadApplicationEvent<>(this, event, type)); + operator.ddl() + .createOrAlter(metadata) + .autoLoad(false) + .commit() + .sync(); + } catch (Exception e) { + log.error(e.getLocalizedMessage(), e); + throw e; + } + } + } + } + + for (Class entity : nonDDL) { + RDBTableMetadata metadata = resolver.resolve(entity); + RDBSchemaMetadata schema = metadata.getSchema(); + RDBTableMetadata table = schema + .getTable(metadata.getName()) + .orElse(null); + if (table == null) { + SqlRequest request = schema.findFeatureNow(CreateTableSqlBuilder.ID).build(metadata); + log.info("DDL SQL for {} \n{}", entity, request.toNativeSql()); + } + schema.addTable(metadata); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/CompositeEntityTableMetadataResolver.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/CompositeEntityTableMetadataResolver.java new file mode 100644 index 000000000..6f496600c --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/CompositeEntityTableMetadataResolver.java @@ -0,0 +1,41 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.mapping.parser.EntityTableMetadataParser; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +public class CompositeEntityTableMetadataResolver implements EntityTableMetadataResolver { + + private final List resolvers = new ArrayList<>(); + + private final Map, AtomicReference> cache = new ConcurrentHashMap<>(); + + public void addParser(EntityTableMetadataParser resolver) { + resolvers.add(resolver); + } + + @Override + public RDBTableMetadata resolve(Class entityClass) { + + return cache.computeIfAbsent(entityClass, type -> new AtomicReference<>(doResolve(type))).get(); + } + + private RDBTableMetadata doResolve(Class entityClass) { + return resolvers + .stream() + .map(resolver -> resolver.parseTableMetadata(entityClass)) + .filter(Optional::isPresent) + .map(Optional::get) + .reduce((t1, t2) -> { + t2.merge(t1); + return t2; + }).orElse(null); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DefaultEntityResultWrapperFactory.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DefaultEntityResultWrapperFactory.java new file mode 100644 index 000000000..20fbec0e2 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DefaultEntityResultWrapperFactory.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; +import org.hswebframework.ezorm.rdb.mapping.EntityManager; +import org.hswebframework.ezorm.rdb.mapping.wrapper.EntityResultWrapper; +import org.hswebframework.ezorm.rdb.mapping.wrapper.NestedEntityResultWrapper; + +@AllArgsConstructor +public class DefaultEntityResultWrapperFactory implements EntityResultWrapperFactory { + + private EntityManager entityManager; + + @Override + @SneakyThrows + public ResultWrapper getWrapper(Class tClass) { + return new NestedEntityResultWrapper<>(entityManager.getMapping(tClass)); + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DetectEntityColumnMapping.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DetectEntityColumnMapping.java new file mode 100644 index 000000000..26115ee33 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DetectEntityColumnMapping.java @@ -0,0 +1,76 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.mapping.EntityColumnMapping; +import org.hswebframework.ezorm.rdb.mapping.MappingFeatureType; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.TableOrViewMetadata; +import org.hswebframework.web.api.crud.entity.EntityFactory; + +import java.util.Map; +import java.util.Optional; + +class DetectEntityColumnMapping implements EntityColumnMapping { + private final String id; + private final Class type; + private final EntityColumnMapping mapping; + private final EntityFactory entityFactory; + + public DetectEntityColumnMapping(Class type, + EntityColumnMapping mapping, + EntityFactory entityFactory) { + this.id = MappingFeatureType.columnPropertyMapping.createFeatureId(type); + this.type = type; + this.mapping = mapping; + this.entityFactory = entityFactory; + } + + @Override + public Class getEntityType() { + return type; + } + + @Override + public Optional getColumnByProperty(String property) { + return mapping.getColumnByProperty(property); + } + + @Override + public Optional getPropertyByColumnName(String columnName) { + return mapping.getPropertyByColumnName(columnName); + } + + @Override + public Optional getColumnByName(String columnName) { + return mapping.getColumnByName(columnName); + } + + @Override + public Map getColumnPropertyMapping() { + return mapping.getColumnPropertyMapping(); + } + + @Override + public TableOrViewMetadata getTable() { + return mapping.getTable(); + } + + @Override + public void reload() { + mapping.reload(); + } + + @Override + public Object newInstance() { + return entityFactory.newInstance(getEntityType()); + } + + @Override + public String getId() { + return id; + } + + @Override + public String getName() { + return getId(); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DialectProvider.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DialectProvider.java new file mode 100644 index 000000000..02d6f0efd --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DialectProvider.java @@ -0,0 +1,57 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.metadata.RDBSchemaMetadata; +import org.hswebframework.ezorm.rdb.metadata.dialect.Dialect; + +/** + * 数据库方言提供商, 通过实现此接口拓展数据库方言. + *

+ * 实现此接口,并使用jdk SPI暴露实现. + *

{@code
+ *   META-INF/services/org.hswebframework.web.crud.configuration.DialectProvider
+ * }
+ * + * @author zhouhao + * @see java.util.ServiceLoader + * @since 4.0.17 + */ +public interface DialectProvider { + + /** + * 方言名称 + * + * @return 方言名称 + */ + String name(); + + /** + * 获取方言实例 + * + * @return 方言实例 + */ + Dialect getDialect(); + + /** + * 获取sql预编译参数绑定符号,如: ? + * + * @return 参数绑定符号 + */ + String getBindSymbol(); + + /** + * 创建一个schema + * + * @param name schema名称 + * @return schema + */ + RDBSchemaMetadata createSchema(String name); + + /** + * 获取验证连接的sql + * + * @return sql + */ + default String getValidationSql() { + return "select 1"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DialectProviders.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DialectProviders.java new file mode 100644 index 000000000..894e9c444 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/DialectProviders.java @@ -0,0 +1,37 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.SneakyThrows; + +import java.util.*; + +public class DialectProviders { + private static final Map allSupportedDialect = new HashMap<>(); + + static { + for (EasyormProperties.DialectEnum value : EasyormProperties.DialectEnum.values()) { + allSupportedDialect.put(value.name(), value); + } + + for (DialectProvider dialectProvider : ServiceLoader.load(DialectProvider.class)) { + allSupportedDialect.put(dialectProvider.name(), dialectProvider); + } + } + + public static List all(){ + return new ArrayList<>(allSupportedDialect.values()); + } + + @SneakyThrows + public static DialectProvider lookup(String dialect) { + DialectProvider provider = allSupportedDialect.get(dialect); + if (provider == null) { + if (dialect.contains(".")) { + provider = (DialectProvider) Class.forName(dialect).newInstance(); + allSupportedDialect.put(dialect, provider); + } else { + throw new UnsupportedOperationException("unsupported dialect : " + dialect + ",all alive dialect :" + allSupportedDialect.keySet()); + } + } + return provider; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormConfiguration.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormConfiguration.java new file mode 100644 index 000000000..43069c441 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormConfiguration.java @@ -0,0 +1,255 @@ +package org.hswebframework.web.crud.configuration; + + +import lombok.SneakyThrows; +import org.hswebframework.ezorm.core.meta.Feature; +import org.hswebframework.ezorm.rdb.events.EventListener; +import org.hswebframework.ezorm.rdb.executor.SyncSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.reactive.ReactiveSqlExecutor; +import org.hswebframework.ezorm.rdb.mapping.EntityColumnMapping; +import org.hswebframework.ezorm.rdb.mapping.EntityManager; +import org.hswebframework.ezorm.rdb.mapping.MappingFeatureType; +import org.hswebframework.ezorm.rdb.mapping.jpa.JpaEntityTableMetadataParser; +import org.hswebframework.ezorm.rdb.mapping.jpa.JpaEntityTableMetadataParserProcessor; +import org.hswebframework.ezorm.rdb.mapping.parser.EntityTableMetadataParser; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBDatabaseMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBSchemaMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.ezorm.rdb.operator.DefaultDatabaseOperator; +import org.hswebframework.web.api.crud.entity.EntityFactory; +import org.hswebframework.web.crud.annotation.EnableEasyormRepository; +import org.hswebframework.web.crud.entity.factory.EntityMappingCustomizer; +import org.hswebframework.web.crud.entity.factory.MapperEntityFactory; +import org.hswebframework.web.crud.events.*; +import org.hswebframework.web.crud.events.expr.SpelSqlExpressionInvoker; +import org.hswebframework.web.crud.generator.*; +import org.hswebframework.web.crud.query.DefaultQueryHelper; +import org.hswebframework.web.crud.query.QueryHelper; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +@AutoConfiguration +@EnableConfigurationProperties(EasyormProperties.class) +@EnableEasyormRepository("org.hswebframework.web.**.entity") +public class EasyormConfiguration { + + static { + + } + + @Bean + @ConditionalOnMissingBean + public EntityFactory entityFactory(ObjectProvider customizers) { + MapperEntityFactory factory = new MapperEntityFactory(); + for (EntityMappingCustomizer customizer : customizers) { + customizer.custom(factory); + } + return factory; + } + + @Bean + @ConditionalOnMissingBean + @SuppressWarnings("all") + public RDBDatabaseMetadata databaseMetadata(Optional syncSqlExecutor, + Optional reactiveSqlExecutor, + EasyormProperties properties) { + RDBDatabaseMetadata metadata = properties.createDatabaseMetadata(); + syncSqlExecutor.ifPresent(metadata::addFeature); + reactiveSqlExecutor.ifPresent(metadata::addFeature); + if (properties.isAutoDdl() && reactiveSqlExecutor.isPresent()) { + for (RDBSchemaMetadata schema : metadata.getSchemas()) { + schema.loadAllTableReactive() + .block(Duration.ofSeconds(30)); + } + } + return metadata; + } + + @Bean + @ConditionalOnMissingBean + public DatabaseOperator databaseOperator(RDBDatabaseMetadata metadata) { + + return DefaultDatabaseOperator.of(metadata); + } + + @Bean + public QueryHelper queryHelper(DatabaseOperator databaseOperator) { + return new DefaultQueryHelper(databaseOperator); + } + + @Bean + public BeanPostProcessor autoRegisterFeature(RDBDatabaseMetadata metadata) { + CompositeEventListener eventListener = new CompositeEventListener(); + metadata.addFeature(eventListener); + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + + if (bean instanceof EventListener) { + eventListener.addListener(((EventListener) bean)); + } else if (bean instanceof Feature) { + metadata.addFeature(((Feature) bean)); + } + + return bean; + } + }; + } + + + @Bean + public CreatorEventListener creatorEventListener() { + return new CreatorEventListener(); + } + + + @Bean + public ValidateEventListener validateEventListener() { + return new ValidateEventListener(); + } + + @Bean + public EntityEventListener entityEventListener(ApplicationEventPublisher eventPublisher, + ObjectProvider invokers, + ObjectProvider customizers) { + DefaultEntityEventListenerConfigure configure = new DefaultEntityEventListenerConfigure(); + customizers.forEach(customizer -> customizer.customize(configure)); + EntityEventListener entityEventListener = new EntityEventListener(eventPublisher, configure); + entityEventListener.setExpressionInvoker(invokers.getIfAvailable(SpelSqlExpressionInvoker::new)); + + return entityEventListener; + } + + @Bean + @ConfigurationProperties(prefix = "easyorm.default-value-generator") + public DefaultIdGenerator defaultIdGenerator() { + + return new DefaultIdGenerator(); + } + + @Bean + public MD5Generator md5Generator() { + return new MD5Generator(); + } + + @Bean + public SnowFlakeStringIdGenerator snowFlakeStringIdGenerator() { + return new SnowFlakeStringIdGenerator(); + } + + @Bean + public RandomIdGenerator randomIdGenerator() { + return new RandomIdGenerator(); + } + + @Bean + public CurrentTimeGenerator currentTimeGenerator() { + return new CurrentTimeGenerator(); + } + + @Configuration + public static class EntityTableMetadataParserConfiguration { + + @Bean + public DefaultEntityResultWrapperFactory defaultEntityResultWrapperFactory(EntityManager entityManager) { + return new DefaultEntityResultWrapperFactory(entityManager); + } + + @Bean + @ConditionalOnMissingBean + public EntityManager entityManager(EntityTableMetadataResolver resolver, EntityFactory entityFactory) { + return new EntityManager() { + @Override + @SneakyThrows + public E newInstance(Class type) { + return entityFactory.newInstance(type); + } + + @Override + public EntityColumnMapping getMapping(Class entity) { + + return resolver.resolve(entity) + .getFeature(MappingFeatureType.columnPropertyMapping.createFeatureId(entity)) + .map(EntityColumnMapping.class::cast) + .orElse(null); + } + }; + } + + @Bean + @ConditionalOnMissingBean + public EntityTableMetadataResolver entityTableMappingResolver(ObjectProvider parsers) { + CompositeEntityTableMetadataResolver resolver = new CompositeEntityTableMetadataResolver(); + parsers.forEach(resolver::addParser); + return resolver; + } + + @Bean + @ConditionalOnMissingBean + public EntityTableMetadataParser jpaEntityTableMetadataParser(RDBDatabaseMetadata metadata, + EntityFactory factory, + ObjectProvider customizers) { + + JpaEntityTableMetadataParser parser = new JpaEntityTableMetadataParser() { + + @Override + public Optional parseTableMetadata(Class entityType) { + Class realType = factory.getInstanceType(entityType, true); + Optional tableOpt = super.parseTableMetadata(realType); + tableOpt.ifPresent(table -> { + EntityColumnMapping columnMapping = table.findFeatureNow( + MappingFeatureType.columnPropertyMapping.createFeatureId(realType) + ); + if (realType != entityType) { + table.addFeature(new DetectEntityColumnMapping(realType, columnMapping, factory)); + table.addFeature(columnMapping = new DetectEntityColumnMapping(entityType, columnMapping, factory)); + } + for (TableMetadataCustomizer customizer : customizers) { + customizer.customTable(realType, table); + } + columnMapping.reload(); + }); + return tableOpt; + } + + @Override + protected JpaEntityTableMetadataParserProcessor createProcessor(RDBTableMetadata table, Class type) { + Class realType = factory.getInstanceType(type, true); + return new JpaEntityTableMetadataParserProcessor(table, realType) { + @Override + protected void customColumn(PropertyDescriptor descriptor, + Field field, + RDBColumnMetadata column, + Set annotations) { + super.customColumn(descriptor, field, column, annotations); + for (TableMetadataCustomizer customizer : customizers) { + customizer.customColumn(realType, descriptor, field, annotations, column); + } + } + }; + } + }; + parser.setDatabaseMetadata(metadata); + + return parser; + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormProperties.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormProperties.java new file mode 100644 index 000000000..5f49d51c3 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormProperties.java @@ -0,0 +1,118 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.*; +import org.hswebframework.ezorm.rdb.metadata.RDBDatabaseMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBSchemaMetadata; +import org.hswebframework.ezorm.rdb.metadata.dialect.Dialect; +import org.hswebframework.ezorm.rdb.supports.h2.H2SchemaMetadata; +import org.hswebframework.ezorm.rdb.supports.mssql.SqlServerSchemaMetadata; +import org.hswebframework.ezorm.rdb.supports.mysql.MysqlSchemaMetadata; +import org.hswebframework.ezorm.rdb.supports.oracle.OracleSchemaMetadata; +import org.hswebframework.ezorm.rdb.supports.postgres.PostgresqlSchemaMetadata; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.*; + +@ConfigurationProperties(prefix = "easyorm") +@Data +public class EasyormProperties { + + private String defaultSchema = "PUBLIC"; + + private String[] schemas = {}; + + private boolean autoDdl = true; + + private boolean allowAlter = false; + + private boolean allowTypeAlter = true; + + /** + * @see DialectProvider + */ + private DialectProvider dialect = DialectEnum.h2; + + @Deprecated + private Class dialectType; + + @Deprecated + private Class schemaType; + + @SneakyThrows + public void setDialect(String dialect) { + this.dialect = DialectProviders.lookup(dialect); + } + + public RDBDatabaseMetadata createDatabaseMetadata() { + RDBDatabaseMetadata metadata = new RDBDatabaseMetadata(createDialect()); + + Set schemaSet = new HashSet<>(Arrays.asList(schemas)); + if (defaultSchema != null) { + schemaSet.add(defaultSchema); + } + schemaSet.stream() + .map(this::createSchema) + .forEach(metadata::addSchema); + + metadata.getSchema(defaultSchema) + .ifPresent(metadata::setCurrentSchema); + + return metadata; + } + + @SneakyThrows + public RDBSchemaMetadata createSchema(String name) { + return dialect.createSchema(name); + } + + @SneakyThrows + public Dialect createDialect() { + return dialect.getDialect(); + } + + @Getter + @AllArgsConstructor + public enum DialectEnum implements DialectProvider { + mysql(Dialect.MYSQL, "?") { + @Override + public RDBSchemaMetadata createSchema(String name) { + return new MysqlSchemaMetadata(name); + } + }, + mssql(Dialect.MSSQL, "@arg") { + @Override + public RDBSchemaMetadata createSchema(String name) { + return new SqlServerSchemaMetadata(name); + } + }, + oracle(Dialect.ORACLE, "?") { + @Override + public RDBSchemaMetadata createSchema(String name) { + return new OracleSchemaMetadata(name); + } + + @Override + public String getValidationSql() { + return "select 1 from dual"; + } + }, + postgres(Dialect.POSTGRES, "$") { + @Override + public RDBSchemaMetadata createSchema(String name) { + return new PostgresqlSchemaMetadata(name); + } + }, + h2(Dialect.H2, "$") { + @Override + public RDBSchemaMetadata createSchema(String name) { + return new H2SchemaMetadata(name); + } + }, + ; + + private final Dialect dialect; + private final String bindSymbol; + + public abstract RDBSchemaMetadata createSchema(String name); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormRepositoryRegistrar.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormRepositoryRegistrar.java new file mode 100644 index 000000000..ba21b8c41 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EasyormRepositoryRegistrar.java @@ -0,0 +1,212 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.rdb.mapping.defaults.DefaultReactiveRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.DefaultSyncRepository; +import org.hswebframework.utils.ClassUtils; +import org.hswebframework.web.crud.annotation.EnableEasyormRepository; +import org.hswebframework.web.api.crud.entity.ImplementFor; +import org.hswebframework.web.crud.annotation.Reactive; +import org.hswebframework.web.api.crud.entity.GenericEntity; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.context.index.CandidateComponentsIndex; +import org.springframework.context.index.CandidateComponentsIndexLoader; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.classreading.SimpleMetadataReaderFactory; +import org.springframework.util.ReflectionUtils; + +import javax.persistence.Table; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +public class EasyormRepositoryRegistrar implements ImportBeanDefinitionRegistrar { + + private final ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver(); + + private final MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(); + + private String getResourceClassName(Resource resource) { + try { + return metadataReaderFactory + .getMetadataReader(resource) + .getClassMetadata() + .getClassName(); + } catch (IOException e) { + return null; + } + } + + @SneakyThrows + private Stream doGetResources(String packageStr) { + String path = ResourcePatternResolver + .CLASSPATH_ALL_URL_PREFIX + .concat(packageStr.replace(".", "/")).concat("/**/*.class"); + return Arrays.stream(resourcePatternResolver.getResources(path)); + } + + protected Set scanEntities(String[] packageStr) { + CandidateComponentsIndex index = CandidateComponentsIndexLoader.loadIndex(org.springframework.util.ClassUtils.getDefaultClassLoader()); + if (null == index) { + return Stream + .of(packageStr) + .flatMap(this::doGetResources) + .map(this::getResourceClassName) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + return Stream + .of(packageStr) + .flatMap(pkg -> index.getCandidateTypes(pkg, Table.class.getName()).stream()) + .collect(Collectors.toSet()); + } + + private Class findIdType(Class entityType) { + Class idType; + try { + if (GenericEntity.class.isAssignableFrom(entityType)) { + return GenericTypeResolver.resolveTypeArgument(entityType, GenericEntity.class); + } + + Class[] ref = new Class[1]; + ReflectionUtils.doWithFields(entityType, field -> { + if (field.isAnnotationPresent(javax.persistence.Id.class)) { + ref[0] = field.getType(); + } + }); + idType = ref[0]; + + if (idType == null) { + Method getId = org.springframework.util.ClassUtils.getMethod(entityType, "getId"); + idType = getId.getReturnType(); + } + } catch (Throwable e) { + log.warn("unknown id type of entity:{}", entityType); + idType = String.class; + } + + return idType; + + } + + @Override + @SneakyThrows + @SuppressWarnings("all") + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + + Map attr = importingClassMetadata.getAnnotationAttributes(EnableEasyormRepository.class.getName()); + if (attr == null) { + return; + } + boolean reactiveEnabled = Boolean.TRUE.equals(attr.get("reactive")); + boolean nonReactiveEnabled = Boolean.TRUE.equals(attr.get("nonReactive")); + + String[] arr = (String[]) attr.get("value"); + + Class[] anno = (Class[]) attr.get("annotation"); + + Set entityInfos = ConcurrentHashMap.newKeySet(); + CandidateComponentsIndex index = CandidateComponentsIndexLoader.loadIndex(org.springframework.util.ClassUtils.getDefaultClassLoader()); + for (String className : scanEntities(arr)) { + Class entityType = org.springframework.util.ClassUtils.forName(className, null); + if (Arrays.stream(anno) + .noneMatch(ann -> AnnotationUtils.getAnnotation(entityType, ann) != null)) { + continue; + } + + Reactive reactive = AnnotationUtils.findAnnotation(entityType, Reactive.class); + + Class idType = findIdType(entityType); + + EntityInfo entityInfo = new EntityInfo(entityType, + entityType, + idType, + reactiveEnabled, + nonReactiveEnabled); + if (!entityInfos.contains(entityInfo)) { + entityInfos.add(entityInfo); + } + + } + for (EntityInfo entityInfo : entityInfos) { + Class entityType = entityInfo.getEntityType(); + Class idType = entityInfo.getIdType(); + Class realType = entityInfo.getRealType(); + if (entityInfo.isReactive()) { + String beanName = entityType.getSimpleName().concat("ReactiveRepository"); + log.trace("Register bean ReactiveRepository<{},{}> {}", entityType.getName(), idType.getSimpleName(), beanName); + + ResolvableType repositoryType = ResolvableType.forClassWithGenerics(DefaultReactiveRepository.class, entityType, idType); + + RootBeanDefinition definition = new RootBeanDefinition(); + definition.setTargetType(repositoryType); + definition.setBeanClass(ReactiveRepositoryFactoryBean.class); + definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); + definition.getPropertyValues().add("entityType", entityType); + if (!registry.containsBeanDefinition(beanName)) { + registry.registerBeanDefinition(beanName, definition); + } else { + entityInfos.remove(entityInfo); + } + } + if (entityInfo.isNonReactive()) { + String beanName = entityType.getSimpleName().concat("SyncRepository"); + log.trace("Register bean SyncRepository<{},{}> {}", entityType.getName(), idType.getSimpleName(), beanName); + + ResolvableType repositoryType = ResolvableType.forClassWithGenerics(DefaultSyncRepository.class, entityType, idType); + RootBeanDefinition definition = new RootBeanDefinition(); + definition.setTargetType(repositoryType); + definition.setBeanClass(SyncRepositoryFactoryBean.class); + definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); + definition.getPropertyValues().add("entityType", entityType); + if (!registry.containsBeanDefinition(beanName)) { + registry.registerBeanDefinition(beanName, definition); + } else { + entityInfos.remove(entityInfo); + } + } + + } + + Map> group = entityInfos + .stream() + .collect(Collectors.groupingBy(EntityInfo::isReactive, Collectors.toSet())); + + for (Map.Entry> entry : group.entrySet()) { + RootBeanDefinition definition = new RootBeanDefinition(); + definition.setTargetType(AutoDDLProcessor.class); + definition.setBeanClass(AutoDDLProcessor.class); + definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); + definition.getPropertyValues().add("entities", entityInfos); + definition.getPropertyValues().add("reactive", entry.getKey()); + definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + definition.setSynthetic(true); + registry.registerBeanDefinition(AutoDDLProcessor.class.getName() + "_" + count.incrementAndGet(), definition); + } + + } + + static AtomicInteger count = new AtomicInteger(); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityInfo.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityInfo.java new file mode 100644 index 000000000..c9ca892f6 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityInfo.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@EqualsAndHashCode(of = "entityType") +@AllArgsConstructor +public class EntityInfo { + private Class entityType; + + private Class realType; + + private Class idType; + + private boolean reactive; + + private boolean nonReactive; +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityResultWrapperFactory.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityResultWrapperFactory.java new file mode 100644 index 000000000..f0ee5da86 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityResultWrapperFactory.java @@ -0,0 +1,8 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; + +public interface EntityResultWrapperFactory { + + ResultWrapper getWrapper(Class tClass); +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityTableMetadataResolver.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityTableMetadataResolver.java new file mode 100644 index 000000000..e031c2555 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/EntityTableMetadataResolver.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; + +public interface EntityTableMetadataResolver { + + RDBTableMetadata resolve(Class entityClass); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/JdbcSqlExecutorConfiguration.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/JdbcSqlExecutorConfiguration.java new file mode 100644 index 000000000..f7ebe67bd --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/JdbcSqlExecutorConfiguration.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.executor.SyncSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.reactive.ReactiveSqlExecutor; +import org.hswebframework.web.crud.sql.DefaultJdbcExecutor; +import org.hswebframework.web.crud.sql.DefaultJdbcReactiveExecutor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@AutoConfiguration +@AutoConfigureAfter(DataSourceAutoConfiguration.class) +@ConditionalOnBean(DataSource.class) +public class JdbcSqlExecutorConfiguration { + @Bean + @ConditionalOnMissingBean + public SyncSqlExecutor syncSqlExecutor() { + return new DefaultJdbcExecutor(); + } + + @Bean + @ConditionalOnMissingBean + public ReactiveSqlExecutor reactiveSqlExecutor() { + return new DefaultJdbcReactiveExecutor(); + } + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/R2dbcSqlExecutorConfiguration.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/R2dbcSqlExecutorConfiguration.java new file mode 100644 index 000000000..a317a7a3c --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/R2dbcSqlExecutorConfiguration.java @@ -0,0 +1,34 @@ +package org.hswebframework.web.crud.configuration; + +import io.r2dbc.spi.ConnectionFactory; +import org.hswebframework.ezorm.rdb.executor.SyncSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.reactive.ReactiveSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.reactive.ReactiveSyncSqlExecutor; +import org.hswebframework.web.crud.sql.DefaultR2dbcExecutor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@AutoConfiguration +@AutoConfigureAfter(name = "org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration") +@ConditionalOnBean(ConnectionFactory.class) +public class R2dbcSqlExecutorConfiguration { + @Bean + @ConditionalOnMissingBean + public ReactiveSqlExecutor reactiveSqlExecutor(EasyormProperties properties) { + DefaultR2dbcExecutor executor = new DefaultR2dbcExecutor(); + executor.setBindSymbol(properties.getDialect().getBindSymbol()); + executor.setBindCustomSymbol(!executor.getBindSymbol().equals("?")); + return executor; + } + + @Bean + @ConditionalOnMissingBean + public SyncSqlExecutor syncSqlExecutor(ReactiveSqlExecutor reactiveSqlExecutor) { + return ReactiveSyncSqlExecutor.of(reactiveSqlExecutor); + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/ReactiveRepositoryFactoryBean.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/ReactiveRepositoryFactoryBean.java new file mode 100644 index 000000000..381f2f945 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/ReactiveRepositoryFactoryBean.java @@ -0,0 +1,48 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.DefaultReactiveRepository; +import org.hswebframework.ezorm.rdb.metadata.RDBSchemaMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; + +@Getter +@Setter +public class ReactiveRepositoryFactoryBean + implements FactoryBean> { + + @Autowired + private DatabaseOperator operator; + + @Autowired + private EntityTableMetadataResolver resolver; + + private Class entityType; + + @Autowired + private EntityResultWrapperFactory wrapperFactory; + + @Override + public ReactiveRepository getObject() { + RDBTableMetadata table = resolver.resolve(entityType); + return new DefaultReactiveRepository<>( + operator, + table.getName(), + entityType, + wrapperFactory.getWrapper(entityType)); + } + + @Override + public Class getObjectType() { + return ReactiveRepository.class; + } + + @Override + public boolean isSingleton() { + return true; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/SyncRepositoryFactoryBean.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/SyncRepositoryFactoryBean.java new file mode 100644 index 000000000..1a873aec2 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/SyncRepositoryFactoryBean.java @@ -0,0 +1,45 @@ +package org.hswebframework.web.crud.configuration; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.DefaultSyncRepository; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; + +@Getter +@Setter +public class SyncRepositoryFactoryBean + implements FactoryBean> { + + @Autowired + private DatabaseOperator operator; + + @Autowired + private EntityTableMetadataResolver resolver; + + @Autowired + private EntityResultWrapperFactory wrapperFactory; + + private Class entityType; + + @Override + public SyncRepository getObject() { + + return new DefaultSyncRepository<>(operator, + resolver.resolve(entityType), + entityType, + wrapperFactory.getWrapper(entityType)); + } + + @Override + public Class getObjectType() { + return SyncRepository.class; + } + + @Override + public boolean isSingleton() { + return true; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/TableMetadataCustomizer.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/TableMetadataCustomizer.java new file mode 100644 index 000000000..f31786433 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/configuration/TableMetadataCustomizer.java @@ -0,0 +1,41 @@ +package org.hswebframework.web.crud.configuration; + +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; + +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.Set; + +/** + * 表结构自定义器,实现此接口来自定义表结构. + * + * @author zhouhao + * @since 4.0.14 + */ +public interface TableMetadataCustomizer { + + /** + * 自定义列,在列被解析后调用. + * + * @param entityType 实体类型 + * @param descriptor 字段描述 + * @param field 字段 + * @param column 列定义 + * @param annotations 字段上的注解 + */ + void customColumn(Class entityType, + PropertyDescriptor descriptor, + Field field, + Set annotations, + RDBColumnMetadata column); + + /** + * 自定义表,在实体类被解析完成后调用. + * + * @param entityType 字段类型 + * @param table 表结构 + */ + void customTable(Class entityType, RDBTableMetadata table); +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/DefaultMapperFactory.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/DefaultMapperFactory.java new file mode 100644 index 000000000..bb828b1a0 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/DefaultMapperFactory.java @@ -0,0 +1,12 @@ +package org.hswebframework.web.crud.entity.factory; + +import java.util.function.Function; + +/** + * 默认的实体映射 + * + * @author zhouhao + */ +@FunctionalInterface +public interface DefaultMapperFactory extends Function { +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/DefaultPropertyCopier.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/DefaultPropertyCopier.java new file mode 100644 index 000000000..6dba6d76d --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/DefaultPropertyCopier.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.crud.entity.factory; + +/** + * 默认的属性复制器 + * + * @author zhouhao + */ +@FunctionalInterface +public interface DefaultPropertyCopier extends PropertyCopier { +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/EntityMappingCustomizer.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/EntityMappingCustomizer.java new file mode 100644 index 000000000..986d1a480 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/EntityMappingCustomizer.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.crud.entity.factory; + +public interface EntityMappingCustomizer { + + void custom(MapperEntityFactory factory); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java new file mode 100644 index 000000000..ab998d404 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java @@ -0,0 +1,275 @@ +/* + * + * * Copyright 2019 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.crud.entity.factory; + +import lombok.SneakyThrows; +import org.hswebframework.utils.ClassUtils; +import org.hswebframework.web.api.crud.entity.EntityFactory; +import org.hswebframework.web.exception.NotFoundException; +import org.hswebframework.web.bean.BeanFactory; +import org.hswebframework.web.bean.FastBeanCopier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * @author zhouhao + * @since 3.0 + */ +@SuppressWarnings("unchecked") +public class MapperEntityFactory implements EntityFactory, BeanFactory { + @SuppressWarnings("all") + private final Map, Mapper> realTypeMapper = new ConcurrentHashMap<>(); + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + @SuppressWarnings("all") + private final Map copierCache = new ConcurrentHashMap<>(); + + private static final DefaultMapperFactory DEFAULT_MAPPER_FACTORY = clazz -> { + String simpleClassName = clazz.getPackage().getName().concat(".Simple").concat(clazz.getSimpleName()); + try { + return defaultMapper(org.springframework.util.ClassUtils.forName(simpleClassName, null)); + } catch (ClassNotFoundException ignore) { + // throw new NotFoundException(e.getMessage()); + } + return null; + }; + + /** + * 默认的属性复制器 + */ + private static final DefaultPropertyCopier DEFAULT_PROPERTY_COPIER = FastBeanCopier::copy; + + private DefaultMapperFactory defaultMapperFactory = DEFAULT_MAPPER_FACTORY; + + private DefaultPropertyCopier defaultPropertyCopier = DEFAULT_PROPERTY_COPIER; + + + public MapperEntityFactory() { + } + + public MapperEntityFactory(Map, Mapper> realTypeMapper) { + this.realTypeMapper.putAll(realTypeMapper); + } + + public MapperEntityFactory addMapping(Class target, Supplier mapper) { + realTypeMapper.put(target, new Mapper(mapper.get().getClass(), mapper)); + return this; + } + + public MapperEntityFactory addMappingIfAbsent(Class target, Supplier mapper) { + realTypeMapper.putIfAbsent(target, new Mapper(mapper.get().getClass(), mapper)); + return this; + } + + public MapperEntityFactory addMapping(Class target, Mapper mapper) { + realTypeMapper.put(target, mapper); + return this; + } + + public MapperEntityFactory addMappingIfAbsent(Class target, Mapper mapper) { + realTypeMapper.putIfAbsent(target, mapper); + return this; + } + + public MapperEntityFactory addCopier(PropertyCopier copier) { + Class source = (Class) ClassUtils.getGenericType(copier.getClass(), 0); + Class target = (Class) ClassUtils.getGenericType(copier.getClass(), 1); + if (source == null || source == Object.class) { + throw new UnsupportedOperationException("generic type " + source + " not support"); + } + if (target == null || target == Object.class) { + throw new UnsupportedOperationException("generic type " + target + " not support"); + } + addCopier(source, target, copier); + return this; + } + + public MapperEntityFactory addCopier(Class source, Class target, PropertyCopier copier) { + copierCache.put(getCopierCacheKey(source, target), copier); + return this; + } + + private String getCopierCacheKey(Class source, Class target) { + return source.getName().concat("->").concat(target.getName()); + } + + @Override + public T copyProperties(S source, T target) { + Objects.requireNonNull(source); + Objects.requireNonNull(target); + try { + PropertyCopier copier = copierCache.get(getCopierCacheKey(source.getClass(), target.getClass())); + if (null != copier) { + return copier.copyProperties(source, target); + } + + return (T) defaultPropertyCopier.copyProperties(source, target); + } catch (Throwable e) { + logger.warn("copy properties error", e); + } + return target; + } + + static final Mapper NON_MAPPER = new Mapper(null, null); + + protected Mapper createMapper(Class beanClass) { + Mapper mapper = null; + Class realType = null; + ServiceLoader serviceLoader = ServiceLoader.load(beanClass, this.getClass().getClassLoader()); + Iterator iterator = serviceLoader.iterator(); + if (iterator.hasNext()) { + realType = (Class) iterator.next().getClass(); + } + + if (realType == null) { + if (!Modifier.isInterface(beanClass.getModifiers()) && !Modifier.isAbstract(beanClass.getModifiers())) { + realType = beanClass; + } else { + mapper = defaultMapperFactory.apply(beanClass); + } + } + + if (mapper == null && realType != null) { + if (logger.isDebugEnabled() && realType != beanClass) { + logger.debug("use instance {} for {}", realType, beanClass); + } + mapper = new Mapper<>(realType, new DefaultInstanceGetter<>(realType)); + } + + return mapper == null ? NON_MAPPER : mapper; + } + + @Override + public T newInstance(Class beanClass) { + return newInstance(beanClass, (Class) null); + } + + @Override + public T newInstance(Class entityClass, Supplier defaultFactory) { + if (entityClass == null) { + return null; + } + Mapper mapper = realTypeMapper.computeIfAbsent(entityClass, this::createMapper); + if (mapper != null && mapper != NON_MAPPER) { + return mapper.getInstanceGetter().get(); + } + return defaultFactory.get(); + } + + @Override + public T newInstance(Class beanClass, Class defaultClass) { + if (beanClass == null) { + return null; + } + Mapper mapper = realTypeMapper.computeIfAbsent(beanClass, this::createMapper); + if (mapper != null && mapper != NON_MAPPER) { + return mapper.getInstanceGetter().get(); + } + if (defaultClass != null) { + return newInstance(defaultClass); + } + if (Map.class == beanClass) { + return (T) new HashMap<>(); + } + if (List.class == beanClass) { + return (T) new ArrayList<>(); + } + if (Set.class == beanClass) { + return (T) new HashSet<>(); + } + + throw new NotFoundException("error.cant_create_instance", beanClass); + } + + @Override + @SuppressWarnings("unchecked") + public Class getInstanceType(Class beanClass, boolean autoRegister) { + if (beanClass == null + || beanClass.isPrimitive() + || beanClass.isArray() + || beanClass.isEnum()) { + return null; + } + Mapper mapper = realTypeMapper.computeIfAbsent( + beanClass, + clazz -> autoRegister ? createMapper(clazz) : null); + + if (null != mapper && mapper != NON_MAPPER) { + return mapper.getTarget(); + } + return Modifier.isAbstract(beanClass.getModifiers()) + || Modifier.isInterface(beanClass.getModifiers()) + ? null : beanClass; + } + + public void setDefaultMapperFactory(DefaultMapperFactory defaultMapperFactory) { + Objects.requireNonNull(defaultMapperFactory); + this.defaultMapperFactory = defaultMapperFactory; + } + + public void setDefaultPropertyCopier(DefaultPropertyCopier defaultPropertyCopier) { + this.defaultPropertyCopier = defaultPropertyCopier; + } + + public static class Mapper { + final Class target; + final Supplier instanceGetter; + + public Mapper(Class target, Supplier instanceGetter) { + this.target = target; + this.instanceGetter = instanceGetter; + } + + public Class getTarget() { + return target; + } + + public Supplier getInstanceGetter() { + return instanceGetter; + } + } + + public static Mapper defaultMapper(Class target) { + return new Mapper<>(target, defaultInstanceGetter(target)); + } + + public static Supplier defaultInstanceGetter(Class clazz) { + return new DefaultInstanceGetter<>(clazz); + } + + static class DefaultInstanceGetter implements Supplier { + final Constructor constructor; + + @SneakyThrows + public DefaultInstanceGetter(Class type) { + this.constructor = type.getConstructor(); + } + + @Override + @SneakyThrows + public T get() { + return constructor.newInstance(); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/PropertyCopier.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/PropertyCopier.java new file mode 100644 index 000000000..871ee51ce --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/PropertyCopier.java @@ -0,0 +1,11 @@ +package org.hswebframework.web.crud.entity.factory; + +/** + * 属性复制接口,用于自定义属性复制 + * + * @author zhouhao + * @since 3.0 + */ +public interface PropertyCopier { + T copyProperties(S source, T target); +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/CompositeEventListener.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/CompositeEventListener.java new file mode 100644 index 000000000..f07ece5db --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/CompositeEventListener.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.crud.events; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.rdb.events.EventContext; +import org.hswebframework.ezorm.rdb.events.EventListener; +import org.hswebframework.ezorm.rdb.events.EventType; +import org.springframework.core.Ordered; + +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +@Getter +@Setter +public class CompositeEventListener implements EventListener { + + private List eventListeners = new CopyOnWriteArrayList<>(); + + @Override + public void onEvent(EventType type, EventContext context) { + for (EventListener eventListener : eventListeners) { + eventListener.onEvent(type, context); + } + } + + public void addListener(EventListener eventListener) { + eventListeners.add(eventListener); + eventListeners.sort(Comparator.comparingLong(e -> e instanceof Ordered ? ((Ordered) e).getOrder() : Ordered.LOWEST_PRECEDENCE)); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/CreatorEventListener.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/CreatorEventListener.java new file mode 100644 index 000000000..772ca00cb --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/CreatorEventListener.java @@ -0,0 +1,147 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.ezorm.rdb.events.EventContext; +import org.hswebframework.ezorm.rdb.events.EventListener; +import org.hswebframework.ezorm.rdb.events.EventType; +import org.hswebframework.ezorm.rdb.mapping.events.MappingContextKeys; +import org.hswebframework.ezorm.rdb.mapping.events.MappingEventTypes; +import org.hswebframework.ezorm.rdb.mapping.events.ReactiveResultHolder; +import org.hswebframework.web.api.crud.entity.Entity; +import org.hswebframework.web.api.crud.entity.RecordCreationEntity; +import org.hswebframework.web.api.crud.entity.RecordModifierEntity; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.validator.CreateGroup; +import org.hswebframework.web.validator.UpdateGroup; +import org.springframework.core.Ordered; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import static org.springframework.data.repository.util.ClassUtils.ifPresent; + +/** + * 自动填充创建人和修改人信息 + */ +public class CreatorEventListener implements EventListener, Ordered { + + @Override + public String getId() { + return "creator-listener"; + } + + @Override + public String getName() { + return "创建者监听器"; + } + + @Override + public void onEvent(EventType type, EventContext context) { + Optional resultHolder = context.get(MappingContextKeys.reactiveResultHolder); + if (type == MappingEventTypes.insert_before + || type == MappingEventTypes.save_before + || type == MappingEventTypes.update_before) { + if (resultHolder.isPresent()) { + ReactiveResultHolder holder = resultHolder.get(); + + holder + .before( + Mono.deferContextual(ctx -> Authentication + .currentReactive() + .doOnNext(auth -> doApplyCreator(ctx, type, context, auth)) + .then()) + ); + } else { + Authentication + .current() + .ifPresent(auth -> doApplyCreator(Context.empty(), type, context, auth)); + } + } + } + + protected void doApplyCreator(ContextView ctx, EventType type, EventContext context, Authentication auth) { + Object instance = context.get(MappingContextKeys.instance).orElse(null); + boolean applyUpdate = !RecordModifierEntity.isDoNotUpdate(ctx); + if (instance != null) { + if (instance instanceof Collection) { + applyCreator(auth, context, ((Collection) instance), + type != MappingEventTypes.update_before,applyUpdate); + } else { + applyCreator(auth, context, instance, + type != MappingEventTypes.update_before,applyUpdate); + } + } + + context + .get(MappingContextKeys.updateColumnInstance) + .ifPresent(map -> applyCreator(auth, context, map, type != MappingEventTypes.update_before,applyUpdate)); + + } + + public void applyCreator(Authentication auth, + EventContext context, + Object entity, + boolean updateCreator, + boolean updateModifier) { + long now = System.currentTimeMillis(); + if (updateCreator) { + if (entity instanceof RecordCreationEntity) { + RecordCreationEntity e = (RecordCreationEntity) entity; + if (ObjectUtils.isEmpty(e.getCreatorId())) { + e.setCreatorId(auth.getUser().getId()); + e.setCreatorName(auth.getUser().getName()); + } + if (e.getCreateTime() == null) { + e.setCreateTime(now); + } + } else if (entity instanceof Map) { + @SuppressWarnings("all") + Map map = ((Map) entity); + map.putIfAbsent("creator_id", auth.getUser().getId()); + map.putIfAbsent("creator_name", auth.getUser().getName()); + map.putIfAbsent("create_time", now); + } + + + } + if (updateModifier){ + if (entity instanceof RecordModifierEntity) { + RecordModifierEntity e = (RecordModifierEntity) entity; + if (ObjectUtils.isEmpty(e.getModifierId())) { + e.setModifierId(auth.getUser().getId()); + e.setModifierName(auth.getUser().getName()); + } + if (e.getModifyTime() == null) { + e.setModifyTime(now); + } + } else if (entity instanceof Map) { + @SuppressWarnings("all") + Map map = ((Map) entity); + map.putIfAbsent("modifier_id", auth.getUser().getId()); + map.putIfAbsent("modifier_name", auth.getUser().getName()); + map.putIfAbsent("modify_time", now); + + } + } + + } + + public void applyCreator(Authentication auth, EventContext context, Collection entities, boolean updateCreator,boolean updateModifier) { + for (Object entity : entities) { + applyCreator(auth, context, entity, updateCreator,updateModifier); + } + + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/DefaultEntityEventListenerConfigure.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/DefaultEntityEventListenerConfigure.java new file mode 100644 index 000000000..1a8ee5002 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/DefaultEntityEventListenerConfigure.java @@ -0,0 +1,112 @@ +package org.hswebframework.web.crud.events; + +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.web.api.crud.entity.Entity; +import org.hswebframework.web.crud.annotation.EnableEntityEvent; +import org.springframework.core.annotation.AnnotatedElementUtils; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class DefaultEntityEventListenerConfigure implements EntityEventListenerConfigure { + + private final Map, Map>> enabledFeatures = new ConcurrentHashMap<>(); + private final Map, Map>> disabledFeatures = new ConcurrentHashMap<>(); + + @Override + public void enable(Class entityType) { + initByEntity(entityType, getOrCreateTypeMap(entityType, enabledFeatures), true); + } + + @Override + public void disable(Class entityType) { + enabledFeatures.remove(entityType); + initByEntity(entityType, getOrCreateTypeMap(entityType, disabledFeatures), true); + } + + @Override + public void enable(Class entityType, EntityEventType type, EntityEventPhase... feature) { + if (feature.length == 0) { + feature = EntityEventPhase.all; + } + getOrCreatePhaseSet(type, getOrCreateTypeMap(entityType, enabledFeatures)) + .addAll(Arrays.asList(feature)); + + //删除disabled + Arrays.asList(feature) + .forEach(getOrCreatePhaseSet(type, getOrCreateTypeMap(entityType, disabledFeatures))::remove); + } + + @Override + public void disable(Class entityType, EntityEventType type, EntityEventPhase... feature) { + if (feature.length == 0) { + feature = EntityEventPhase.all; + } + getOrCreatePhaseSet(type, getOrCreateTypeMap(entityType, disabledFeatures)) + .addAll(Arrays.asList(feature)); + //删除enabled + Arrays.asList(feature) + .forEach(getOrCreatePhaseSet(type, getOrCreateTypeMap(entityType, enabledFeatures))::remove); + } + + protected Map> getOrCreateTypeMap(Class type, + Map, Map>> map) { + return map.computeIfAbsent(type, ignore -> new EnumMap<>(EntityEventType.class)); + } + + protected Set getOrCreatePhaseSet(EntityEventType type, + Map> map) { + return map.computeIfAbsent(type, ignore -> EnumSet.noneOf(EntityEventPhase.class)); + } + + protected void initByEntity(Class type, + Map> typeSetMap, + boolean all) { + EnableEntityEvent annotation = AnnotatedElementUtils.findMergedAnnotation(type, EnableEntityEvent.class); + EntityEventType[] types = annotation != null ? annotation.value() : all ? EntityEventType.values() : new EntityEventType[0]; + + for (EntityEventType entityEventType : types) { + Set phases = getOrCreatePhaseSet(entityEventType, typeSetMap); + phases.addAll(Arrays.asList(EntityEventPhase.values())); + } + } + + @Override + public boolean isEnabled(Class entityType) { + Map> enabled = initByEntityType(entityType); + return MapUtils.isNotEmpty(enabled); + } + + @Override + public boolean isEnabled(Class entityType, + EntityEventType type, + EntityEventPhase phase) { + Map> enabled = initByEntityType(entityType); + if (MapUtils.isEmpty(enabled)) { + return false; + } + Map> disabled = disabledFeatures.get(entityType); + Set phases = enabled.get(type); + if (phases != null && phases.contains(phase)) { + if (disabled != null) { + Set disabledPhases = disabled.get(type); + return disabledPhases == null || !disabledPhases.contains(phase); + } + return true; + } + + return false; + } + + private Map> initByEntityType(Class entityType) { + return enabledFeatures + .compute(entityType, (k, v) -> { + if (v != null) { + return v; + } + v = new EnumMap<>(EntityEventType.class); + initByEntity(k, v, false); + return v; + }); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeCreateEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeCreateEvent.java new file mode 100644 index 000000000..4cc2bbeee --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeCreateEvent.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityBeforeCreateEvent extends DefaultAsyncEvent implements Serializable { + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityBeforeCreateEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeDeleteEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeDeleteEvent.java new file mode 100644 index 000000000..c05f1ad87 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeDeleteEvent.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @param + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + */ +@AllArgsConstructor +@Getter +public class EntityBeforeDeleteEvent extends DefaultAsyncEvent implements Serializable { + + private static final long serialVersionUID = -7158901204884303777L; + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityBeforeDeleteEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeModifyEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeModifyEvent.java new file mode 100644 index 000000000..d94508536 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeModifyEvent.java @@ -0,0 +1,30 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @param + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + */ +@AllArgsConstructor +@Getter +public class EntityBeforeModifyEvent extends DefaultAsyncEvent implements Serializable { + + private static final long serialVersionUID = -7158901204884303777L; + + private final List before; + + private final List after; + + private final Class entityType; + + @Override + public String toString() { + return "EntityBeforeModifyEvent<" + entityType.getSimpleName() + ">\n{\nbefore:" + before + "\nafter: " + after + "\n}"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeQueryEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeQueryEvent.java new file mode 100644 index 000000000..f163f1834 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeQueryEvent.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityBeforeQueryEvent extends DefaultAsyncEvent implements Serializable { + + private final QueryParam param; + + private final Class entityType; + + @Override + public String toString() { + return "EntityBeforeQueryEvent<" + entityType.getSimpleName() + ">"+param; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeSaveEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeSaveEvent.java new file mode 100644 index 000000000..582e8778b --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityBeforeSaveEvent.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityBeforeSaveEvent extends DefaultAsyncEvent implements Serializable { + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityBeforeSaveEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityCreatedEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityCreatedEvent.java new file mode 100644 index 000000000..11415b627 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityCreatedEvent.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityCreatedEvent extends DefaultAsyncEvent implements Serializable { + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityCreatedEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityDDLEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityDDLEvent.java new file mode 100644 index 000000000..3711a620c --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityDDLEvent.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.crud.events; + +import lombok.Getter; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; +import org.springframework.context.ApplicationEvent; + +@Getter +public class EntityDDLEvent extends ApplicationEvent { + private final Class type; + + private final RDBTableMetadata table; + + public EntityDDLEvent(Object source,Class type,RDBTableMetadata table) { + super(source); + this.type=type; + this.table=table; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityDeletedEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityDeletedEvent.java new file mode 100644 index 000000000..7fcd6906e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityDeletedEvent.java @@ -0,0 +1,30 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; + +/** + * @param + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + */ +@AllArgsConstructor +@Getter +public class EntityDeletedEvent extends DefaultAsyncEvent implements Serializable { + + private static final long serialVersionUID = -7158901204884303777L; + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityDeletedEvent<" + entityType.getSimpleName() + ">"+entity; + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventHelper.java new file mode 100644 index 000000000..8de961632 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventHelper.java @@ -0,0 +1,140 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.web.api.crud.entity.Entity; +import org.hswebframework.web.event.AsyncEvent; +import org.hswebframework.web.event.GenericsPayloadApplicationEvent; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * 实体事件帮助器 + * + * @author zhouhao + * @since 4.0.12 + */ +public class EntityEventHelper { + + private static final String doEventContextKey = EntityEventHelper.class.getName() + "_doEvent"; + + /** + * 判断当前是否设置了事件 + * + * @param defaultIfEmpty 如果未设置时的默认值 + * @return 是否设置了事件 + */ + public static Mono isDoFireEvent(boolean defaultIfEmpty) { + return Mono + .deferContextual(ctx -> Mono.justOrEmpty(ctx.getOrEmpty(doEventContextKey))) + .defaultIfEmpty(defaultIfEmpty); + } + + public static Mono tryFireEvent(Supplier> task) { + return Mono + .deferContextual(ctx -> { + if (Boolean.TRUE.equals(ctx.getOrDefault(doEventContextKey, true))) { + return task.get(); + } + return Mono.empty(); + }); + } + + /** + * 设置Mono不触发实体类事件 + * + *
+     *     save(...)
+     *     .as(EntityEventHelper::setDoNotFireEvent)
+     * 
+ * + * @param stream 流 + * @param 泛型 + * @return 流 + */ + public static Mono setDoNotFireEvent(Mono stream) { + return stream.contextWrite(Context.of(doEventContextKey, false)); + } + + /** + * 设置Flux不触发实体类事件 + *
+     *     fetch()
+     *     .as(EntityEventHelper::setDoNotFireEvent)
+     * 
+ * + * @param stream 流 + * @param 泛型 + * @return 流 + */ + public static Flux setDoNotFireEvent(Flux stream) { + return stream.contextWrite(Context.of(doEventContextKey, false)); + } + + public static Mono publishSavedEvent(Object source, + Class entityType, + List entities, + Consumer>> publisher) { + return publishEvent(source, entityType, () -> new EntitySavedEvent<>(entities, entityType), publisher); + } + + public static Mono publishModifyEvent(Object source, + Class entityType, + List before, + Consumer afterTransfer, + Consumer>> publisher) { + return publishEvent(source, + entityType, + () -> new EntityModifyEvent<>(before, + before + .stream() + .map(t -> t.copyTo(entityType)) + .peek(afterTransfer) + .collect(Collectors.toList()), + entityType), + publisher); + } + + public static Mono publishModifyEvent(Object source, + Class entityType, + List before, + List after, + Consumer>> publisher) { + //没有数据被更新则不触发事件 + if (before.isEmpty()) { + return Mono.empty(); + } + return publishEvent(source, entityType, () -> new EntityModifyEvent<>(before, after, entityType), publisher); + } + + public static Mono publishDeletedEvent(Object source, + Class entityType, + List entities, + Consumer>> publisher) { + return publishEvent(source, entityType, () -> new EntityDeletedEvent<>(entities, entityType), publisher); + } + + public static Mono publishCreatedEvent(Object source, + Class entityType, + List entities, + Consumer>> publisher) { + return publishEvent(source, entityType, () -> new EntityCreatedEvent<>(entities, entityType), publisher); + } + + public static Mono publishEvent(Object source, + Class entityType, + Supplier eventSupplier, + Consumer> publisher) { + E event = eventSupplier.get(); + if (event == null) { + return Mono.empty(); + } + publisher.accept(new GenericsPayloadApplicationEvent<>(source, event, entityType)); + return event.getAsync(); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListener.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListener.java new file mode 100644 index 000000000..8dae712ff --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListener.java @@ -0,0 +1,589 @@ +package org.hswebframework.web.crud.events; + + +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.ezorm.core.GlobalConfig; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.ezorm.rdb.events.EventListener; +import org.hswebframework.ezorm.rdb.events.*; +import org.hswebframework.ezorm.rdb.executor.NullValue; +import org.hswebframework.ezorm.rdb.mapping.*; +import org.hswebframework.ezorm.rdb.mapping.events.MappingContextKeys; +import org.hswebframework.ezorm.rdb.mapping.events.MappingEventTypes; +import org.hswebframework.ezorm.rdb.mapping.events.ReactiveResultHolder; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.NativeSql; +import org.hswebframework.ezorm.rdb.operator.dml.update.UpdateOperator; +import org.hswebframework.web.api.crud.entity.Entity; +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.event.AsyncEvent; +import org.hswebframework.web.event.GenericsPayloadApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.Ordered; +import reactor.core.publisher.Mono; +import reactor.function.Function3; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import static org.hswebframework.web.crud.events.EntityEventHelper.publishEvent; + +@SuppressWarnings("all") +@RequiredArgsConstructor +public class EntityEventListener implements EventListener, Ordered { + + public static final ContextKey> readyToDeleteContextKey = ContextKey.of("readyToDelete"); + //更新前的数据 + public static final ContextKey> readyToUpdateBeforeContextKey = ContextKey.of("readyToUpdateBefore"); + //更新后的数据 + public static final ContextKey> readyToUpdateAfterContextKey = ContextKey.of("readyToUpdateAfter"); + + private final ApplicationEventPublisher eventPublisher; + + private final EntityEventListenerConfigure listenerConfigure; + + @Setter + private SqlExpressionInvoker expressionInvoker; + + @Override + public String getId() { + return "entity-listener"; + } + + @Override + public String getName() { + return "实体变更事件监听器"; + } + + @Override + public void onEvent(EventType type, EventContext context) { + + if (context.get(MappingContextKeys.error).isPresent()) { + return; + } + EntityColumnMapping mapping = context.get(MappingContextKeys.columnMapping).orElse(null); + Class entityType; + + if (mapping == null || + !Entity.class.isAssignableFrom(entityType = (Class) mapping.getEntityType()) || + !listenerConfigure.isEnabled(entityType)) { + return; + } + + if (type == MappingEventTypes.select_before) { + handleQueryBefore(mapping, context); + } + if (type == MappingEventTypes.insert_before) { + boolean single = context.get(MappingContextKeys.type).map("single"::equals).orElse(false); + if (single) { + handleSingleOperation(mapping.getEntityType(), + EntityEventType.create, + context, + EntityPrepareCreateEvent::new, + EntityBeforeCreateEvent::new, + EntityCreatedEvent::new); + } else { + handleBatchOperation(mapping.getEntityType(), + EntityEventType.create, + context, + EntityPrepareCreateEvent::new, + EntityBeforeCreateEvent::new, + EntityCreatedEvent::new); + } + } + if (type == MappingEventTypes.save_before) { + boolean single = context.get(MappingContextKeys.type).map("single"::equals).orElse(false); + if (single) { + handleSingleOperation(mapping.getEntityType(), + EntityEventType.save, + context, + + EntityPrepareSaveEvent::new, + EntityBeforeSaveEvent::new, + EntitySavedEvent::new); + } else { + handleBatchOperation(mapping.getEntityType(), + EntityEventType.save, + context, + EntityPrepareSaveEvent::new, + EntityBeforeSaveEvent::new, + EntitySavedEvent::new); + } + } + if (type == MappingEventTypes.update_before) { + handleUpdateBefore(context); + } + if (type == MappingEventTypes.delete_before) { + handleDeleteBefore(entityType, context); + } + } + + protected void handleQueryBefore(EntityColumnMapping mapping, EventContext context) { + context.get(MappingContextKeys.reactiveResultHolder) + .ifPresent(holder -> { + context.get(MappingContextKeys.queryOaram) + .ifPresent(queryParam -> { + EntityBeforeQueryEvent event = new EntityBeforeQueryEvent<>(queryParam, mapping.getEntityType()); + eventPublisher.publishEvent(new GenericsPayloadApplicationEvent<>(this, event, mapping.getEntityType())); + holder + .before( + event.getAsync() + ); + }); + }); + } + + protected List createAfterData(List olds, + EventContext context) { + List newValues = new ArrayList<>(olds.size()); + + EntityColumnMapping mapping = context + .get(MappingContextKeys.columnMapping) + .orElseThrow(UnsupportedOperationException::new); + + Map columns = context + .get(MappingContextKeys.updateColumnInstance) + .orElse(Collections.emptyMap()); + + for (Object old : olds) { + Map oldMap = null; + Object data = FastBeanCopier.copy(old, mapping.newInstance()); + for (Map.Entry entry : columns.entrySet()) { + + RDBColumnMetadata column = mapping.getColumnByName(entry.getKey()).orElse(null); + if (column == null) { + continue; + } + + Object value = entry.getValue(); + + //set null + if (value instanceof NullValue) { + value = null; + } + //原生sql + if (value instanceof NativeSql) { + value = expressionInvoker == null ? null : expressionInvoker.invoke( + ((NativeSql) value), + mapping, + oldMap == null ? oldMap = createFullMapping(old, mapping) : oldMap); + if (value == null) { + continue; + } + } + + GlobalConfig + .getPropertyOperator() + .setProperty(data, column.getAlias(), value); + + } + newValues.add(data); + } + return newValues; + } + + protected Map createFullMapping(Object old, EntityColumnMapping mapping) { + Map map = FastBeanCopier.copy(old, new HashMap<>()); + + for (RDBColumnMetadata column : mapping.getTable().getColumns()) { + if (map.containsKey(column.getAlias())) { + map.put(column.getName(), map.get(column.getAlias())); + } + } + + return map; + } + + protected Mono sendUpdateEvent(List before, + List after, + Class type, + Function3, List, Class, AsyncEvent> mapper) { + + return publishEvent(this, + type, + () -> mapper.apply(before, after, type), + eventPublisher::publishEvent); + } + + protected Mono sendDeleteEvent(List olds, + Class type, + BiFunction, Class, AsyncEvent> eventBuilder) { + return publishEvent(this, + type, + () -> eventBuilder.apply(olds, type), + eventPublisher::publishEvent); + } + + // 回填修改后的字段到准备更新的数据中 + // 用于实现通过事件来修改即将被修改的数据 + protected void prepareUpdateInstance(List before, List after, EventContext ctx) { + Map instance = ctx + .get(MappingContextKeys.updateColumnInstance) + .orElse(null); + if (before.size() != 1 || after.size() != 1 || instance == null) { + //不支持一次性更新多条数据时设置. + return; + } + EntityColumnMapping mapping = ctx + .get(MappingContextKeys.columnMapping) + .orElseThrow(UnsupportedOperationException::new); + + Object afterEntity = after.get(0); + Object beforeEntity = before.get(0); + Map copy = new HashMap<>(instance); + + Map afterMap = FastBeanCopier.copy(afterEntity, new HashMap<>()); + Map beforeMap = FastBeanCopier.copy(beforeEntity, new HashMap<>()); + + //设置实体类中指定的字段值 + for (Map.Entry entry : afterMap.entrySet()) { + RDBColumnMetadata column = mapping.getColumnByProperty(entry.getKey()).orElse(null); + if (column == null || !column.isUpdatable()) { + continue; + } + + //原始值 + Object origin = copy.remove(column.getAlias()); + if (origin == null) { + origin = copy.remove(column.getName()); + } + //没有指定原始值,说明是通过事件指定的. + if (origin == null) { + //值相同忽略更新,可能是事件并没有修改这个字段. + if (Objects.equals(beforeMap.get(column.getAlias()), entry.getValue()) || + Objects.equals(beforeMap.get(column.getName()), entry.getValue())) { + continue; + } + } + + //按sql更新 忽略 + if (origin instanceof NativeSql) { + continue; + } + //设置新的值 + instance.put(column.getAlias(), entry.getValue()); + } + + DSLUpdate operator = ctx + .get(ContextKeys.>source()) + .orElse(null); + + if (operator != null && MapUtils.isNotEmpty(copy)) { + for (Map.Entry entry : copy.entrySet()) { + Object val = entry.getValue(); + if (val instanceof NullValue || val instanceof NativeSql) { + continue; + } + operator.excludes(entry.getKey()); + } + + } + + } + + protected void handleUpdateBefore(DSLUpdate update, EventContext context) { + Object repo = context.get(MappingContextKeys.repository).orElse(null); + EntityColumnMapping mapping = context + .get(MappingContextKeys.columnMapping) + .orElseThrow(UnsupportedOperationException::new); + Class entityType = (Class) mapping.getEntityType(); + if (repo instanceof ReactiveRepository) { + ReactiveResultHolder holder = context.get(MappingContextKeys.reactiveResultHolder).orElse(null); + if (holder != null) { + AtomicReference, List>> updated = new AtomicReference<>(); + //prepare + if (isEnabled(entityType, + EntityEventType.modify, + EntityEventPhase.prepare, + EntityEventPhase.before, + EntityEventPhase.after)) { + holder.before( + this.doAsyncEvent(() -> ((ReactiveRepository) repo) + .createQuery() + .setParam(update.toQueryParam()) + .fetch() + .collectList() + .flatMap((list) -> { + //没有数据被修改则不触发事件 + if (list.isEmpty()) { + return Mono.empty(); + } + List after = createAfterData(list, context); + updated.set(Tuples.of(list, after)); + context.set(readyToUpdateBeforeContextKey, list); + context.set(readyToUpdateAfterContextKey, after); + EntityPrepareModifyEvent event = new EntityPrepareModifyEvent(list, after, entityType); + + return sendUpdateEvent(list, + after, + entityType, + (_list, _after, _type) -> event) + .then(Mono.fromRunnable(() -> { + if (event.hasListener()) { + prepareUpdateInstance(list, after, context); + } + })); + + }).then()) + ); + } + //before + if (isEnabled(entityType, EntityEventType.modify, EntityEventPhase.before)) { + holder.invoke(this.doAsyncEvent(() -> { + Tuple2, List> _tmp = updated.get(); + if (_tmp != null) { + return sendUpdateEvent(_tmp.getT1(), + _tmp.getT2(), + entityType, + EntityBeforeModifyEvent::new); + } + return Mono.empty(); + })); + } + + //after + if (isEnabled(entityType, EntityEventType.modify, EntityEventPhase.after)) { + holder.after(v -> this + .doAsyncEvent(() -> { + Tuple2, List> _tmp = updated.getAndSet(null); + if (_tmp != null) { + return sendUpdateEvent(_tmp.getT1(), + _tmp.getT2(), + entityType, + EntityModifyEvent::new); + } + return Mono.empty(); + })); + } + } + } else if (repo instanceof SyncRepository) { + if (isEnabled(entityType, EntityEventType.modify, EntityEventPhase.before)) { + QueryParam param = update.toQueryParam(); + SyncRepository syncRepository = ((SyncRepository) repo); + List list = syncRepository.createQuery() + .setParam(param) + .fetch(); + if (list.isEmpty()) { + return; + } + sendUpdateEvent(list, + createAfterData(list, context), + (Class) mapping.getEntityType(), + EntityBeforeModifyEvent::new) + .block(); + } + } + } + + protected void handleUpdateBefore(EventContext context) { + context.>get(ContextKeys.source()) + .ifPresent(dslUpdate -> { + handleUpdateBefore(dslUpdate, context); + }); + + } + + protected void handleDeleteBefore(Class entityType, EventContext context) { + EntityColumnMapping mapping = context + .get(MappingContextKeys.columnMapping) + .orElseThrow(UnsupportedOperationException::new); + context.get(ContextKeys.source()) + .ifPresent(dslUpdate -> { + Object repo = context.get(MappingContextKeys.repository).orElse(null); + if (repo instanceof ReactiveRepository) { + context.get(MappingContextKeys.reactiveResultHolder) + .ifPresent(holder -> { + AtomicReference> deleted = new AtomicReference<>(); + if (isEnabled(entityType, EntityEventType.delete, EntityEventPhase.before, EntityEventPhase.after)) { + holder.before( + this.doAsyncEvent(() -> ((ReactiveRepository) repo) + .createQuery() + .setParam(dslUpdate.toQueryParam()) + .fetch() + .collectList() + .doOnNext(list -> { + context.set(readyToDeleteContextKey, list); + }) + .filter(CollectionUtils::isNotEmpty) + .flatMap(list -> { + deleted.set(list); + return this + .sendDeleteEvent(list, (Class) mapping.getEntityType(), EntityBeforeDeleteEvent::new); + }) + ) + ); + } + if (isEnabled(entityType, EntityEventType.delete, EntityEventPhase.after)) { + holder.after(v -> this + .doAsyncEvent(() -> { + List _tmp = deleted.getAndSet(null); + if (CollectionUtils.isNotEmpty(_tmp)) { + return sendDeleteEvent(_tmp, (Class) mapping.getEntityType(), EntityDeletedEvent::new); + } + return Mono.empty(); + })); + } + + }); + } else if (repo instanceof SyncRepository) { + QueryParam param = dslUpdate.toQueryParam(); + SyncRepository syncRepository = ((SyncRepository) repo); + List list = syncRepository.createQuery() + .setParam(param) + .fetch(); + this.sendDeleteEvent(list, (Class) mapping.getEntityType(), EntityBeforeDeleteEvent::new) + .block(); + } + }); + } + + protected void handleUpdateAfter(EventContext context) { + + } + + protected void handleBatchOperation(Class clazz, + EntityEventType entityEventType, + EventContext context, + BiFunction, Class, AsyncEvent> before, + BiFunction, Class, AsyncEvent> execute, + BiFunction, Class, AsyncEvent> after) { + + List lst = context.get(MappingContextKeys.instance) + .filter(List.class::isInstance) + .map(List.class::cast) + .orElse(null); + if (lst == null) { + return; + } + + AsyncEvent prepareEvent = before.apply(lst, clazz); + AsyncEvent afterEvent = after.apply(lst, clazz); + AsyncEvent beforeEvent = execute.apply(lst, clazz); + Object repo = context.get(MappingContextKeys.repository).orElse(null); + if (repo instanceof ReactiveRepository) { + Optional resultHolder = context.get(MappingContextKeys.reactiveResultHolder); + if (resultHolder.isPresent()) { + ReactiveResultHolder holder = resultHolder.get(); + if (null != prepareEvent && isEnabled(clazz, entityEventType, EntityEventPhase.prepare)) { + holder.before( + this.doAsyncEvent(() -> { + return publishEvent(this, + clazz, + () -> prepareEvent, + eventPublisher::publishEvent); + }) + ); + } + + if (null != beforeEvent && isEnabled(clazz, entityEventType, EntityEventPhase.before)) { + holder.invoke( + this.doAsyncEvent(() -> { + return publishEvent(this, + clazz, + () -> beforeEvent, + eventPublisher::publishEvent); + }) + ); + } + if (null != afterEvent && isEnabled(clazz, entityEventType, EntityEventPhase.after)) { + holder.after(v -> { + return this.doAsyncEvent(() -> { + return publishEvent(this, + clazz, + () -> afterEvent, + eventPublisher::publishEvent); + }); + }); + } + return; + } + } + eventPublisher.publishEvent(new GenericsPayloadApplicationEvent<>(this, afterEvent, clazz)); + //block非响应式的支持 + afterEvent.getAsync().block(); + } + + boolean isEnabled(Class clazz, EntityEventType entityEventType, EntityEventPhase... phase) { + for (EntityEventPhase entityEventPhase : phase) { + if (listenerConfigure.isEnabled(clazz, entityEventType, entityEventPhase)) { + return true; + } + } + return false; + } + + protected void handleSingleOperation(Class clazz, + EntityEventType entityEventType, + EventContext context, + BiFunction, Class, AsyncEvent> before, + BiFunction, Class, AsyncEvent> execute, + BiFunction, Class, AsyncEvent> after) { + context.get(MappingContextKeys.instance) + .filter(Entity.class::isInstance) + .map(Entity.class::cast) + .ifPresent(entity -> { + AsyncEvent prepareEvent = before.apply(Collections.singletonList(entity), clazz); + AsyncEvent beforeEvent = execute.apply(Collections.singletonList(entity), clazz); + AsyncEvent afterEvent = after.apply(Collections.singletonList(entity), clazz); + + Object repo = context.get(MappingContextKeys.repository).orElse(null); + if (repo instanceof ReactiveRepository) { + Optional resultHolder = context.get(MappingContextKeys.reactiveResultHolder); + if (resultHolder.isPresent()) { + ReactiveResultHolder holder = resultHolder.get(); + if (null != prepareEvent && isEnabled(clazz, entityEventType, EntityEventPhase.prepare)) { + holder.before( + this.doAsyncEvent(() -> { + return publishEvent(this, + clazz, + () -> prepareEvent, + eventPublisher::publishEvent); + }) + ); + } + + if (null != beforeEvent && isEnabled(clazz, entityEventType, EntityEventPhase.before)) { + holder.invoke( + this.doAsyncEvent(() -> { + return publishEvent(this, + clazz, + () -> beforeEvent, + eventPublisher::publishEvent); + }) + ); + } + if (null != afterEvent && isEnabled(clazz, entityEventType, EntityEventPhase.after)) { + holder.after(v -> { + return this.doAsyncEvent(() -> { + return publishEvent(this, + clazz, + () -> afterEvent, + eventPublisher::publishEvent); + }); + }); + } + return; + } + } + eventPublisher.publishEvent(new GenericsPayloadApplicationEvent<>(this, afterEvent, clazz)); + //block非响应式的支持 + afterEvent.getAsync().block(); + }); + } + + protected Mono doAsyncEvent(Supplier> eventSupplier) { + return EntityEventHelper.tryFireEvent(eventSupplier); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 100; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListenerConfigure.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListenerConfigure.java new file mode 100644 index 000000000..3bf69830e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListenerConfigure.java @@ -0,0 +1,73 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.web.api.crud.entity.Entity; + +/** + * 实体事件监听器配置 + *
+ *     configure.enable(MyEntity.class)//启用事件
+ *              //禁用某一类事件
+ *              .disable(MyEntity.class,EntityEventType.modify,EntityEventPhase.all)
+ * 
+ * + * @author zhouhao + * @since 4.0.12 + */ +public interface EntityEventListenerConfigure { + + /** + * 启用实体类的事件 + * + * @param entityType 实体类 + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + */ + void enable(Class entityType); + + /** + * 禁用实体类事件 + * + * @param entityType 实体类 + */ + void disable(Class entityType); + + /** + * 启用指定类型的事件 + * + * @param entityType 实体类型 + * @param type 事件类型 + * @param phases 事件阶段,如果不传则启用全部 + */ + void enable(Class entityType, + EntityEventType type, + EntityEventPhase... phases); + + /** + * 禁用指定类型的事件 + * + * @param entityType 实体类型 + * @param type 事件类型 + * @param phases 事件阶段,如果不传则禁用全部 + */ + void disable(Class entityType, + EntityEventType type, + EntityEventPhase... phases); + + /** + * 判断实体类是否启用了事件 + * + * @param entityType 实体类 + * @return 是否启用 + */ + boolean isEnabled(Class entityType); + + /** + * 判断实体类是否启用了指定类型的事件 + * + * @param entityType 实体类 + * @param type 事件类型 + * @param phase 事件阶段 + * @return 是否启用 + */ + boolean isEnabled(Class entityType, EntityEventType type, EntityEventPhase phase); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListenerCustomizer.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListenerCustomizer.java new file mode 100644 index 000000000..b5fd2dedf --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventListenerCustomizer.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.crud.events; + +/** + * 实体事件监听器自定义接口,用于自定义实体事件 + * + * @author zhouhao + * @see EntityEventListenerConfigure + * @since 4.0.12 + */ +public interface EntityEventListenerCustomizer { + + /** + * 执行自定义 + * @param configure configure + */ + void customize(EntityEventListenerConfigure configure); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventPhase.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventPhase.java new file mode 100644 index 000000000..7b157d5c2 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventPhase.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.crud.events; + +public enum EntityEventPhase { + prepare, + before, + after; + + public static EntityEventPhase[] all = EntityEventPhase.values(); +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventType.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventType.java new file mode 100644 index 000000000..48689e753 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityEventType.java @@ -0,0 +1,8 @@ +package org.hswebframework.web.crud.events; + +public enum EntityEventType { + create, + delete, + modify, + save +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityModifyEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityModifyEvent.java new file mode 100644 index 000000000..ffece1438 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityModifyEvent.java @@ -0,0 +1,30 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityModifyEvent extends DefaultAsyncEvent implements Serializable{ + + private static final long serialVersionUID = -7158901204884303777L; + + private final List before; + + private final List after; + + private final Class entityType; + + @Override + public String toString() { + return "EntityModifyEvent<" + entityType.getSimpleName() + ">\n{\nbefore:" + before + "\nafter: " + after + "\n}"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareCreateEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareCreateEvent.java new file mode 100644 index 000000000..e0f39d6c2 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareCreateEvent.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityPrepareCreateEvent extends DefaultAsyncEvent implements Serializable { + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityPrepareCreateEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareModifyEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareModifyEvent.java new file mode 100644 index 000000000..752cad145 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareModifyEvent.java @@ -0,0 +1,30 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityPrepareModifyEvent extends DefaultAsyncEvent implements Serializable{ + + private static final long serialVersionUID = -7158901204884303777L; + + private final List before; + + private final List after; + + private final Class entityType; + + @Override + public String toString() { + return "EntityPrepareModifyEvent<" + entityType.getSimpleName() + ">\n{\nbefore:" + before + "\nafter: " + after + "\n}"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareSaveEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareSaveEvent.java new file mode 100644 index 000000000..15c073a28 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntityPrepareSaveEvent.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntityPrepareSaveEvent extends DefaultAsyncEvent implements Serializable { + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntityPrepareSaveEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntitySavedEvent.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntitySavedEvent.java new file mode 100644 index 000000000..222030058 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/EntitySavedEvent.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.event.DefaultAsyncEvent; + +import java.io.Serializable; +import java.util.List; + +/** + * @see org.hswebframework.web.crud.annotation.EnableEntityEvent + * @param + */ +@AllArgsConstructor +@Getter +public class EntitySavedEvent extends DefaultAsyncEvent implements Serializable { + + private final List entity; + + private final Class entityType; + + @Override + public String toString() { + return "EntitySavedEvent<" + entityType.getSimpleName() + ">"+entity; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/SqlExpressionInvoker.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/SqlExpressionInvoker.java new file mode 100644 index 000000000..8dcf2fb51 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/SqlExpressionInvoker.java @@ -0,0 +1,12 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.ezorm.rdb.mapping.EntityColumnMapping; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.NativeSql; + +import java.util.Map; + +public interface SqlExpressionInvoker { + + Object invoke(NativeSql sql, EntityColumnMapping mapping, Map object); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java new file mode 100644 index 000000000..4d52c1c14 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java @@ -0,0 +1,82 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.ezorm.rdb.events.EventContext; +import org.hswebframework.ezorm.rdb.events.EventListener; +import org.hswebframework.ezorm.rdb.events.EventType; +import org.hswebframework.ezorm.rdb.mapping.events.MappingContextKeys; +import org.hswebframework.ezorm.rdb.mapping.events.MappingEventTypes; +import org.hswebframework.ezorm.rdb.mapping.events.ReactiveResultHolder; +import org.hswebframework.web.api.crud.entity.Entity; +import org.hswebframework.web.i18n.LocaleUtils; +import org.hswebframework.web.validator.CreateGroup; +import org.hswebframework.web.validator.UpdateGroup; +import org.springframework.core.Ordered; + +import java.util.List; +import java.util.Optional; + +public class ValidateEventListener implements EventListener, Ordered { + + @Override + public String getId() { + return "validate-listener"; + } + + @Override + public String getName() { + return "验证器监听器"; + } + + @Override + + public void onEvent(EventType type, EventContext context) { + Optional resultHolder = context.get(MappingContextKeys.reactiveResultHolder); + + if (resultHolder.isPresent()) { + resultHolder + .ifPresent(holder -> holder + .invoke(LocaleUtils + .doInReactive(() -> { + tryValidate(type, context); + return null; + }) + )); + } else { + tryValidate(type, context); + } + } + + @SuppressWarnings("all") + public void tryValidate(EventType type, EventContext context) { + if (type == MappingEventTypes.insert_before || type == MappingEventTypes.save_before) { + + boolean single = context.get(MappingContextKeys.type).map("single"::equals).orElse(false); + if (single) { + context.get(MappingContextKeys.instance) + .filter(Entity.class::isInstance) + .map(Entity.class::cast) + .ifPresent(entity -> entity.tryValidate(CreateGroup.class)); + } else { + context.get(MappingContextKeys.instance) + .filter(List.class::isInstance) + .map(List.class::cast) + .ifPresent(lst -> lst.stream() + .filter(Entity.class::isInstance) + .map(Entity.class::cast) + .forEach(e -> ((Entity) e).tryValidate(CreateGroup.class)) + ); + } + + } else if (type == MappingEventTypes.update_before) { + context.get(MappingContextKeys.instance) + .filter(Entity.class::isInstance) + .map(Entity.class::cast) + .ifPresent(entity -> entity.tryValidate(UpdateGroup.class)); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 1000; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/expr/AbstractSqlExpressionInvoker.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/expr/AbstractSqlExpressionInvoker.java new file mode 100644 index 000000000..d235917b9 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/expr/AbstractSqlExpressionInvoker.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.events.expr; + +import org.hswebframework.ezorm.rdb.mapping.EntityColumnMapping; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.NativeSql; +import org.hswebframework.web.crud.events.SqlExpressionInvoker; +import reactor.function.Function3; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; + +public abstract class AbstractSqlExpressionInvoker implements SqlExpressionInvoker { + + private final Map, Object>> compiled = + new ConcurrentHashMap<>(); + + @Override + public Object invoke(NativeSql sql, EntityColumnMapping mapping, Map object) { + return compiled.computeIfAbsent(sql.getSql(), this::compile) + .apply(mapping,sql.getParameters(), object); + } + + + protected abstract Function3, Object> compile(String sql); + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/expr/SpelSqlExpressionInvoker.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/expr/SpelSqlExpressionInvoker.java new file mode 100644 index 000000000..d038a8a8b --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/expr/SpelSqlExpressionInvoker.java @@ -0,0 +1,196 @@ +package org.hswebframework.web.crud.events.expr; + +import io.netty.util.concurrent.FastThreadLocal; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.rdb.mapping.EntityColumnMapping; +import org.hswebframework.web.crud.query.QueryHelperUtils; +import org.springframework.context.expression.MapAccessor; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.expression.*; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.ReflectiveMethodResolver; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.util.Assert; +import reactor.function.Function3; + +import javax.annotation.Nonnull; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; + +@Slf4j +public class SpelSqlExpressionInvoker extends AbstractSqlExpressionInvoker { + + protected static class SqlFunctions extends HashMap { + + private final EntityColumnMapping mapping; + + public SqlFunctions(EntityColumnMapping mapping, Map map) { + super(map); + this.mapping = mapping; + } + + @Override + public Object get(Object key) { + Object val = super.get(key); + if (val == null) { + val = super.get(QueryHelperUtils.toHump(String.valueOf(key))); + } + if (val == null) { + val = mapping + .getPropertyByColumnName(String.valueOf(key)) + .map(super::get) + .orElse(null); + } + return val; + } + + public String lower(Object str) { + return String.valueOf(str).toLowerCase(); + } + + public String upper(Object str) { + return String.valueOf(str).toUpperCase(); + } + + public Object ifnull(Object nullable, Object val) { + return nullable == null ? val : nullable; + } + + public String substring(Object str, int start, int length) { + return String.valueOf(str).substring(start, length); + } + + public String trim(Object str) { + return String.valueOf(str).trim(); + } + + public String concat(Object... args) { + StringBuilder builder = new StringBuilder(); + for (Object arg : args) { + builder.append(arg); + } + return builder.toString(); + } + + public Object coalesce(Object... args) { + for (Object arg : args) { + if (arg != null) { + return arg; + } + } + return null; + } + } + + static final FastThreadLocal SHARED_CONTEXT = new FastThreadLocal() { + @Override + protected StandardEvaluationContext initialValue() { + StandardEvaluationContext context = new StandardEvaluationContext(); + context.addPropertyAccessor(accessor); + context.addMethodResolver(new ReflectiveMethodResolver() { + @Override + public MethodExecutor resolve(@Nonnull EvaluationContext context, + @Nonnull Object targetObject, + @Nonnull String name, + @Nonnull List argumentTypes) throws AccessException { + return super.resolve(context, targetObject, name.toLowerCase(), argumentTypes); + } + }); + context.setOperatorOverloader(new OperatorOverloader() { + @Override + public boolean overridesOperation(@Nonnull Operation operation, Object leftOperand, Object rightOperand) throws EvaluationException { + if (leftOperand instanceof Number || rightOperand instanceof Number) { + return leftOperand == null || rightOperand == null; + } + return leftOperand == null && rightOperand == null; + } + + @Override + public Object operate(@Nonnull Operation operation, Object leftOperand, Object rightOperand) throws EvaluationException { + return null; + } + }); + return context; + } + }; + + @Override + protected Function3, Object> compile(String sql) { + + StringBuilder builder = new StringBuilder(sql.length()); + int argIndex = 0; + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + if (c == '?') { + builder.append("_arg").append(argIndex++); + } else { + builder.append(c); + } + } + try { + SpelExpressionParser parser = new SpelExpressionParser(); + + Expression expression = parser.parseExpression(builder.toString()); + AtomicLong errorCount = new AtomicLong(); + + return (mapping, args, object) -> { + if (errorCount.get() > 1024) { + return null; + } + object = createArguments(mapping, object); + + if (args != null && args.length != 0) { + int index = 0; + for (Object parameter : args) { + object.put("_arg" + index, parameter); + } + } + StandardEvaluationContext context = SHARED_CONTEXT.get(); + try { + context.setRootObject(object); + Object val = expression.getValue(context); + errorCount.set(0); + return val; + } catch (Throwable err) { + log.warn("invoke native sql [{}] value error", + sql, + err); + errorCount.incrementAndGet(); + } finally { + context.setRootObject(null); + } + return null; + }; + } catch (Throwable error) { + return spelError(sql, error); + } + } + + protected SqlFunctions createArguments(EntityColumnMapping mapping, Map args) { + return new SqlFunctions(mapping, args); + } + + protected Function3, Object> spelError(String sql, Throwable error) { + log.warn("create sql expression [{}] parser error", sql, error); + return (mapping, args, data) -> null; + } + + static ExtMapAccessor accessor = new ExtMapAccessor(); + + static class ExtMapAccessor extends MapAccessor { + @Override + public boolean canRead(@Nonnull EvaluationContext context, Object target, @Nonnull String name) throws AccessException { + return target instanceof Map; + } + + @Override + @Nonnull + public TypedValue read(@Nonnull EvaluationContext context, Object target, @Nonnull String name) throws AccessException { + Assert.state(target instanceof Map, "Target must be of type Map"); + Map map = (Map) target; + Object value = map.get(name); + return new TypedValue(value); + } + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/exception/DatabaseExceptionAnalyzerReporter.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/exception/DatabaseExceptionAnalyzerReporter.java new file mode 100644 index 000000000..5ccf62594 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/exception/DatabaseExceptionAnalyzerReporter.java @@ -0,0 +1,69 @@ +package org.hswebframework.web.crud.exception; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.crud.configuration.DialectProvider; +import org.hswebframework.web.crud.configuration.DialectProviders; +import org.hswebframework.web.exception.analyzer.ExceptionAnalyzerReporter; + +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Slf4j +public class DatabaseExceptionAnalyzerReporter extends ExceptionAnalyzerReporter { + + public DatabaseExceptionAnalyzerReporter() { + init(); + } + + void init() { + addSimpleReporter( + Pattern.compile("^Binding.*"), + error -> log + .warn(wrapLog("请在application.yml中正确配置`easyorm.dialect`,可选项为:{}"), + DialectProviders + .all() + .stream() + .map(DialectProvider::name) + .collect(Collectors.toList()) + , error)); + + addSimpleReporter( + Pattern.compile("^Unknown database.*"), + error -> log + .warn(wrapLog("请先手动创建数据库或者配置`easyorm.default-schema`,数据库名不能包含只能由`数字字母下划线`组成."), error)); + + addSimpleReporter( + Pattern.compile("^Timeout on blocking.*"), + error -> log + .warn(wrapLog("操作超时,请检查数据库连接是否正确,数据库是否能正常访问."), error)); + + + initForPgsql(); + + initRedis(); + } + + void initRedis(){ + addReporter( + err->err.getClass().getCanonicalName().contains("RedisConnectionException"), + error -> log + .warn(wrapLog("请检查redis连接配置."), error)); + } + + void initForPgsql() { + addSimpleReporter( + Pattern.compile(".*\\[3D000].*"), + error -> log + .warn(wrapLog("请先手动创建数据库,数据库名不能包含只能由`数字字母下划线`组成."), error)); + + addSimpleReporter( + Pattern.compile(".*\\[3F000].*"), + error -> log + .warn(wrapLog("请正确配置`easyorm.default-schema`为pgsql数据库中对应的schema."), error)); + + addReporter( + err->err.getClass().getCanonicalName().contains("PostgresConnectionException"), + error -> log + .warn(wrapLog("请检查数据库连接配置是否正确."), error)); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/CurrentTimeGenerator.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/CurrentTimeGenerator.java new file mode 100644 index 000000000..7a61da223 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/CurrentTimeGenerator.java @@ -0,0 +1,39 @@ +package org.hswebframework.web.crud.generator; + +import org.hswebframework.ezorm.core.DefaultValue; +import org.hswebframework.ezorm.core.DefaultValueGenerator; +import org.hswebframework.ezorm.core.RuntimeDefaultValue; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; + +import java.time.LocalDateTime; +import java.util.Date; + +public class CurrentTimeGenerator implements DefaultValueGenerator { + @Override + public String getSortId() { + return Generators.CURRENT_TIME; + } + + @Override + public DefaultValue generate(RDBColumnMetadata metadata) { + return (RuntimeDefaultValue) () -> generic(metadata.getJavaType()); + } + + protected Object generic(Class type) { + if (type == Date.class) { + return new Date(); + } + if (type == java.sql.Date.class) { + return new java.sql.Date(System.currentTimeMillis()); + } + if (type == LocalDateTime.class) { + return LocalDateTime.now(); + } + return System.currentTimeMillis(); + } + + @Override + public String getName() { + return "当前系统时间"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/DefaultIdGenerator.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/DefaultIdGenerator.java new file mode 100644 index 000000000..983e57bf4 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/DefaultIdGenerator.java @@ -0,0 +1,47 @@ +package org.hswebframework.web.crud.generator; + +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.core.DefaultValue; +import org.hswebframework.ezorm.core.DefaultValueGenerator; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.web.id.IDGenerator; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +public class DefaultIdGenerator implements DefaultValueGenerator { + + @Getter + @Setter + private String defaultId = Generators.SNOW_FLAKE; + + @Getter + @Setter + private Map mappings = new HashMap<>(); + + @Override + public String getSortId() { + return Generators.DEFAULT_ID_GENERATOR; + } + + @Override + @SneakyThrows + public DefaultValue generate(RDBColumnMetadata metadata) { + String genId = mappings.getOrDefault(metadata.getOwner().getName(), defaultId); + DefaultValueGenerator generator = metadata.findFeatureNow(DefaultValueGenerator.createId(genId)); + log.debug("use default id generator : {} for column : {}", generator.getSortId(), metadata.getFullName()); + return generator.generate(metadata); + } + + @Override + public String getName() { + return "默认ID生成器"; + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/Generators.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/Generators.java new file mode 100644 index 000000000..e03e2facb --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/Generators.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.crud.generator; + +public interface Generators { + + /** + * @see DefaultIdGenerator + */ + String DEFAULT_ID_GENERATOR = "default_id"; + + + /** + * @see MD5Generator + */ + String MD5 = "md5"; + + /** + * @see SnowFlakeStringIdGenerator + */ + String SNOW_FLAKE = "snow_flake"; + + /** + * @see CurrentTimeGenerator + */ + String CURRENT_TIME = "current_time"; + + /** + * @see org.hswebframework.web.id.RandomIdGenerator + */ + String RANDOM = "random"; + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/MD5Generator.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/MD5Generator.java new file mode 100644 index 000000000..56dcb60d7 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/MD5Generator.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.crud.generator; + +import org.hswebframework.ezorm.core.DefaultValueGenerator; +import org.hswebframework.ezorm.core.RuntimeDefaultValue; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.web.id.IDGenerator; + +public class MD5Generator implements DefaultValueGenerator { + @Override + public String getSortId() { + return Generators.MD5; + } + + @Override + public RuntimeDefaultValue generate(RDBColumnMetadata metadata) { + return IDGenerator.MD5::generate; + } + + @Override + public String getName() { + return "MD5"; + } + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/RandomIdGenerator.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/RandomIdGenerator.java new file mode 100644 index 000000000..f5b1dedfa --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/RandomIdGenerator.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.crud.generator; + +import org.hswebframework.ezorm.core.DefaultValueGenerator; +import org.hswebframework.ezorm.core.RuntimeDefaultValue; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.web.id.IDGenerator; + +public class RandomIdGenerator implements DefaultValueGenerator { + @Override + public String getSortId() { + return Generators.RANDOM; + } + + @Override + public RuntimeDefaultValue generate(RDBColumnMetadata metadata) { + return IDGenerator.RANDOM::generate; + } + + @Override + public String getName() { + return "Random"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/SnowFlakeStringIdGenerator.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/SnowFlakeStringIdGenerator.java new file mode 100644 index 000000000..14a71a0fe --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/generator/SnowFlakeStringIdGenerator.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.crud.generator; + +import org.hswebframework.ezorm.core.DefaultValueGenerator; +import org.hswebframework.ezorm.core.RuntimeDefaultValue; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.web.id.IDGenerator; + +public class SnowFlakeStringIdGenerator implements DefaultValueGenerator { + @Override + public String getSortId() { + return Generators.SNOW_FLAKE; + } + + @Override + public RuntimeDefaultValue generate(RDBColumnMetadata metadata) { + return IDGenerator.SNOW_FLAKE_STRING::generate; + } + + @Override + public String getName() { + return "SnowFlake"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/DefaultQueryHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/DefaultQueryHelper.java new file mode 100644 index 000000000..25f7d541c --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/DefaultQueryHelper.java @@ -0,0 +1,1368 @@ +package org.hswebframework.web.crud.query; + +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.ezorm.core.*; +import org.hswebframework.ezorm.core.dsl.Query; +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.ezorm.core.param.TermType; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.reactive.ReactiveSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.wrapper.ColumnWrapperContext; +import org.hswebframework.ezorm.rdb.executor.wrapper.MapResultWrapper; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrappers; +import org.hswebframework.ezorm.rdb.mapping.EntityPropertyDescriptor; +import org.hswebframework.ezorm.rdb.mapping.defaults.record.DefaultRecord; +import org.hswebframework.ezorm.rdb.mapping.defaults.record.Record; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBFeatureType; +import org.hswebframework.ezorm.rdb.metadata.TableOrViewMetadata; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.ezorm.rdb.operator.builder.Paginator; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.NativeSql; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.PrepareSqlFragments; +import org.hswebframework.ezorm.rdb.operator.dml.Join; +import org.hswebframework.ezorm.rdb.operator.dml.JoinType; +import org.hswebframework.ezorm.rdb.operator.dml.QueryOperator; +import org.hswebframework.ezorm.rdb.operator.dml.SelectColumnSupplier; +import org.hswebframework.ezorm.rdb.operator.dml.query.BuildParameterQueryOperator; +import org.hswebframework.ezorm.rdb.operator.dml.query.Selects; +import org.hswebframework.ezorm.rdb.operator.dml.query.SortOrder; +import org.hswebframework.ezorm.rdb.utils.PropertyUtils; +import org.hswebframework.web.api.crud.entity.EntityFactoryHolder; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.bean.FastBeanCopier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import javax.persistence.Table; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + + +@AllArgsConstructor +public class DefaultQueryHelper implements QueryHelper { + + private final DatabaseOperator database; + + private final Map, Table> nameMapping = new ConcurrentHashMap<>(); + + private final Map analyzerCaches = new ConcurrentHashMap<>(); + + static final ResultWrapper countWrapper = + ResultWrappers.column("_total", i -> ((Number) i).intValue()); + + @Override + public QueryAnalyzer analysis(String selectSql) { + return analyzerCaches.computeIfAbsent(selectSql, sql -> new QueryAnalyzerImpl(database, sql)); + } + + @Override + public NativeQuerySpec select(String sql, Object... args) { + return new NativeQuerySpecImpl<>(this, sql, args, DefaultRecord::new, false); + } + + @Override + public NativeQuerySpec select(String sql, + Supplier newInstance, + Object... args) { + NativeQuerySpecImpl impl = new NativeQuerySpecImpl<>( + this, sql, args, map -> FastBeanCopier.copy(map, newInstance), true); + impl.setMapBuilder(ToHumpMap::new); + return impl; + } + + @Override + public SelectColumnMapperSpec select(Class resultType) { + return new QuerySpec<>(resultType, this); + } + + @Override + public SelectSpec select(Class resultType, Consumer> mapperSpec) { + QuerySpec querySpec = new QuerySpec<>(resultType, this); + + mapperSpec.accept(querySpec); + + return querySpec; + } + + TableOrViewMetadata getTable(Class type) { + Table table = nameMapping.computeIfAbsent(type, this::parseTableName); + if (StringUtils.hasText(table.schema())) { + return database + .getMetadata() + .getSchema(table.schema()) + .flatMap(schema -> schema.getTableOrView(table.name(), false)) + .orElseThrow(() -> new UnsupportedOperationException("table [" + table.schema() + "." + table.name() + "] not found")); + } + return database + .getMetadata() + .getCurrentSchema() + .getTableOrView(table.name(), false) + .orElseThrow(() -> new UnsupportedOperationException("table [" + table.name() + "] not found")); + } + + static RDBColumnMetadata getColumn(TableOrViewMetadata table, String column) { + return table + .getColumn(column) + .orElseThrow(() -> new UnsupportedOperationException("column [" + column + "] not found in [" + table.getName() + "]")); + } + + Table parseTableName(Class type) { + Table table = AnnotatedElementUtils.findMergedAnnotation(type, Table.class); + if (null == table) { + throw new UnsupportedOperationException("type [" + type.getName() + "] not found @Table annotation"); + } + return table; + } + + @SafeVarargs + private static T[] toArray(T... arr) { + return arr; + } + + static class NativeQuerySpecImpl extends MapResultWrapper implements NativeQuerySpec { + + ContextView logContext = Context.empty(); + + private final DefaultQueryHelper parent; + private final QueryAnalyzer analyzer; + private final Object[] args; + + private final Function, R> mapper; + + + private QueryParamEntity param; + + + NativeQuerySpecImpl(DefaultQueryHelper parent, + String sql, + Object[] args, + Function, R> mapper, + boolean nest) { + this.parent = parent; + this.analyzer = parent.analysis(sql); + this.args = args; + this.mapper = mapper; + setWrapperNestObject(nest); + } + + @Override + public void wrapColumn(ColumnWrapperContext> context) { + Map instance = context.getRowInstance(); + String column = context.getColumnLabel(); + QueryAnalyzer.Column col = analyzer.findColumn(column).orElse(null); + + if (col != null && !analyzer.columnIsExpression(column, context.getColumnIndex())) { + Object val = col.metadata == null + ? getCodec().decode(context.getResult()) + : col.metadata.decode(context.getResult()); + doWrap(instance, column, val); + } else { + doWrap(instance, col == null ? QueryHelperUtils.toHump(column) : col.alias, getCodec().decode(context.getResult())); + } + } + + + @Override + public NativeQuerySpec logger(Logger logger) { + this.logContext = Context.of(Logger.class, logger); + return this; + } + + @Override + public Mono count() { + + SqlRequest countSql = analyzer.refactorCount(param == null ? new QueryParamEntity() : param, args); + + return parent + .database + .sql() + .reactive() + .select(countSql, countWrapper) + .single(0) + .contextWrite(logContext); + } + + @Override + public ExecuteSpec where(QueryParamEntity param) { + this.param = param; + return this; + } + + @Override + public Flux fetch() { + QueryParamEntity _param = param == null ? QueryParamEntity.of().noPaging() : param; + SqlRequest request = analyzer.refactor(_param, args); + if (_param.isPaging()) { + request = createPagingSql(request, param.getPageIndex(), param.getPageSize()); + } + return parent + .database + .sql() + .reactive() + .select(request, this) + .map(mapper) + .contextWrite(logContext); + } + + @Override + public Flux fetch(int pageIndex, int pageSize) { + if (param == null) { + param = new QueryParamEntity(); + } + param.doPaging(pageIndex, pageSize); + return fetch(); + } + + @Override + public Mono> fetchPaged() { + if (param == null) { + return fetchPaged(0, 25); + } + return fetchPaged(param); + } + + private SqlRequest createPagingSql(SqlRequest request, int pageIndex, int pageSize) { + PrepareSqlFragments sql = PrepareSqlFragments.of(request.getSql(), request.getParameters()); + + Paginator paginator = parent + .database + .getMetadata() + .getCurrentSchema() + .findFeatureNow(RDBFeatureType.paginator.getId()); + + return paginator.doPaging(sql, pageIndex, pageSize).toRequest(); + } + + @Override + public Mono> fetchPaged(int pageIndex, int pageSize) { + return fetchPaged(this.param == null + ? new QueryParamEntity().doPaging(pageIndex, pageSize) + : this.param.clone().doPaging(pageIndex, pageSize)); + } + + public Mono> fetchPaged(QueryParamEntity param) { + + SqlRequest listSql = analyzer.refactor(param, args); + + ReactiveSqlExecutor sqlExecutor = parent.database.sql().reactive(); + + if (param.getTotal() != null) { + return sqlExecutor + .select(createPagingSql(listSql, param.getPageIndex(), param.getPageSize()), this).map(mapper) + .collectList() + .map(list -> PagerResult.of(param.getTotal(), list, param)) + .contextWrite(logContext); + } + + SqlRequest countSql = analyzer.refactorCount(param, args); + + if (param.isParallelPager()) { + return Mono.zip(sqlExecutor + .select(countSql, countWrapper) + .single(0), + sqlExecutor + .select(createPagingSql(listSql, param.getPageIndex(), param.getPageSize()), this) + .map(mapper) + .collectList(), + (total, list) -> PagerResult.of(total, list, param)) + .contextWrite(logContext); + } + + return sqlExecutor + .select(countSql, countWrapper) + .single(0) + .>flatMap(total -> { + QueryParamEntity copy = param.clone(); + copy.rePaging(total); + if (total == 0) { + return Mono.just(PagerResult.of(0, new ArrayList<>(), copy)); + } + return sqlExecutor + .select(createPagingSql(listSql, copy.getPageIndex(), copy.getPageSize()), this) + .map(mapper) + .collectList() + .map(list -> PagerResult.of(total, list, copy)); + + }) + .contextWrite(logContext); + } + } + + static abstract class ColumnMapping { + final QuerySpec parent; + + public ColumnMapping(QuerySpec parent) { + this.parent = parent; + } + + abstract SelectColumnSupplier[] forSelect(); + + abstract boolean match(String[] column); + + abstract void applyValue(R result, String[] column, Object sqlValue); + + static class All extends ColumnMapping { + private final String table; + private final Class tableType; + private TableOrViewMetadata target; + + private final String alias; + + private final String targetProperty; + + private final ResolvableType propertyType; + + @SneakyThrows + public All(QuerySpec parent, + String table, + Class tableType, + Setter setter) { + super(parent); + this.table = table; + this.tableType = tableType; + this.targetProperty = setter == null ? null : MethodReferenceConverter.convertToColumn(setter); + if (this.targetProperty != null) { + Field field = ReflectionUtils.findField(parent.clazz, targetProperty); + if (field == null) { + throw new NoSuchFieldException(parent.clazz.getName() + "." + targetProperty); + } + propertyType = ResolvableType.forField(field, parent.clazz); + } else { + propertyType = null; + } + String prefix = targetProperty == null ? "all" : targetProperty; + int size = parent.mappings.size(); + this.alias = size == 0 ? prefix : prefix + "_" + size; + } + + boolean propertyTypeIsCollection() { + return propertyType != null && Collection.class.isAssignableFrom(propertyType.toClass()); + } + + @Override + boolean match(String[] column) { + return column.length >= 2 && Objects.equals(alias, column[0]); + } + + @Override + void applyValue(R result, String[] column, Object sqlValue) { + + if (column.length > 1) { + RDBColumnMetadata metadata = target.getColumn(column[1]).orElse(null); + + if (metadata != null) { + ObjectPropertyOperator operator = GlobalConfig.getPropertyOperator(); + if (targetProperty == null) { + operator.setProperty(result, column[1], metadata.decode(sqlValue)); + } else { + Object val = operator.getPropertyOrNew(result, targetProperty); + operator.setProperty(val, column[1], metadata.decode(sqlValue)); + } + } + + } + } + + SelectColumnSupplier[] toColumns(TableOrViewMetadata table, + String owner) { + + return table + .getColumns() + .stream() + .map(column -> Selects + .column(owner == null ? column.getName() : owner + "." + column.getName()) + .as(alias + "." + column.getAlias())) + .toArray(SelectColumnSupplier[]::new); + } + + JoinConditionalSpecImpl getJoin() { + if (this.table != null) { + return parent.getJoinByAlias(this.table); + } else { + return parent.getJoinByClass(tableType); + } + } + + @Override + SelectColumnSupplier[] forSelect() { + if (propertyTypeIsCollection()) { + return new SelectColumnSupplier[0]; + } + //查询主表 + if (tableType == parent.from) { + return toColumns(this.target = parent.table, null); + } + + //join表 + JoinConditionalSpecImpl join = getJoin(); + + this.target = join.main; + + return toColumns(this.target, join.alias); + } + } + + static class Default extends ColumnMapping { + private final String column; + private String alias; + private final Getter getter; + private final Setter setter; + RDBColumnMetadata metadata; + + public Default(QuerySpec parent, + String column, + Getter getter, + String alias, + Setter setter) { + super(parent); + this.column = column; + this.alias = alias; + this.getter = getter; + this.setter = setter; + } + + @Override + boolean match(String[] column) { + return column.length == 1 && Objects.equals(alias, column[0]); + } + + @Override + void applyValue(R result, String[] column, Object sqlValue) { + if (setter != null) { + setter.accept(result, (V) metadata.decode(sqlValue)); + return; + } + GlobalConfig.getPropertyOperator().setProperty(result, column[0], metadata.decode(sqlValue)); + } + + @Override + SelectColumnSupplier[] forSelect() { + this.alias = this.alias != null ? + this.alias : MethodReferenceConverter.convertToColumn(setter); + + if (column != null) { + String[] nestMaybe = column.split("[.]"); + if (nestMaybe.length == 2) { + JoinConditionalSpecImpl join = parent.getJoinByAlias(nestMaybe[0]); + + metadata = getColumn(join.main, nestMaybe[1]); + } else { + metadata = getColumn(parent.table, column); + } + return toArray(Selects.column(column).as(alias)); + + } else if (getter != null) { + + MethodReferenceInfo info = MethodReferenceConverter.parse(getter); + //查主表 + if (info.getOwner() == parent.from) { + metadata = getColumn(parent.table, info.getColumn()); + return toArray(Selects.column(info.getColumn()).as(alias)); + } else { + JoinConditionalSpecImpl join = parent.getJoinByClass(info.getOwner()); + metadata = getColumn(join.main, info.getColumn()); + return toArray(Selects.column(join.alias + "." + info.getColumn()).as(alias)); + } + + } + throw new IllegalArgumentException("column or getter can not be null"); + } + } + } + + @Slf4j + static class QuerySpec implements SelectSpec, FromSpec, SortSpec, ResultWrapper, SelectColumnMapperSpec { + + private final Class clazz; + + private final DefaultQueryHelper parent; + + private final List> mappings = new ArrayList<>(); + + private TableOrViewMetadata table; + + private Class from; + + private int joinIndex; + private QueryOperator query; + + private List joins; + + private QueryParamEntity param; + final ContextView logContext; + + private Function, Flux> resultHandler = Function.identity(); + + public QuerySpec(Class clazz, DefaultQueryHelper parent) { + this.clazz = EntityFactoryHolder.getMappedType(clazz); + this.parent = parent; + logContext = Context.of(Logger.class, LoggerFactory.getLogger(clazz)); + } + + private List joins() { + return joins == null ? joins = new ArrayList<>(3) : joins; + } + + private JoinConditionalSpecImpl getJoinByClass(Class clazz) { + + if (joins != null) { + for (JoinConditionalSpecImpl join : joins) { + if (Objects.equals(join.mainClass, clazz)) { + return join; + } + } + } + + throw new IllegalArgumentException("join class [" + clazz + "] not found!"); + } + + private JoinConditionalSpecImpl getJoinByAlias(String alias) { + if (joins != null) { + for (JoinConditionalSpecImpl join : joins) { + if (Objects.equals(join.alias, alias)) { + return join; + } + } + } + + throw new IllegalArgumentException("join alias [" + alias + "] not found!"); + } + + @Override + public FromSpec from(Class clazz) { + query = parent + .database + .dml() + .query(table = parent.getTable(from = clazz)); + return this; + } + + private QueryOperator createQuery() { + QueryOperator query = this.query.clone(); + for (ColumnMapping mapping : mappings) { + query.select(mapping.forSelect()); + } + return query; + + } + + @Override + public Mono count() { + BuildParameterQueryOperator operator = (BuildParameterQueryOperator) query.clone(); + operator.getParameter().setPageIndex(null); + operator.getParameter().setPageSize(null); + operator.getParameter().setOrderBy(new ArrayList<>()); + return operator + .select(Selects.count1().as("_total")) + .fetch(countWrapper) + .reactive() + .single(0) + .contextWrite(logContext); + } + + @Override + public Flux fetch() { + + return createQuery() + .fetch(this) + .reactive() + .contextWrite(logContext) + .as(resultHandler); + } + + @Override + public Flux fetch(int pageIndex, int pageSize) { + return createQuery() + .paging(pageIndex, pageSize) + .fetch(this) + .reactive() + .contextWrite(logContext) + .as(resultHandler); + } + + @Override + public Mono> fetchPaged() { + if (param != null) { + return fetchPaged(param); + } + return fetchPaged(0, 25); + } + + @Override + public Mono> fetchPaged(int pageIndex, int pageSize) { + return fetchPaged(param != null + ? param.clone().doPaging(pageIndex, pageSize) + : new QueryParamEntity().doPaging(pageIndex, pageSize)); + } + + private Mono> fetchPaged(QueryParamEntity param) { + + if (param.getTotal() != null) { + return createQuery() + .paging(param.getPageIndex(), param.getPageSize()) + .fetch(this) + .reactive() + .as(resultHandler) + .collectList() + .map(list -> PagerResult.of(param.getTotal(), list, param)) + .contextWrite(logContext); + } + + if (param.isParallelPager()) { + return Mono.zip(count(), + createQuery() + .paging(param.getPageIndex(), param.getPageSize()) + .fetch(this) + .reactive() + .as(resultHandler) + .collectList(), + (total, list) -> PagerResult.of(total, list, param)) + .contextWrite(logContext); + } + + + return this + .count() + .flatMap(i -> { + QueryParamEntity copy = param.clone(); + copy.rePaging(i); + if (i == 0) { + return Mono.just(PagerResult.of(0, new ArrayList<>(), copy)); + } + return createQuery() + .paging(copy.getPageIndex(), copy.getPageSize()) + .fetch(this) + .reactive() + .as(resultHandler) + .collectList() + .map(list -> PagerResult.of(i, list, copy)) + .contextWrite(logContext); + }); + } + + @Override + public SortSpec where(QueryParamEntity param) { + query.setParam(this.param = refactorParam(param.clone())); + return this; + } + + private QueryParamEntity refactorParam(QueryParamEntity param) { + + for (Term term : param.getTerms()) { + refactorTerm(term); + } + + return param; + } + + private void refactorTerm(Term term) { + term.setColumn(refactorColumn(term.getColumn())); + } + + @Override + @SuppressWarnings("all") + public SortSpec where(Consumer> dsl) { + + query.where(c -> dsl.accept(new ConditionalImpl(this, c))); + + return this; + } + + private String createJoinAlias() { + return "j_" + (joinIndex++); + } + + public JoinSpec join(Class type, + String alias, + JoinType joinType, + Consumer> on) { + TableOrViewMetadata joinTable = parent.getTable(type); + + Query condition = QueryParamEntity.newQuery(); + + JoinConditionalSpecImpl spec = new JoinConditionalSpecImpl( + this, + type, + joinTable, + alias, + condition + ); + + joins().add(spec); + + on.accept(spec); + + QueryParamEntity param = condition.getParam(); + + for (ColumnMapping mapping : mappings) { + if (mapping instanceof ColumnMapping.All) { + // 1对多 + ColumnMapping.All all = (ColumnMapping.All) mapping; + if (all.propertyTypeIsCollection()) { + if (all.tableType == null) { + if (Objects.equals(all.table, spec.alias)) { + buildOnToMany(param, spec, all); + return this; + } + } else if (all.tableType == type) { + buildOnToMany(param, spec, all); + return this; + } + } + } + } + + Join join = new Join(); + join.setAlias(spec.alias); + join.setTerms(param.getTerms()); + join.setType(joinType); + join.setTarget(spec.main.getFullName()); + + query.join(join); + return this; + + } + + class Joiner { + private final List terms; + + private final List joinTerms = new ArrayList<>(); + + public Joiner(List terms) { + this.terms = terms; + prepare(terms); + } + + public void prepare(List terms) { + for (Term term : terms) { + if (Objects.equals(TermType.eq, term.getTermType()) + && term.getValue() instanceof JoinConditionalSpecImpl.ColumnRef) { + joinTerms.add(term); + } + if (term.getTerms() != null) { + prepare(term.getTerms()); + } + } + } + + + private Function, Flux> buildHandler(JoinConditionalSpecImpl join, + ColumnMapping.All mapping) { + if (joinTerms.size() == 1) { + return buildBatchHandler(join, mapping); + } + return flux -> flux + .flatMap(data -> { + QueryParamEntity param = new QueryParamEntity(); + param.setTerms(refactorTerms(data)); + return parent + .select(join.mainClassSafe()) + .all(join.mainClass) + .from(join.mainClass) + .where(param.noPaging()) + .fetch() + .collectList() + .map(list -> FastBeanCopier.copy(Collections.singletonMap(mapping.targetProperty, list), data)); + }, 16); + } + + private List refactorTerms(R main) { + return refactorTerms(terms.stream().map(Term::clone).collect(Collectors.toList()), main); + } + + private List refactorTerms(List terms, R main) { + for (Term term : terms) { + refactorTerms(main, term); + if (CollectionUtils.isNotEmpty(term.getTerms())) { + refactorTerms(term.getTerms(), main); + } + } + return terms; + } + + private void refactorTerms(R main, Term term) { + if (term.getValue() instanceof JoinConditionalSpecImpl.ColumnRef) { + JoinConditionalSpecImpl.ColumnRef ref = (JoinConditionalSpecImpl.ColumnRef) term.getValue(); + String mainProperty = ref.getColumn().getAlias(); + + Object value = FastBeanCopier.getProperty(main, mainProperty); + if (value == null) { + term.setTermType(TermType.isnull); + term.setValue(1); + } else { + term.setValue(value); + } + } + } + + private Function, Flux> buildBatchHandler(JoinConditionalSpecImpl join, + ColumnMapping.All mapping) { + Term term = joinTerms.get(0); + JoinConditionalSpecImpl.ColumnRef ref = (JoinConditionalSpecImpl.ColumnRef) term.getValue(); + + String joinProperty = term.getColumn(); + String mainProperty = ref.getColumn().getAlias(); + + return flux -> QueryHelper + .combineOneToMany( + flux, + t -> FastBeanCopier.getProperty(t, mainProperty), + idList -> { + term.setColumn(joinProperty); + term.setTermType(TermType.in); + term.setValue(idList); + + QueryParamEntity param = new QueryParamEntity(); + param.setTerms(terms); + return parent + .select(join.mainClassSafe()) + .all(join.mainClass) + .from(join.mainClass) + .where(param.noPaging()) + .fetch(); + }, + r -> FastBeanCopier.getProperty(r, joinProperty), + (t, list) -> FastBeanCopier.copy(Collections.singletonMap(mapping.targetProperty, list), t) + ); + } + + } + + private void buildOnToMany(QueryParamEntity param, JoinConditionalSpecImpl join, ColumnMapping.All mapping) { + + this.resultHandler = this.resultHandler.andThen(new Joiner(param.getTerms()).buildHandler(join, mapping)); + } + + @Override + public JoinSpec fullJoin(Class type, Consumer> on) { + return join(type, createJoinAlias(), JoinType.full, on); + } + + @Override + public JoinSpec leftJoin(Class type, Consumer> on) { + return join(type, createJoinAlias(), JoinType.left, on); + } + + @Override + public JoinSpec innerJoin(Class type, Consumer> on) { + return join(type, createJoinAlias(), JoinType.inner, on); + } + + @Override + public JoinSpec rightJoin(Class type, Consumer> on) { + return join(type, createJoinAlias(), JoinType.right, on); + } + + @SneakyThrows + public R newRowInstance0() { + return clazz.getConstructor().newInstance(); + } + + @Override + @SneakyThrows + public R newRowInstance() { + return EntityFactoryHolder.newInstance(clazz, this::newRowInstance0); + } + + @Override + public void wrapColumn(ColumnWrapperContext context) { + if (context.getResult() == null) { + return; + } + String[] column = context.getColumnLabel().split("[.]"); + ColumnMapping mapping = getMappingByColumn(column); + if (null == mapping) { + return; + } + + mapping.applyValue(context.getRowInstance(), column, context.getResult()); + + } + + @Override + public boolean completedWrapRow(R result) { + return true; + } + + @Override + public R getResult() { + throw new UnsupportedOperationException(); + } + + public ColumnMapping getMappingByColumn(String[] column) { + for (ColumnMapping mapping : mappings) { + if (mapping.match(column)) { + return mapping; + } + } + return null; + } + + + @Override + public SelectColumnMapperSpec all(Class joinType) { + mappings.add(new ColumnMapping.All<>(this, null, joinType, null)); + return this; + } + + @Override + public SelectColumnMapperSpec all(Class joinType, Setter setter) { + mappings.add(new ColumnMapping.All<>(this, null, joinType, setter)); + return this; + } + + @Override + public SelectColumnMapperSpec all(String table) { + mappings.add(new ColumnMapping.All<>(this, table, null, null)); + return this; + } + + @Override + public SelectColumnMapperSpec all(String table, Setter setter) { + mappings.add(new ColumnMapping.All<>(this, table, null, setter)); + return this; + } + + @Override + public SelectColumnMapperSpec as(Getter column, Setter target) { + + mappings.add(new ColumnMapping.Default<>(this, null, column, null, target)); + return this; + } + + @Override + public SelectColumnMapperSpec as(Getter getter, String target) { + mappings.add(new ColumnMapping.Default<>(this, null, getter, target, null)); + return this; + } + + @Override + public SelectColumnMapperSpec as(String column, Setter target) { + mappings.add(new ColumnMapping.Default<>(this, column, null, null, target)); + return this; + } + + @Override + public SelectColumnMapperSpec as(String column, String target) { + mappings.add(new ColumnMapping.Default<>(this, column, null, target, null)); + return this; + } + + + @Override + public SortSpec orderBy(String column, SortOrder.Order order) { + SortOrder sortOrder = new SortOrder(); + sortOrder.setColumn(column); + sortOrder.setOrder(order); + query.orderBy(sortOrder); + return this; + } + + @Override + public SortSpec orderBy(Getter column, SortOrder.Order order) { + + MethodReferenceInfo referenceInfo = MethodReferenceConverter.parse(column); + if (referenceInfo.getOwner() == from) { + return orderBy(referenceInfo.getColumn(), order); + } + JoinConditionalSpecImpl join = getJoinByClass(referenceInfo.getOwner()); + + return orderBy(join.alias + "." + referenceInfo.getColumn(), order); + } + + public String refactorColumn(String column) { + if (null == column) { + return null; + } + if (column.contains(".")) { + String[] joinColumn = column.split("[.]"); + for (ColumnMapping mapping : mappings) { + if (mapping instanceof ColumnMapping.All) { + //传递的是property + if (Objects.equals(joinColumn[0], ((ColumnMapping.All) mapping).targetProperty)) { + JoinConditionalSpecImpl join = ((ColumnMapping.All) mapping).getJoin(); + joinColumn[0] = join.alias; + return String.join(".", joinColumn); + } + } + } + } + return column; + } + } + + @AllArgsConstructor + static class JoinConditionalSpecImpl implements JoinConditionalSpec { + private final QuerySpec parent; + private final Class mainClass; + private final TableOrViewMetadata main; + private String alias; + private final Conditional target; + + @SuppressWarnings("all") + private Class mainClassSafe() { + return (Class) mainClass; + } + + @Override + public JoinConditionalSpecImpl applyColumn(StaticMethodReferenceColumn mainColumn, + String termType, + String alias, + StaticMethodReferenceColumn joinColumn) { + MethodReferenceInfo main = MethodReferenceConverter.parse(mainColumn); + MethodReferenceInfo join = MethodReferenceConverter.parse(joinColumn); + + //mainColumn是主表的列 + if (main.getOwner() == parent.from) { + return applyColumn(join.getColumn(), termType, parent.table, parent.table.getName(), mainColumn.getColumn()); + } + //join为主表 + if (join.getOwner() == parent.from) { + return applyColumn(mainColumn.getColumn(), termType, parent.table, parent.table.getName(), join.getColumn()); + } + + JoinConditionalSpecImpl spec = alias == null ? parent.getJoinByClass(join.getOwner()) : parent.getJoinByAlias(alias); + + return applyColumn(mainColumn.getColumn(), termType, spec.main, spec.alias, join.getColumn()); + } + + @Override + public JoinConditionalSpecImpl applyColumn(StaticMethodReferenceColumn mainColumn, + String termType, + StaticMethodReferenceColumn joinColumn) { + return applyColumn(mainColumn, termType, null, joinColumn); + } + + public JoinConditionalSpecImpl applyColumn(String mainColumn, + String termType, + TableOrViewMetadata join, + String alias, + String column) { + + RDBColumnMetadata columnMetadata = join + .getColumn(column) + .orElseThrow(() -> new IllegalArgumentException("column [" + column + "] not found")); + + getAccepter().accept(mainColumn, termType, new ColumnRef(columnMetadata, alias)); + + return this; + } + + @AllArgsConstructor + @lombok.Getter + public static class ColumnRef implements NativeSql { + private final RDBColumnMetadata column; + private final String alias; + + @Override + public String getSql() { + return column.getFullName(alias); + } + } + + @Override + public JoinNestConditionalSpec nest() { + Term term = new Term(); + term.setType(Term.Type.and); + target.accept(term); + + return new JoinNestConditionalSpecImpl<>(parent, this, term); + } + + @Override + public JoinNestConditionalSpec orNest() { + Term term = new Term(); + term.setType(Term.Type.or); + target.accept(term); + + return new JoinNestConditionalSpecImpl<>(parent, this, term); + } + + @Override + public JoinConditionalSpecImpl and() { + target.and(); + return this; + } + + @Override + public JoinConditionalSpecImpl or() { + target.or(); + return this; + } + + @Override + public JoinConditionalSpecImpl and(String column, String termType, Object value) { + target.and(column, termType, value); + return this; + } + + @Override + public JoinConditionalSpecImpl or(String column, String termType, Object value) { + target.or(column, termType, value); + return this; + } + + @Override + public Accepter getAccepter() { + return ((column, termType, value) -> { + target.getAccepter().accept(column, termType, value); + return this; + }); + } + + @Override + public JoinConditionalSpecImpl accept(Term term) { + target.accept(term); + return this; + } + + @Override + public JoinConditionalSpecImpl alias(String alias) { + this.alias = alias; + return this; + } + + + } + + static class JoinNestConditionalSpecImpl + extends SimpleNestConditional implements JoinNestConditionalSpec { + final QuerySpec parent; + + private final Term term; + + public JoinNestConditionalSpecImpl(QuerySpec parent, T target, Term term) { + super(target, term); + this.parent = parent; + this.term = term; + } + + @Override + public NestConditional accept(String column, String termType, Object value) { + return getAccepter().accept(parent.refactorColumn(column), termType, value); + } + + @Override + @SuppressWarnings("all") + public JoinNestConditionalSpecImpl nest() { + return new JoinNestConditionalSpecImpl<>(parent, this, term.nest()); + } + + @Override + @SuppressWarnings("all") + public JoinNestConditionalSpecImpl orNest() { + return new JoinNestConditionalSpecImpl<>(parent, this, term.orNest()); + } + + @Override + public JoinNestConditionalSpecImpl applyColumn(StaticMethodReferenceColumn joinColumn, + String termType, + String alias, + StaticMethodReferenceColumn mainOrJoinColumn) { + MethodReferenceInfo main = MethodReferenceConverter.parse(joinColumn); + MethodReferenceInfo join = MethodReferenceConverter.parse(joinColumn); + + //mainColumn是主表的列 + if (main.getOwner() == parent.from) { + return applyColumn(join.getColumn(), termType, parent.table, parent.table.getName(), joinColumn.getColumn()); + } + //join为主表 + if (join.getOwner() == parent.from) { + return applyColumn(joinColumn.getColumn(), termType, parent.table, parent.table.getName(), join.getColumn()); + } + + JoinConditionalSpecImpl spec = alias == null ? parent.getJoinByClass(join.getOwner()) : parent.getJoinByAlias(alias); + + return applyColumn(joinColumn.getColumn(), termType, spec.main, spec.alias, join.getColumn()); + } + + @Override + public JoinNestConditionalSpecImpl applyColumn(StaticMethodReferenceColumn mainColumn, + String termType, + StaticMethodReferenceColumn joinColumn) { + return applyColumn(joinColumn, termType, null, joinColumn); + } + + + public JoinNestConditionalSpecImpl applyColumn(String mainColumn, + String termType, + TableOrViewMetadata join, + String alias, + String column) { + + RDBColumnMetadata columnMetadata = join + .getColumn(column) + .orElseThrow(() -> new IllegalArgumentException("column [" + column + "] not found")); + + getAccepter().accept(mainColumn, termType, new JoinConditionalSpecImpl.ColumnRef(columnMetadata, alias)); + + return this; + } + + @Override + public Accepter, Object> getAccepter() { + return (column, termType, value) -> { + super.getAccepter().accept(column, termType, value); + return this; + }; + } + } + + static class NestConditionalImpl extends SimpleNestConditional { + final QuerySpec parent; + + final Term term; + + public NestConditionalImpl(QuerySpec parent, T target, Term term) { + super(target, term); + this.parent = parent; + this.term = term; + } + + @Override + public NestConditional> nest() { + return new NestConditionalImpl<>(parent, this, term.nest()); + } + + @Override + public NestConditional> orNest() { + return new NestConditionalImpl<>(parent, this, term.orNest()); + } + + @Override + public NestConditional accept(String column, String termType, Object value) { + return super.accept(parent.refactorColumn(column), termType, value); + } + + @Override + public NestConditional accept(MethodReferenceColumn column, String termType) { + MethodReferenceInfo info = MethodReferenceConverter.parse(column); + if (info.getOwner() == parent.from) { + return super.accept(column, termType); + } + JoinConditionalSpecImpl join = parent.getJoinByClass(info.getOwner()); + return super.accept(join.alias + "." + info.getColumn(), termType, column.get()); + } + + @Override + public NestConditional accept(StaticMethodReferenceColumn column, String termType, Object value) { + MethodReferenceInfo info = MethodReferenceConverter.parse(column); + if (info.getOwner() == parent.from) { + return super.accept(column, termType, value); + } + JoinConditionalSpecImpl join = parent.getJoinByClass(info.getOwner()); + + super.accept(join.alias + "." + info.getColumn(), termType, value); + return this; + } + + } + + @AllArgsConstructor + static class ConditionalImpl> implements Conditional { + final QuerySpec parent; + + final Conditional real; + + @Override + public NestConditional nest() { + Term term = new Term(); + term.setType(Term.Type.and); + real.accept(term); + + return new NestConditionalImpl<>(parent, (T) this, term); + } + + @Override + public NestConditional orNest() { + Term term = new Term(); + term.setType(Term.Type.or); + real.accept(term); + return new NestConditionalImpl<>(parent, (T) this, term); + } + + @Override + public T and() { + real.and(); + return castSelf(); + } + + @Override + public T or() { + real.or(); + return castSelf(); + } + + @Override + public T and(String column, String termType, Object value) { + real.and(column, termType, value); + return castSelf(); + } + + @Override + public T or(String column, String termType, Object value) { + real.or(column, termType, value); + return castSelf(); + } + + @Override + public T accept(String column, String termType, Object value) { + return Conditional.super.accept(parent.refactorColumn(column), termType, value); + } + + @Override + public T accept(MethodReferenceColumn column, String termType) { + MethodReferenceInfo info = MethodReferenceConverter.parse(column); + if (info.getOwner() == parent.from) { + return Conditional.super.accept(column, termType); + } + JoinConditionalSpecImpl join = parent.getJoinByClass(info.getOwner()); + + return getAccepter().accept(join.alias + "." + info.getColumn(), termType, column.get()); + } + + @Override + public T accept(StaticMethodReferenceColumn column, String termType, Object value) { + MethodReferenceInfo info = MethodReferenceConverter.parse(column); + if (info.getOwner() == parent.from) { + return Conditional.super.accept(column, termType, value); + } + JoinConditionalSpecImpl join = parent.getJoinByClass(info.getOwner()); + + return getAccepter().accept(join.alias + "." + info.getColumn(), termType, value); + } + + @Override + public Accepter getAccepter() { + return (column, termType, value) -> { + real.getAccepter().accept(column, termType, value); + return castSelf(); + }; + } + + @Override + public T accept(Term term) { + real.accept(term); + return castSelf(); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinConditionalSpec.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinConditionalSpec.java new file mode 100644 index 000000000..1458c8050 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinConditionalSpec.java @@ -0,0 +1,38 @@ +package org.hswebframework.web.crud.query; + +import org.hswebframework.ezorm.core.Conditional; +import org.hswebframework.ezorm.core.StaticMethodReferenceColumn; + +public interface JoinConditionalSpec> extends JoinOnSpec, Conditional { + + @Override + JoinNestConditionalSpec nest(); + + @Override + JoinNestConditionalSpec orNest(); + + /** + * 使用方法引用定义join表别名。 + * + *
{@code
+     * // join t_detail detail ....
+     *  alias(MyEntity.getDetail)
+     * }
+ * + * @param alias 别名 + * @return this + */ + default C alias(StaticMethodReferenceColumn alias) { + return alias(alias.getColumn()); + } + + /** + * 定义join表别名,在后续列转换和条件中可以使用别名进行引用。 + * + * @param alias 别名 + * @return this + */ + C alias(String alias); + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinNestConditionalSpec.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinNestConditionalSpec.java new file mode 100644 index 000000000..f1b7b5654 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinNestConditionalSpec.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.crud.query; + +import org.hswebframework.ezorm.core.NestConditional; +import org.hswebframework.ezorm.core.TermTypeConditionalSupport; + +public interface JoinNestConditionalSpec + extends JoinOnSpec>, NestConditional { + + @Override + JoinNestConditionalSpec> nest(); + + @Override + JoinNestConditionalSpec> orNest(); + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinOnSpec.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinOnSpec.java new file mode 100644 index 000000000..2ef41d620 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/JoinOnSpec.java @@ -0,0 +1,261 @@ +package org.hswebframework.web.crud.query; + +import org.hswebframework.ezorm.core.StaticMethodReferenceColumn; +import org.hswebframework.ezorm.core.TermTypeConditionalSupport; +import org.hswebframework.ezorm.core.param.TermType; + +public interface JoinOnSpec { + + /** + * 设置 join on = 条件 + *
{@code
+     *   // join detail d on d.id = t.id
+     *    is(DetailEntity::getId,MyEntity::getId)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param T + * @param T2 + * @return this + */ + default Self is(StaticMethodReferenceColumn joinColumn, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.eq, mainOrJoinColumn); + } + + /** + * 设置 join on = 条件 + *
{@code
+     *   // join detail d on d.id = d2.id
+     *    is("id","d2",MyEntity::getId)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param alias 另外一个join表的别名 + * @param T + * @param T2 + * @return this + */ + default Self is(StaticMethodReferenceColumn joinColumn, + String alias, + StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.eq, alias, mainOrJoinColumn); + } + + /** + * 设置 join on != 条件 + *
{@code
+     *   // join detail d on d.id != t.id
+     *    not(DetailEntity::getId,MyEntity::getId)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param T + * @param T2 + * @return this + */ + default Self not(StaticMethodReferenceColumn joinColumn, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.not, mainOrJoinColumn); + } + + /** + * 设置 join on != 条件 + *
{@code
+     *   // join detail d on d.id != d2.id
+     *    not("id","d2",MyEntity::getId)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param alias 另外一个join表的别名 + * @param T + * @param T2 + * @return this + */ + default Self not(StaticMethodReferenceColumn joinColumn, + String alias, + StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.not, alias, mainOrJoinColumn); + } + + /** + * 设置 join on > 条件 + *
{@code
+     *   // join detail d on d.max_age > t.age
+     *    gt(DetailEntity::getMaxAge,MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param T + * @param T2 + * @return this + */ + default Self gt(StaticMethodReferenceColumn joinColumn, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.gt, mainOrJoinColumn); + } + + /** + * 设置 join on > 条件 + *
{@code
+     *   // join detail d on d.max_age > t2.age
+     *    gt(DetailEntity::getMaxAge,"t2",MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param alias 另外一个join表的别名 + * @param T + * @param T2 + * @return this + */ + default Self gt(StaticMethodReferenceColumn joinColumn, String alias, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.gt, alias, mainOrJoinColumn); + } + + + /** + * 设置 join on >= 条件 + *
{@code
+     *   // join detail d on d.max_age >= t.age
+     *    gte(DetailEntity::getMaxAge,MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param T + * @param T2 + * @return this + */ + default Self gte(StaticMethodReferenceColumn joinColumn, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.gte, mainOrJoinColumn); + } + + /** + * 设置 join on >= 条件 + *
{@code
+     *   // join detail d on d.max_age >= t2.age
+     *    gte(DetailEntity::getMaxAge,"t2",MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param alias 另外一个join表的别名 + * @param T + * @param T2 + * @return this + */ + default Self gte(StaticMethodReferenceColumn joinColumn, String alias, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.gte, alias, mainOrJoinColumn); + } + + + /** + * 设置 join on < 条件 + *
{@code
+     *   // join detail d on d.max_age < t.age
+     *    lt(DetailEntity::getMaxAge,MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param T + * @param T2 + * @return this + */ + default Self lt(StaticMethodReferenceColumn joinColumn, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.lt, mainOrJoinColumn); + } + + /** + * 设置 join on < 条件 + *
{@code
+     *   // join detail d on d.max_age < t2.age
+     *    lt(DetailEntity::getMaxAge,"t2",MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param alias 另外一个join表的别名 + * @param T + * @param T2 + * @return this + */ + default Self lt(StaticMethodReferenceColumn joinColumn, String alias, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.lt, alias, mainOrJoinColumn); + } + + + /** + * 设置 join on <= 条件 + *
{@code
+     *   // join detail d on d.max_age <= t.age
+     *    lte(DetailEntity::getMaxAge,MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param T + * @param T2 + * @return this + */ + default Self lte(StaticMethodReferenceColumn joinColumn, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.lte, mainOrJoinColumn); + } + + /** + * 设置 join on <= 条件 + *
{@code
+     *   // join detail d on d.max_age <= t2.age
+     *    lte(DetailEntity::getMaxAge,"t2",MyEntity::getAge)
+     * }
+ * + * @param joinColumn 关联表列 + * @param mainOrJoinColumn 主表或者其他关联表列 + * @param alias 另外一个join表的别名 + * @param T + * @param T2 + * @return this + */ + default Self lte(StaticMethodReferenceColumn joinColumn, String alias, StaticMethodReferenceColumn mainOrJoinColumn) { + return applyColumn(joinColumn, TermType.lte, alias, mainOrJoinColumn); + } + + /** + * 设置 join on 字段关联条件 + *
{@code
+     *   // join on t.age > d.max_age
+     *    applyColumn(MyEntity::getAge,"gt",Detail::getMaxAge)
+     * }
+ * + * @param joinColumn 列名,可以为其他关联表的列名 + * @param termType 条件类型 {@link TermType} {@link org.hswebframework.ezorm.rdb.operator.builder.fragments.TermFragmentBuilder#getId() } + * @param mainOrJoinColumn 关联表列名 + * @return this + */ + Self applyColumn(StaticMethodReferenceColumn joinColumn, + String termType, + StaticMethodReferenceColumn mainOrJoinColumn); + + /** + * 设置 join on 字段关联条件 + *
{@code
+     *   // join detail d on d.age > d2.max_age
+     *    applyColumn(Detail::getAge,"gt","d2",Detail::getMaxAge)
+     * }
+ * + * @param joinColumn 列名,可以为其他关联表的列名 + * @param termType 条件类型 {@link TermType} {@link org.hswebframework.ezorm.rdb.operator.builder.fragments.TermFragmentBuilder#getId() } + * @param alias 另外一个join表别名 + * @param mainOrJoinColumn 关联表列名 + * @return this + */ + Self applyColumn(StaticMethodReferenceColumn joinColumn, + String termType, + String alias, + StaticMethodReferenceColumn mainOrJoinColumn); + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryAnalyzer.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryAnalyzer.java new file mode 100644 index 000000000..d80733b1b --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryAnalyzer.java @@ -0,0 +1,208 @@ +package org.hswebframework.web.crud.query; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.hswebframework.ezorm.core.FeatureId; +import org.hswebframework.ezorm.core.FeatureType; +import org.hswebframework.ezorm.core.meta.Feature; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.TableOrViewMetadata; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 查询分析器,用于分析SQL查询语句以及对SQL进行重构,追加查询条件等操作 + * + * @author zhouhao + * @since 4.0.16 + */ +public interface QueryAnalyzer { + + /** + * @return 原始SQL + */ + String originalSql(); + + /** + * 基于{@link QueryParamEntity}动态条件来重构SQL,将根据动态条件添加where条件,排序等操作. + * + * @param entity 查询条件 + * @param args 原始SQL中的预编译参数 + * @return 重构后的SQL + */ + SqlRequest refactor(QueryParamEntity entity, Object... args); + + /** + * 基于{@link QueryParamEntity}动态条件来重构用于查询count的SQL,通常用于分页时查询总数. + *
{@code
+     *  select count(1) _total from .....
+     * }
+ * + * @param entity 查询条件 + * @param args 原始SQL中的预编译参数 + * @return 重构后的SQL + */ + SqlRequest refactorCount(QueryParamEntity entity, Object... args); + + /** + * @return 查询信息 + */ + Select select(); + + /** + * 根据名称或者别名,查找查询语句中的列信息. + * + * @param name 列名、别名或者列全名 + * @return 列信息 + */ + Optional findColumn(String name); + + /** + * 判断查询的列是否为表达式,如使用了函数: sum(num) as num + * + * @param name 列名 + * @param index 列序号 + * @return 是否为表达式 + */ + boolean columnIsExpression(String name, int index); + + /** + * @return 关联表信息 + */ + List joins(); + + @AllArgsConstructor + @Getter + class Join { + + final String alias; + final Type type; + final Table table; + + // final List on; + + enum Type { + left, right, inner + } + } + + @RequiredArgsConstructor + @Getter + class Select { + private transient Map columns; + + final List columnList; + + final Table table; + + public Select newSelectAlias(String alias) { + return new Select(columnList + .stream() + .map(col -> col.moveOwner(alias)) + .collect(Collectors.toList()), + table.newAlias(alias)); + } + + public Map getColumns() { + return columns == null + ? columns = columnList + .stream() + .collect(Collectors.toMap(Column::getAlias, Function.identity(), (a, b) -> b)) + : columns; + } + } + + @Getter + @AllArgsConstructor + class Table { + final String alias; + + final TableOrViewMetadata metadata; + + public Table newAlias(String alias) { + return new Table(alias, metadata); + } + } + + @AllArgsConstructor + @Getter + class Column implements Feature { + static final FeatureId FEATURE_ID = FeatureId.of("AnalyzedColumn"); + + //列名 + String name; + //别名 + String alias; + //所有者 + String owner; + //元数据信息 + RDBColumnMetadata metadata; + + public Column moveOwner(String owner) { + return new Column(name, alias, owner, metadata); + } + + @Override + public String getId() { + return FEATURE_ID.getId(); + } + + @Override + public FeatureType getType() { + return AnalyzerFeatureType.AnalyzedCol; + } + } + + class SelectTable extends Table { + final Map columns; + + public SelectTable(String alias, + Map columns, + TableOrViewMetadata metadata) { + super(alias, metadata); + this.columns = columns; + } + + @Override + public Table newAlias(String alias) { + return new SelectTable( + alias, + columns + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().moveOwner(alias), + (l, r) -> r, + LinkedHashMap::new + )) + , metadata); + } + + public Map getColumns() { + return Collections.unmodifiableMap(columns); + } + } + + + enum AnalyzerFeatureType implements FeatureType { + AnalyzedCol; + + @Override + public String getId() { + return name(); + } + + @Override + public String getName() { + return name(); + } + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryAnalyzerImpl.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryAnalyzerImpl.java new file mode 100644 index 000000000..cca2fb1a2 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryAnalyzerImpl.java @@ -0,0 +1,1186 @@ +package org.hswebframework.web.crud.query; + +import lombok.Getter; +import lombok.SneakyThrows; +import net.sf.jsqlparser.expression.*; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.select.*; +import net.sf.jsqlparser.statement.values.ValuesStatement; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.ezorm.core.meta.FeatureSupportedMetadata; +import org.hswebframework.ezorm.core.param.Sort; +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.metadata.*; +import org.hswebframework.ezorm.rdb.metadata.dialect.Dialect; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.*; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import java.util.*; + +import static net.sf.jsqlparser.statement.select.PlainSelect.getFormatedList; +import static org.hswebframework.ezorm.rdb.operator.builder.fragments.TermFragmentBuilder.createFeatureId; + + +class QueryAnalyzerImpl implements FromItemVisitor, SelectItemVisitor, SelectVisitor, QueryAnalyzer { + + private final DatabaseOperator database; + + private String sql; + + private final SelectBody parsed; + + private QueryAnalyzer.Select select; + + private final Map joins = new LinkedHashMap<>(); + + private final List withItems = new ArrayList<>(); + private QueryRefactor injector; + + private volatile Map columnMappings; + + private final Map virtualTable = new HashMap<>(); + + @Override + public String originalSql() { + return sql; + } + + @Override + public SqlRequest refactor(QueryParamEntity entity, Object... args) { + if (injector == null) { + initInjector(); + } + return injector.refactor(entity, args); + } + + @Override + public SqlRequest refactorCount(QueryParamEntity entity, Object... args) { + if (injector == null) { + initInjector(); + } + return injector.refactorCount(entity, args); + } + + @Override + public Select select() { + return select; + } + + @Override + public Optional findColumn(String name) { + return Optional.ofNullable(getColumnMappings().get(name)); + } + + @Override + public List joins() { + return new ArrayList<>(joins.values()); + } + + QueryAnalyzerImpl(DatabaseOperator database, String sql) { + this(database, parse(sql)); + this.sql = sql; + } + + + public boolean columnIsExpression(String name, int index) { + + if (index >= 0 && select.getColumnList().size() > index) { + return select.getColumnList().get(index) instanceof ExpressionColumn; + } + + return select.getColumns().get(name) instanceof ExpressionColumn; + } + + private Map getColumnMappings() { + if (columnMappings == null) { + synchronized (this) { + if (columnMappings == null) { + columnMappings = new HashMap<>(); + + if (select.table instanceof SelectTable) { + + for (Map.Entry entry : + ((SelectTable) select.getTable()).getColumns().entrySet()) { + Column column = entry.getValue(); + Column col = new Column(column.getName(), column.getAlias(), select.table.alias, column.metadata); + columnMappings.put(entry.getKey(), col); + columnMappings.put(select.table.alias + "." + entry.getKey(), col); + + if (!(column instanceof ExpressionColumn) && column.metadata != null) { + columnMappings.put(column.metadata.getName(), col); + columnMappings.put(select.table.alias + "." + column.metadata.getName(), col); + columnMappings.put(column.metadata.getAlias(), col); + columnMappings.put(select.table.alias + "." + column.metadata.getAlias(), col); + } + } + + for (Column column : select.getColumnList()) { + columnMappings.put(column.getName(), column); + columnMappings.put(column.getAlias(), column); + if (null != column.getOwner()) { + columnMappings.put(column.getOwner() + "." + column.getName(), column); + columnMappings.put(column.getOwner() + "." + column.getAlias(), column); + } + } + } else { + // 主表 + for (RDBColumnMetadata column : select.table.metadata.getColumns()) { + Column col = new Column(column.getName(), column.getAlias(), select.table.alias, column); + columnMappings.put(column.getName(), col); + columnMappings.put(column.getAlias(), col); + columnMappings.put(select.table.alias + "." + column.getName(), col); + columnMappings.put(select.table.alias + "." + column.getAlias(), col); + } + } + + //关联表 + for (Join join : joins.values()) { + if (join.table instanceof SelectTable) { + for (Column column : select.getColumnList()) { + columnMappings.putIfAbsent(column.getName(), column); + columnMappings.putIfAbsent(column.getAlias(), column); + columnMappings.put(column.getOwner() + "." + column.getName(), column); + columnMappings.put(column.getOwner() + "." + column.getAlias(), column); + } + } else { + for (RDBColumnMetadata column : join.table.metadata.getColumns()) { + Column col = new Column(column.getName(), column.getAlias(), join.alias, column); + columnMappings.putIfAbsent(column.getName(), col); + columnMappings.putIfAbsent(column.getAlias(), col); + + columnMappings.put(join.alias + "." + column.getName(), col); + columnMappings.put(join.alias + "." + column.getAlias(), col); + } + } + + } + } + } + } + return columnMappings; + } + + private Column getColumnOrSelectColumn(String name) { + Column column = select.getColumns().get(name); + + if (column != null) { + return column; + } + column = select.getColumns().get(QueryHelperUtils.toSnake(name)); + if (column != null) { + return column; + } + + return getColumnMappings().get(name); + } + + @SneakyThrows + private static net.sf.jsqlparser.statement.select.Select parse(String sql) { + return ((net.sf.jsqlparser.statement.select.Select) CCJSqlParserUtil.parse(sql)); + } + + QueryAnalyzerImpl(DatabaseOperator database, SelectBody selectBody, QueryAnalyzerImpl parent) { + this.database = database; + this.virtualTable.putAll(parent.virtualTable); + if (null != selectBody) { + this.parsed = selectBody; + selectBody.accept(this); + } else { + this.parsed = null; + } + } + + QueryAnalyzerImpl(DatabaseOperator database, SubSelect select, QueryAnalyzerImpl parent) { + this.parsed = select.getSelectBody(); + this.database = database; + this.virtualTable.putAll(parent.virtualTable); + //with ... + if (CollectionUtils.isNotEmpty(select.getWithItemsList())) { + for (WithItem withItem : select.getWithItemsList()) { + withItem.accept(this); + } + } + if (this.parsed != null) { + this.parsed.accept(this); + } + } + + QueryAnalyzerImpl(DatabaseOperator database, net.sf.jsqlparser.statement.select.Select select) { + this.parsed = select.getSelectBody(); + this.database = database; + //with ... + if (CollectionUtils.isNotEmpty(select.getWithItemsList())) { + for (WithItem withItem : select.getWithItemsList()) { + withItem.accept(this); + } + } + + if (this.parsed != null) { + this.parsed.accept(this); + } + } + + private String parsePlainName(String name) { + if (name == null || name.isEmpty()) { + return null; + } + char firstChar = name.charAt(0); + + if (firstChar == '`' || firstChar == '"' || firstChar == '[' || + name.startsWith(database.getMetadata().getDialect().getQuoteStart())) { + + return new String(name.toCharArray(), 1, name.length() - 2); + } + + return name; + } + + @Override + public void visit(net.sf.jsqlparser.schema.Table tableName) { + String schema = parsePlainName(tableName.getSchemaName()); + + String name = parsePlainName(tableName.getName()); + + RDBSchemaMetadata schemaMetadata; + if (schema != null) { + schemaMetadata = database + .getMetadata() + .getSchema(schema) + .orElseThrow(() -> new IllegalStateException("schema " + schema + " not initialized")); + } else { + schemaMetadata = database.getMetadata().getCurrentSchema(); + if (!virtualTable.containsKey(name)) { + tableName.setSchemaName(schemaMetadata.getQuoteName()); + } + } + + String alias = tableName.getAlias() == null ? tableName.getName() : tableName.getAlias().getName(); + + TableOrViewMetadata tableMetadata = schemaMetadata + .getTableOrView(name, false) + .orElseGet(() -> virtualTable.get(name)); + + if (tableMetadata == null) { + throw new IllegalStateException("table or view " + tableName.getName() + " not found in " + schemaMetadata.getName()); + } + tableName.setName(tableMetadata.getRealName()); + QueryAnalyzer.Table table = new QueryAnalyzer.Table( + parsePlainName(alias), + tableMetadata + ); + + select = new QueryAnalyzer.Select(new ArrayList<>(), table); + + } + + // select * from ( select a,b,c from table ) t + @Override + public void visit(SubSelect subSelect) { + visit(subSelect, subSelect.getAlias() == null ? null : subSelect.getAlias().getName()); + } + + public void visit(SubSelect subSelect, String alias) { + SelectBody body = subSelect.getSelectBody(); + QueryAnalyzerImpl sub = new QueryAnalyzerImpl(database, body, this); + Map columnMap = new LinkedHashMap<>(); + for (Column column : sub.select.getColumnList()) { + + columnMap.put(column.getAlias(), + new Column(column.name, column.getAlias(), column.owner, column.metadata)); + } + + select = new QueryAnalyzer.Select( + new ArrayList<>(), + new QueryAnalyzer.SelectTable( + parsePlainName(alias), + columnMap, + sub.select.table.metadata + ) + ); + } + + @Override + public void visit(SubJoin subjoin) { + for (net.sf.jsqlparser.statement.select.Join join : subjoin.getJoinList()) { + join.getRightItem().accept(this); + } + } + + @Override + public void visit(LateralSubSelect lateralSubSelect) { + this.visit(lateralSubSelect.getSubSelect(), + lateralSubSelect.getAlias() == null ? null : lateralSubSelect.getAlias().getName()); + } + + @Override + public void visit(ValuesList valuesList) { + if (valuesList.getAlias() == null) { + throw new IllegalArgumentException("valuesList[" + valuesList + "] must have alias"); + } + String name = parsePlainName(valuesList.getAlias().getName()); + FakeTable view = new FakeTable(); + if (valuesList.getColumnNames() != null) { + //获取会自动创建列 + for (String columnName : valuesList.getColumnNames()) { + RDBColumnMetadata ignore = view.getColumn(parsePlainName(columnName)).orElse(null); + } + } + + if (valuesList.getAlias().getAliasColumns() != null) { + for (Alias.AliasColumn alias : valuesList.getAlias().getAliasColumns()) { + RDBColumnMetadata ignore = view.getColumn(parsePlainName(alias.name)).orElse(null); + } + } + + view.setName(name); + view.setRealName(name); + view.setSchema(database.getMetadata().getCurrentSchema()); + view.setAlias(name); + + Table table = new Table(name, view); + + select = new QueryAnalyzer.Select(new ArrayList<>(), table); + } + + @Override + public void visit(TableFunction tableFunction) { + if (tableFunction.getAlias() == null) { + throw new IllegalArgumentException("table function[" + tableFunction + "] must have alias"); + } + String name = parsePlainName(tableFunction.getAlias().getName()); + + FakeTable view = new FakeTable(); + + view.setName(name); + view.setSchema(database.getMetadata().getCurrentSchema()); + view.setAlias(name); + + Table table = new Table(name, view); + + select = new QueryAnalyzer.Select(new ArrayList<>(), table); + + } + + @Override + public void visit(ParenthesisFromItem aThis) { + aThis.getFromItem().accept(this); + String alias = parsePlainName(aThis.getAlias() == null ? null : aThis.getAlias().getName()); + if (alias != null) { + this.select = select.newSelectAlias(alias); + } + } + + @Override + public void visit(AllColumns allColumns) { + putSelectColumns(select.table, select.columnList); + + for (QueryAnalyzer.Join value : new HashSet<>(joins.values())) { + putSelectColumns(value.table, select.columnList); + } + } + + private void putSelectColumns(QueryAnalyzer.Table table, List container) { + + if (table instanceof QueryAnalyzer.SelectTable) { + QueryAnalyzer.SelectTable selectTable = ((QueryAnalyzer.SelectTable) table); + + for (QueryAnalyzer.Column column : selectTable.columns.values()) { + String alias = table == select.table ? column.getAlias() : table.alias + "." + column.getAlias(); + container.add(new QueryAnalyzer.Column( + column.name, + alias, + table.alias, + column.metadata + )); + } + } else { + for (RDBColumnMetadata column : table.metadata.getColumns()) { + String alias = table == select.table ? column.getAlias() : table.alias + "." + column.getAlias(); + + container.add(new QueryAnalyzer.Column( + column.getRealName(), + alias, + table.alias, + column + )); + } + } + } + + @Override + public void visit(AllTableColumns allTableColumns) { + net.sf.jsqlparser.schema.Table table = allTableColumns.getTable(); + + String name = table.getName(); + + if (Objects.equals(select.table.alias, name)) { + putSelectColumns(select.table, select.columnList); + return; + } + + QueryAnalyzer.Join join = joins.get(parsePlainName(table.getName())); + + if (join == null) { + throw new IllegalStateException("table " + table.getName() + " not found in join"); + } + putSelectColumns(join.table, select.columnList); + } + + private QueryAnalyzer.Table getTable(net.sf.jsqlparser.schema.Table table) { + QueryAnalyzer.Table meta; + if (null == table) { + return select.table; + } + String tableName = parsePlainName(table.getName()); + + if (Objects.equals(tableName, select.table.alias)) { + meta = select.table; + } else { + QueryAnalyzer.Join join = joins.get(tableName); + if (join == null) { + throw new IllegalStateException("table " + table + " not found in from or join"); + } + meta = join.table; + } + return meta; + } + + + static class ExpressionColumn extends Column { + + private final SelectItem expr; + + public ExpressionColumn(String alias, String owner, RDBColumnMetadata metadata, SelectItem expr) { + super(alias, alias, owner, metadata); + this.expr = expr; + } + + @Override + public ExpressionColumn moveOwner(String owner) { + return new ExpressionColumn(alias, owner, metadata, expr); + } + } + + private void refactorAlias(Alias alias) { + if (alias != null) { + alias.setName( + database + .getMetadata() + .getDialect() + .quote(parsePlainName(alias.getName()), false) + ); + } + } + + @Override + public void visit(SelectExpressionItem selectExpressionItem) { + Expression expr = selectExpressionItem.getExpression(); + Alias alias = selectExpressionItem.getAlias(); + + if (!(expr instanceof net.sf.jsqlparser.schema.Column)) { + String aliasName = parsePlainName(alias == null ? expr.toString() : alias.getName()); + refactorAlias(alias); + select.columnList.add(new ExpressionColumn(aliasName, null, null, selectExpressionItem)); + + return; + } + net.sf.jsqlparser.schema.Column column = ((net.sf.jsqlparser.schema.Column) expr); + + String columnName = parsePlainName(column.getColumnName()); + + QueryAnalyzer.Table table = getTable(column.getTable()); + + String aliasName = alias == null ? columnName : parsePlainName(alias.getName()); + + RDBColumnMetadata metadata = table + .getMetadata() + .getColumn(columnName) + .orElse(null); + + if (metadata == null) { + if (table instanceof QueryAnalyzer.SelectTable) { + Column c = ((SelectTable) table).columns.get(columnName); + if (null != c) { + if (c.metadata == null) { + select.columnList.add(new QueryAnalyzer.Column(c.getName(), aliasName, table.alias, null)); + return; + } + metadata = c.metadata; + } + } + } + + if (metadata == null) { + throw new IllegalStateException("column [" + column.getColumnName() + "] not found in " + table.metadata.getName()); + } + + select.columnList.add(new QueryAnalyzer.Column(metadata.getRealName(), aliasName, table.alias, metadata)); + + + } + + @Override + public void visit(PlainSelect select) { + + FromItem from = select.getFromItem(); + + if (from == null) { + throw new IllegalArgumentException("select can not be without 'from'"); + } + from.accept(this); + + + List joinList = select.getJoins(); + + if (joinList != null) { + for (net.sf.jsqlparser.statement.select.Join join : joinList) { + FromItem fromItem = join.getRightItem(); + QueryAnalyzerImpl joinAn = new QueryAnalyzerImpl(database, (SelectBody) null, this); + fromItem.accept(joinAn); + + Join.Type type; + if (join.isLeft()) { + type = Join.Type.left; + } else if (join.isRight()) { + type = Join.Type.right; + } else if (join.isInner()) { + type = Join.Type.inner; + } else { + type = null; + } + joins.put(joinAn.select.table.alias, new Join(joinAn.select.table.alias, type, joinAn.select.table)); + } + } + + for (SelectItem selectItem : select.getSelectItems()) { + selectItem.accept(this); + } + } + + @Override + public void visit(SetOperationList setOpList) { + //union + + for (SelectBody body : setOpList.getSelects()) { + body.accept(this); + // break; + } + + + } + + @Override + public void visit(WithItem withItem) { + withItems.add(withItem); + + String name = withItem.getName(); + RDBViewMetadata view = new RDBViewMetadata(); + view.setName(name); + view.setSchema(database.getMetadata().getCurrentSchema()); + virtualTable.put(name, view); + if (withItem.getSubSelect() != null) { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl(database, withItem.getSubSelect(), this); + for (Column column : analyzer.select.getColumnList()) { + RDBColumnMetadata metadata; + if (column.getMetadata() == null) { + metadata = new RDBColumnMetadata(); + } else { + metadata = column.metadata.clone(); + } + metadata.setName(column.getName()); + metadata.setAlias(column.getAlias()); + view.addColumn(metadata); + } + } + } + + @Override + public void visit(ValuesStatement aThis) { + + } + + private void initInjector() { + SimpleQueryRefactor injector = new SimpleQueryRefactor(); + parsed.accept(injector); + for (WithItem withItem : withItems) { + withItem.accept(injector); + } + this.injector = injector; + } + + static class QueryAnalyzerTermsFragmentBuilder extends AbstractTermsFragmentBuilder { + + @Override + public SqlFragments createTermFragments(QueryAnalyzerImpl parameter, List terms) { + return super.createTermFragments(parameter, terms); + } + + @Override + public SqlFragments createTermFragments(QueryAnalyzerImpl impl, Term term) { + Dialect dialect = impl.database.getMetadata().getDialect(); + + Table table = impl.select.table; + String column = term.getColumn(); + + Column col = impl.getColumnMappings().get(column); +// +// if (col == null) { +// if (column.contains(".")) { +// String[] split = column.split("\\."); +// if (split.length == 2) { +// QueryAnalyzer.Join join = impl.joins.get(split[0]); +// if (null != join) { +// table = join.table; +// column = split[1]; +// } else { +// throw new IllegalArgumentException("undefined column [" + column + "]"); +// } +// } +// } +// RDBColumnMetadata columnMetadata = table +// .getMetadata() +// .getColumn(column) +// .orElse(null); +// if (columnMetadata != null) { +// col = new Column(column, column, table.alias, columnMetadata); +// } else { +// throw new IllegalArgumentException("undefined column [" + column + "]"); +// } +// } + if (col == null) { + throw new IllegalArgumentException("undefined column [" + column + "]"); + } + + if (!Objects.equals(impl.select.table.alias, col.getOwner())) { + QueryAnalyzer.Join join = impl.joins.get(col.getOwner()); + if (null != join) { + table = join.table; + } else { + throw new IllegalArgumentException("undefined column [" + column + "]"); + } + } + + FeatureSupportedMetadata metadata = col.metadata; + if (col.metadata == null) { + metadata = table.metadata; + } + + String colName = col.metadata != null ? col.metadata.getRealName() : col.name; + String fullName = col.metadata != null + ? col.getMetadata().getFullName(table.alias) + : table.alias + "." + dialect.quote(colName, false); + + return metadata + .findFeature(createFeatureId(term.getTermType())) + .map(feature -> feature.createFragments( + fullName, col.metadata, term)) + .orElse(EmptySqlFragments.INSTANCE); + } + } + + static QueryAnalyzerTermsFragmentBuilder TERMS_BUILDER = new QueryAnalyzerTermsFragmentBuilder(); + + class SimpleQueryRefactor implements QueryRefactor, SelectVisitor { + private String prefix = ""; + private String from; + + private String columns; + + private String where; + private int prefixParameters; + private String orderBy; + + private String suffix; + private int suffixParameters; + + private boolean fastCount = true; + + private SqlFragments QUERY, SUFFIX, FAST_COUNT, SLOW_COUNT; + + SimpleQueryRefactor() { + + } + + + private void initColumns(StringBuilder columns) { + int idx = 0; + Dialect dialect = database.getMetadata().getDialect(); + + if (select.columnList.size() == 1 && "*".equals(select.columnList.get(0).name)) { + columns.append(select.columnList.get(0).owner).append('.').append('*'); + return; + } + for (Column column : select.columnList) { + if ("*".equals(column.name)) { + continue; + } + + if (idx++ > 0) { + columns.append(","); + } + if (column instanceof ExpressionColumn) { + columns.append(((ExpressionColumn) column).expr); + fastCount = false; + continue; + } + + columns.append(column.owner) + .append('.') + .append(dialect.quote(column.name, column.metadata != null && !column.metadata.realNameDetected())) + .append(" as ") + .append(dialect.quote(column.alias, false)); + } + } + + @Override + public void visit(PlainSelect plainSelect) { + + StringBuilder from = new StringBuilder(); + StringBuilder columns = new StringBuilder(); + StringBuilder suffix = new StringBuilder(); + + + if (plainSelect.getDistinct() != null) { + columns.append(plainSelect.getDistinct()) + .append(' '); + fastCount = false; + } + + initColumns(columns); + + if (plainSelect.getFromItem() != null) { + from.append("FROM "); + + from.append(plainSelect.getFromItem()); + PrepareStatementVisitor visitor = new PrepareStatementVisitor(); + plainSelect.getFromItem().accept(visitor); + prefixParameters += visitor.parameterSize; + } + + if (plainSelect.getJoins() != null) { + PrepareStatementVisitor visitor = new PrepareStatementVisitor(); + for (net.sf.jsqlparser.statement.select.Join join : plainSelect.getJoins()) { + if (join.isSimple()) { + from.append(", ").append(join); + } else { + from.append(" ").append(join); + } + if (null != join.getRightItem()) { + join.getRightItem().accept(visitor); + } + if (null != join.getOnExpressions()) { + for (Expression onExpression : join.getOnExpressions()) { + onExpression.accept(visitor); + } + } + } + prefixParameters += visitor.parameterSize; + } + + if (plainSelect.getWhere() != null) { + PrepareStatementVisitor visitor = new PrepareStatementVisitor(); + plainSelect.getWhere().accept(visitor); + prefixParameters += visitor.parameterSize; + where = plainSelect.getWhere().toString(); + } + + if (plainSelect.getOrderByElements() != null) { + orderBy = getFormatedList(plainSelect.getOrderByElements(), ""); + } + + if (plainSelect.getGroupBy() != null) { + fastCount = false; + suffix.append(' ').append(plainSelect.getGroupBy()); + } + suffix.append(' '); + + if (plainSelect.getHaving() != null) { + PrepareStatementVisitor visitor = new PrepareStatementVisitor(); + plainSelect.getHaving().accept(visitor); + suffixParameters = visitor.parameterSize; + suffix.append(" HAVING ").append(plainSelect.getHaving()); + } + + this.columns = columns.toString(); + this.from = from.toString(); + this.suffix = suffix.toString(); + + } + + @Override + public void visit(SetOperationList setOpList) { + StringBuilder from = new StringBuilder(); + StringBuilder columns = new StringBuilder(); + + initColumns(columns); + + from.append("FROM ("); + from.append(setOpList); + from.append(") "); + from.append(select.table.alias); + + this.from = from.toString(); + this.columns = columns.toString(); + this.suffix = ""; + + } + + @Override + public void visit(WithItem withItem) { + if (!StringUtils.hasText(prefix)) { + prefix += "WITH "; + } + prefix += withItem; + PrepareStatementVisitor visitor = new PrepareStatementVisitor(); + withItem.accept(visitor); + prefixParameters += visitor.parameterSize; + } + + @Override + public void visit(ValuesStatement aThis) { + PrepareStatementVisitor visitor = new PrepareStatementVisitor(); + aThis.accept(visitor); + } + + public Object[] getPrefixParameters(Object... args) { + if (prefixParameters == 0) { + return new Object[0]; + } + Assert.isTrue(args.length >= prefixParameters, + "Illegal prepare statement parameter size, expect: " + prefixParameters + ", actual: " + args.length); + + return Arrays.copyOfRange(args, 0, prefixParameters); + } + + public Object[] getSuffixParameters(Object... args) { + if (suffixParameters == 0) { + return new Object[0]; + } + Assert.isTrue(args.length >= suffixParameters + prefixParameters, + "Illegal prepare statement parameter size, expect: " + suffixParameters + prefixParameters + ", actual: " + args.length); + + return Arrays.copyOfRange(args, prefixParameters, suffixParameters + prefixParameters); + } + + @Override + public SqlRequest refactor(QueryParamEntity param, Object... args) { + if (QUERY == null) { + QUERY = SqlFragments.of(prefix, "SELECT", columns, from); + } + BatchSqlFragments sql = new BatchSqlFragments( + StringUtils.hasText(where) ? 10 : 6, 2); + sql.add(QUERY) + .addParameter(getPrefixParameters(args)); + + appendWhere(sql, param); + + sql.addSql(suffix) + .addParameter(getSuffixParameters(args)); + + appendOrderBy(sql, param); + + return sql.toRequest(); + } + + + @Override + public SqlRequest refactorCount(QueryParamEntity param, Object... args) { + BatchSqlFragments sql = new BatchSqlFragments( + StringUtils.hasText(where) ? 10 : 7, 2); + if (SUFFIX == null) { + SUFFIX = SqlFragments.of(suffix); + } + + if (fastCount) { + if (FAST_COUNT == null) { + FAST_COUNT = SqlFragments.of( + prefix, "SELECT count(1) as", + database.getMetadata().getDialect().quote("_total"), + from); + } + //SELECT count(1) as _total from + sql.add(FAST_COUNT); + sql.addParameter(getPrefixParameters(args)); + + appendWhere(sql, param); + + sql.add(SUFFIX); + } else { + if (SLOW_COUNT == null) { + SLOW_COUNT = SqlFragments + .of(prefix, + "SELECT count(1) as", + database.getMetadata().getDialect().quote("_total"), + "from (SELECT", columns, from); + } + + sql.add(SLOW_COUNT); + sql.addParameter(getPrefixParameters(args)); + + appendWhere(sql, param); + + sql.add(SUFFIX); + sql.addSql(") _t"); + } + + return sql + .addParameter(getSuffixParameters(args)) + .toRequest(); + } + + private void appendOrderBy(AppendableSqlFragments sql, QueryParamEntity param) { + + if (CollectionUtils.isNotEmpty(param.getSorts())) { + int index = 0; + BatchSqlFragments orderByValue = null; + BatchSqlFragments orderByColumn = null; + for (Sort sort : param.getSorts()) { + String name = sort.getName(); + Column column = getColumnOrSelectColumn(name); + + if (column == null) { + continue; + } + boolean desc = "desc".equalsIgnoreCase(sort.getOrder()); + String columnName = column.getOwner() == null ? + database.getMetadata().getDialect().quote(column.getName(), false) + : org.hswebframework.ezorm.core.utils.StringUtils + .concat(column.getOwner(), + ".", + database.getMetadata().getDialect().quote(column.getName())); + //按固定值排序 + if (sort.getValue() != null) { + if (orderByValue == null) { + orderByValue = new BatchSqlFragments(); + orderByValue.addSql("case"); + } + orderByValue.addSql("when"); + orderByValue.addSql(columnName, "= ?").addParameter(sort.getValue()); + orderByValue.addSql("then").addSql(String.valueOf(desc ? 10000 + index++ : index++)); + } else { + if (orderByColumn == null) { + orderByColumn = new BatchSqlFragments(); + } else { + orderByColumn.addSql(","); + } + //todo function支持 + orderByColumn + .addSql(columnName) + .addSql(desc ? "DESC" : "ASC"); + } + } + + boolean customOrder = (orderByValue != null || orderByColumn != null); + + if (customOrder || orderBy != null) { + sql.addSql("ORDER BY"); + } + //按固定值 + if (orderByValue != null) { + orderByValue.addSql("else 10000 end"); + sql.addFragments(orderByValue); + } + //按列 + if (orderByColumn != null) { + if (orderByValue != null) { + sql.add(SqlFragments.COMMA); + } + sql.addFragments(orderByColumn); + } + if (orderBy != null) { + if (customOrder) { + sql.add(SqlFragments.COMMA); + } + sql.addSql(orderBy); + } + } else { + if (orderBy != null) { + sql.addSql("ORDER BY", orderBy); + } + } + + } + + private void appendWhere(AppendableSqlFragments sql, QueryParamEntity param) { + SqlFragments fragments = TERMS_BUILDER.createTermFragments(QueryAnalyzerImpl.this, param.getTerms()); + + if (fragments.isNotEmpty() || StringUtils.hasText(where)) { + sql.add(SqlFragments.WHERE); + } + + if (StringUtils.hasText(where)) { + sql.add(SqlFragments.LEFT_BRACKET); + sql.addSql(where); + sql.add(SqlFragments.RIGHT_BRACKET); + } + + if (fragments.isNotEmpty()) { + if (StringUtils.hasText(where)) { + sql.add(SqlFragments.AND); + } + sql.add(SqlFragments.LEFT_BRACKET); + sql.addFragments(fragments); + sql.add(SqlFragments.RIGHT_BRACKET); + } + } + + } + + + @Getter + static class PrepareStatementVisitor extends ExpressionVisitorAdapter implements FromItemVisitor, SelectVisitor { + private int parameterSize; + + public PrepareStatementVisitor() { + setSelectVisitor(this); + } + + @Override + public void visit(JdbcParameter parameter) { + parameterSize++; + super.visit(parameter); + } + + @Override + public void visit(net.sf.jsqlparser.schema.Table tableName) { + + } + + @Override + public void visit(SubJoin subjoin) { + if (subjoin.getLeft() != null) { + subjoin.getLeft().accept(this); + } + if (CollectionUtils.isNotEmpty(subjoin.getJoinList())) { + for (net.sf.jsqlparser.statement.select.Join join : subjoin.getJoinList()) { + if (join.getRightItem() != null) { + join.getRightItem().accept(this); + } + if (join.getOnExpressions() != null) { + join.getOnExpressions().forEach(expr -> expr.accept(this)); + } + } + } + } + + @Override + public void visit(LateralSubSelect lateralSubSelect) { + if (lateralSubSelect.getSubSelect() != null) { + lateralSubSelect.getSubSelect().accept((ExpressionVisitor) this); + } + } + + @Override + public void visit(ValuesList valuesList) { + if (valuesList.getMultiExpressionList() != null) { + for (ExpressionList expressionList : valuesList.getMultiExpressionList().getExpressionLists()) { + expressionList.getExpressions().forEach(expr -> expr.accept(this)); + } + } + } + + @Override + public void visit(TableFunction tableFunction) { + tableFunction.getFunction().accept(this); + } + + @Override + public void visit(ParenthesisFromItem aThis) { + aThis.getFromItem().accept(this); + } + + @Override + public void visit(PlainSelect plainSelect) { + plainSelect.getFromItem().accept(this); + if (plainSelect.getJoins() != null) { + for (net.sf.jsqlparser.statement.select.Join join : plainSelect.getJoins()) { + join.getRightItem().accept(this); + } + } + if (plainSelect.getSelectItems() != null) { + for (SelectItem selectItem : plainSelect.getSelectItems()) { + selectItem.accept(this); + } + } + if (plainSelect.getWhere() != null) { + plainSelect.getWhere().accept(this); + } + if (plainSelect.getHaving() != null) { + plainSelect.getHaving().accept(this); + } + + if (plainSelect.getGroupBy() != null) { + for (Expression expression : plainSelect.getGroupBy().getGroupByExpressionList().getExpressions()) { + expression.accept(this); + } + } + } + + @Override + public void visit(SetOperationList setOpList) { + if (CollectionUtils.isNotEmpty(setOpList.getSelects())) { + for (SelectBody select : setOpList.getSelects()) { + select.accept(this); + } + } + if (setOpList.getOffset() != null) { + setOpList.getOffset().getOffset().accept(this); + } + if (setOpList.getLimit() != null) { + if (setOpList.getLimit().getRowCount() != null) { + setOpList.getLimit().getRowCount().accept(this); + } + if (setOpList.getLimit().getOffset() != null) { + setOpList.getLimit().getOffset().accept(this); + } + } + } + + @Override + public void visit(WithItem withItem) { + if (CollectionUtils.isNotEmpty(withItem.getWithItemList())) { + for (SelectItem selectItem : withItem.getWithItemList()) { + selectItem.accept(this); + } + } + if (withItem.getSubSelect() != null) { + withItem.getSubSelect().accept((ExpressionVisitor) this); + } + } + + @Override + public void visit(ValuesStatement aThis) { + if (aThis.getExpressions() != null) { + aThis.getExpressions().accept(this); + } + } + } + + static class FakeTable extends RDBViewMetadata { + @Override + public Optional getColumn(String name) { + //sql中声明的列都可以使用 + + QueryHelperUtils.assertLegalColumn(name); + + RDBColumnMetadata fake = new RDBColumnMetadata(); + fake.setName(name); + addColumn(fake); + return Optional.of(fake); + } + } + + private interface QueryRefactor { + + SqlRequest refactor(QueryParamEntity param, Object... args); + + SqlRequest refactorCount(QueryParamEntity param, Object... args); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryHelper.java new file mode 100644 index 000000000..1c109291c --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryHelper.java @@ -0,0 +1,800 @@ +package org.hswebframework.web.crud.query; + +import org.hswebframework.ezorm.core.Conditional; +import org.hswebframework.ezorm.core.MethodReferenceConverter; +import org.hswebframework.ezorm.core.dsl.Query; +import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; +import org.hswebframework.ezorm.rdb.mapping.defaults.record.Record; +import org.hswebframework.ezorm.rdb.operator.dml.query.SortOrder; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.slf4j.Logger; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.ArrayList; +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; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * 使用DSL方式链式调用来构建复杂查询 + * + *
{@code
+ *
+ * // select a.id as `a.id` ,b.name as b.name from table_a a
+ * // left join table_b b on a.id=b.id
+ * // where b.name like 'zhang%'
+ *
+ *   Flux =  helper
+ *      .select(R.class)
+ *      .as(A::getName,R::setName)
+ *      .as(A::getId,R::setAid)
+ *      .from(A.class)
+ *      .leftJoin(B.class,spec-> spec.is(A::id, B::id))
+ *      .where(dsl->dsl.like(B::getName,'zhang%'))
+ *      .fetch();
+ *
+ * }
+ *

+ * 使用原生SQL方式来构建动态条件查询 + *

{@code
+ *      helper
+ *       .select("select * from table_a a left join table_b b on a.id=b.id",R::new)
+ *       .where(dsl->dsl.like(R::getName,'zhang%'))
+ *       .fetch();
+ *  }
+ * + * @author zhouhao + * @see QueryHelper#select(String, Object...) + * @see QueryHelper#select(Class) + * @see QueryHelper#transformPageResult(Mono, Function) + * @see QueryHelper#combineOneToMany(Flux, Getter, ReactiveQuery, Getter, Setter) + * @see QueryHelper#combineOneToMany(Flux, Getter, Function, Getter, Setter) + * @since 4.0.16 + */ +public interface QueryHelper { + + /** + * 基于SQL创建分析器 + * + * @param selectSql SQL + * @return QueryAnalyzer + */ + QueryAnalyzer analysis(String selectSql); + + /** + * 逻辑和{@link QueryHelper#select(String, Object...)}相同,将查询结果转换为指定的实体类 + * + * @param sql SQL + * @param newInstance 实体类实例化方法 + * @param args 参数 + * @param 实体类型 + * @return NativeQuerySpec + */ + NativeQuerySpec select(String sql, + Supplier newInstance, + Object... args); + + /** + * 创建原生SQL查询器 + *

+ * 预编译参数仅支持?占位符,如果要使用模版,请使用{@link org.hswebframework.ezorm.rdb.executor.SqlRequests#template(String, Object)} + * 构造sql以及参数 + *

{@code
+     *
+     *  Flux records = helper
+     *        .select("select * from table where type = ?",type)
+     *         //注入动态查询条件
+     *        .where(param)
+     *        //或者编程式构造动态条件
+     *        .where(dsl->dsl.is("name",name))
+     *        //执行查询
+     *        .fetch();
+     * }
+ *

+ * join逻辑: + * + *

{@code
+     *
+     *  helper.select("select t1.id,t2.* from table t1"+
+     *                " left join table2 t2 on t1.id = t2.id") ...
+     *
+     *  将返回结构:
+     *   [
+     *     {
+     *     "id":"t1.id的值",
+     *     "t2.c1":"t2的字段"
+     *     }
+     *   ]
+     * }
+ * + *

+ * ⚠️注意:避免动态拼接SQL语句,应该使用预编译参数或者动态注入动态条件来进行条件处理. + * + * @param sql SQL查询语句 + * @param args 预编译参数 + * @return 查询构造器 + */ + NativeQuerySpec select(String sql, Object... args); + + + /** + * 创建一个查询构造器 + * + * @param resultType 实体类型,必须明确定义实体类,不能使用{@link java.util.Map}等类型 + * @param 类型 + * @return 查询构造器 + */ + SelectColumnMapperSpec select(Class resultType); + + /** + * 创建一个查询构造器,并返回指定的实体类型 + * + * @param resultType 实体类型,必须明确定义实体类,不能使用{@link java.util.Map}等类型 + * @param mapperSpec 实体映射配置 + * @param 类型 + * @return 查询构造器 + */ + SelectSpec select(Class resultType, + Consumer> mapperSpec); + + + interface NativeQuerySpec extends ExecuteSpec { + + /** + * 设置日志,在执行sql等操作时使用此日志进行日志打印. + * + * @param logger Logger + * @return this + */ + NativeQuerySpec logger(Logger logger); + + /** + * 以DSL方式构造查询条件 + *

{@code
+         *  helper
+         *  .select("select * from table t")
+         *  .where(dsl->dsl.is("type","device"))
+         * }
+ * + * @param dsl DSL + * @return this + */ + default ExecuteSpec where(Consumer> dsl) { + Query query = QueryParamEntity.newQuery().noPaging(); + dsl.accept(query); + return where(query.getParam()); + } + + /** + * 指定动态查询条件,通常用于前端动态传入查询条件 + *
{@code
+         *  helper
+         *  .select("select * from table t")
+         *  .where(param)
+         *  .fetch()
+         * }
+ * + * @param param DSL + * @return this + */ + ExecuteSpec where(QueryParamEntity param); + + } + + interface SelectSpec { + + /** + * 指定从哪个表查询 + * + * @param clazz 实体类型,类上需要注解{@link javax.persistence.Table},并使用{@link javax.persistence.Column}来描述列 + * @param 实体类型 + * @return 查询构造器 + * @see javax.persistence.Table + */ + FromSpec from(Class clazz); + + } + + + /** + * 查询条件构造器 + * + * @param 查询结果类型 + */ + interface WhereSpec extends ExecuteSpec { + + /** + * 使用动态查询参数来作为查询条件,用于通过参数传递查询条件的场景 + * + * @param param 查询参数 + * @return 排序描述 + * @see QueryParamEntity + */ + SortSpec where(QueryParamEntity param); + + /** + * 使用DSL方式来构造查询条件,用于编程式的构造查询条件 + *
{@code
+         *
+         *   // where t.name = ? or age > 18
+         *   where(dsl->dsl.is(MyEntity::getName,name).or().gt(MyEntity::getAge,18))
+         *
+         * }
+ * + * @param dsl DSL条件构造接收器 + * @return 排序描述 + */ + SortSpec where(Consumer> dsl); + } + + + /** + * 排序构造器 + * + * @param 查询结果类型 + */ + interface SortSpec extends ExecuteSpec { + + /** + * 使用指定的列名进行正序排序,多次执行将使用多列排序 + *
{@code
+         *  // order by a.index asc
+         *  orderByAsc("a.index");
+         * }
+ * + * @param column 列名 + * @return 排序构造器 + */ + default SortSpec orderByAsc(String column) { + return orderBy(column, SortOrder.Order.asc); + } + + /** + * 使用指定的列名进行倒序排序,多次执行将使用多列排序 + *
{@code
+         *  // order by a.index desc
+         *  orderByDesc("a.index");
+         * }
+ * + * @param column 列名 + * @return 排序构造器 + */ + default SortSpec orderByDesc(String column) { + return orderBy(column, SortOrder.Order.desc); + } + + /** + * 使用指定的列名进行排序,多次执行将使用多列排序 + *
{@code
+         *  // order by a.index asc
+         *  orderBy("a.index",SortOrder.Order.asc);
+         * }
+ * + * @param column 列名 + * @param order 排序方式 + * @return 排序构造器 + */ + SortSpec orderBy(String column, + SortOrder.Order order); + + + /** + * 对方法应用对应的列名进行正序排序,多次执行将使用多列排序 + *
{@code
+         *
+         *  // order by sort_order asc
+         *  orderByAsc(MyEntity::getSortOrder)
+         *
+         * }
+ * + * @param column 方法引用 + * @param S + * @return 排序构造器 + */ + default SortSpec orderByAsc(Getter column) { + return orderBy(column, SortOrder.Order.asc); + } + + /** + * 对方法应用对应的列名进行倒序排序,多次执行将使用多列排序 + *
{@code
+         *
+         *  // order by sort_order desc
+         *  orderByDesc(MyEntity::getSortOrder)
+         *
+         * }
+ * + * @param column 方法引用 + * @param S + * @return 排序构造器 + */ + default SortSpec orderByDesc(Getter column) { + return orderBy(column, SortOrder.Order.desc); + } + + /** + * 对方法应用对应的列名进行排序,多次执行将使用多列排序 + *
{@code
+         *
+         *  // order by sort_order desc
+         *  orderBy(MyEntity::getSortOrder,SortOrder.Order.desc)
+         *
+         * }
+ * + * @param column 方法引用 + * @param S + * @return 排序构造器 + */ + SortSpec orderBy(Getter column, + SortOrder.Order order); + + + } + + interface FromSpec extends JoinSpec, SortSpec { + + + } + + /** + * 表关联构造器 + * + * @param 查询结果类型 + */ + interface JoinSpec extends WhereSpec, SortSpec { + + + /** + * 对指定的实体类进行 left join + * + *
{@code
+         *   // left join detail on my.id = detail.id
+         *   leftJoin(DetailEntity.class,spec->spec.is(MyEntity::getId,DetailEntity::getId)
+         * }
+ * + * @param type 实体类型,需要注解{@link javax.persistence.Table} + * @param on 关联条件构造器 + * @param T + * @return 表关联构造器 + */ + JoinSpec leftJoin(Class type, Consumer> on); + + /** + * 对指定的实体类进行 right join + * + *
{@code
+         *   // left join detail on my.id = detail.id
+         *   rightJoin(DetailEntity.class,spec->spec.is(MyEntity::getId,DetailEntity::getId)
+         * }
+ * + * @param type 实体类型,需要注解{@link javax.persistence.Table} + * @param on 关联条件构造器 + * @param T + * @return 表关联构造器 + */ + JoinSpec rightJoin(Class type, Consumer> on); + + /** + * 对指定的实体类进行 inner join + * + *
{@code
+         *   // inner join detail on my.id = detail.id
+         *   innerJoin(DetailEntity.class,spec->spec.is(MyEntity::getId,DetailEntity::getId)
+         * }
+ * + * @param type 实体类型,需要注解{@link javax.persistence.Table} + * @param on 关联条件构造器 + * @param T + * @return 表关联构造器 + */ + JoinSpec innerJoin(Class type, Consumer> on); + + /** + * 对指定的实体类进行 full join + * + *
{@code
+         *   // join t1 on t1.id = t2.id
+         *   fullJoin(DetailEntity.class,spec->spec.is(MyEntity::getId,DetailEntity::getId)
+         * }
+ * + * @param type 实体类型,需要注解{@link javax.persistence.Table} + * @param on 关联条件构造器 + * @param T + * @return 表关联构造器 + */ + JoinSpec fullJoin(Class type, Consumer> on); + + + } + + /** + * 执行查询 + * + * @param + */ + interface ExecuteSpec { + + /** + * 执行count查询 + * + * @return count + */ + Mono count(); + + /** + * 执行查询,返回数据流 + * + * @return 数据流 + */ + Flux fetch(); + + /** + * 执行查询,返回数据流 + * + * @return 数据流 + */ + Flux fetch(int pageIndex,int pageSize); + + /** + * 执行分页查询,默认返回第一页的25条数据. + * + * @return 分页结果 + */ + Mono> fetchPaged(); + + /** + * 执行分页查询,并对结果进行转换 + * + * @param transfer 转换器 + * @param 转换后的数据类型 + * @return 转换后的分页结果 + */ + default Mono> fetchPaged(Function, Mono>> transfer) { + return transformPageResult(fetchPaged(), transfer); + } + + /** + * 指定分页执行查询 + * + * @param pageIndex 分页序号,从0开始 + * @param pageSize 每页数量 + * @return 分页结果 + */ + Mono> fetchPaged(int pageIndex, int pageSize); + + /** + * 指定分页执行查询,并对结果进行转换 + * + * @param pageIndex 分页序号,从0开始 + * @param pageSize 每页数量 + * @param transfer 转换器 + * @param 转换后的数据类型 + * @return 转换后的分页结果 + */ + default Mono> fetchPaged(int pageIndex, int pageSize, Function, Mono>> transfer) { + return transformPageResult(fetchPaged(pageIndex, pageSize), transfer); + } + } + + interface SelectColumnMapperSpec extends ColumnMapperSpec>, SelectSpec { + + } + + /** + * 列名映射构造器 + * + * @param 查询结果类型 + * @param Self + */ + interface ColumnMapperSpec> { + + /** + * 查询指定类型对应的表的全部字段. + * + * @param tableType 类型,只能是from或者join的类型. + * @return Self + */ + Self all(Class tableType); + + /** + * 查询指定类型对应的表的全部字段并映射到结果类型的一个字段中. + * + *
{@code
+         *   all(DetailEntity.class,MyEntity::setDetail)
+         * }
+ *

+ * 如果setter对应的属性类型为List,则自动进行一对多查询. + * 此时不支持按关联表进行条件查询主表的数据. + * + * @param tableType 类型,只能是from或者join的类型. + * @return Self + * @see QueryHelper#combineOneToMany(Flux, Getter, ReactiveQuery, Getter, Setter) + */ + Self all(Class tableType, Setter setter); + + /** + * 查询指定表的全部字段. + * + * @param tableOrAlias 表名或者join别名,只能是from或者join的表. + * @return Self + */ + Self all(String tableOrAlias); + + /** + * 查询指定类型对应的表的全部字段并映射到结果类型的一个字段中. + * + *

{@code
+         *   all("detail",MyEntity::setDetail)
+         * }
+ * + * @param tableOrAlias 表名或者join别名,只能是from或者join的表. + * @return Self + */ + Self all(String tableOrAlias, Setter setter); + + /** + * 指定查询的列名,以及映射到结果类型的字段. + *
{@code
+         *   as(DetailEntity::getName,MyEntity::setDetailName)
+         * }
+ * + * @param column 列名 + * @param target 结果类型字段 + * @param S + * @param V + * @return Self + */ + Self as(Getter column, Setter target); + + /** + * 指定查询的列名,以及映射到结果类型的字段. + *
{@code
+         *   as(DetailEntity::getName,"detail.name")
+         * }
+ * + * @param column 列名 + * @param target 结果类型字段 + * @param S + * @param V + * @return Self + */ + Self as(Getter column, String target); + + /** + * 指定查询的列名,以及映射到结果类型的字段. + * + *
{@code
+         *   as("_d.name",MyEntity::setDetailName)
+         * }
+ * + * @param column 列名 + * @param target 结果类型字段 + * @return Self + */ + Self as(String column, Setter target); + + /** + * 指定查询的列名,以及映射到结果类型的字段. + *
{@code
+         *   as("_d.name","detail.name")
+         * }
+ * + * @param column 列名 + * @param target 结果类型字段 + * @return Self + */ + Self as(String column, String target); + } + + /** + * Getter接口定义,只能使用方法引用实现此接口,如: + * + *
{@code
+     *   MyEntity::getId
+     * }
+ * + * @param + * @param + */ + interface Getter extends Function, Serializable { + + } + + /** + * Setter接口定义,只能使用方法引用实现此接口,如: + * + *
{@code
+     *   MyEntity::setId
+     * }
+ * + * @param + * @param + */ + interface Setter extends BiConsumer, Serializable { + + } + + /** + * 一对多数据组合,通常用于进行一对多的数据查询. + * + *
{@code
+     *
+     *  Flux flux = QueryHelper
+     *          .combineOneToMany(
+     *               myService.createQuery().fetch(),
+     *               MyEntity::getId,
+     *               infoService.createQuery(),
+     *               InfoEntity::getMyId,
+     *               MyEntity::setInfos
+     *           )
+     *
+     * }
+ * + * @param source 源数据 + * @param idMapper 主数据的ID获取器,如: MyEntity::getId + * @param fetcher 关联数据获取器,如: infoService.createQuery() + * @param mainIdGetter 关联数据的主数据ID获取器,如: InfoEntity::getMyId + * @param setter 主数据的关联数据设置器,如: MyEntity::setInfos + * @param 主数据类型 + * @param 主数据ID类型 + * @param 关联数据类型 + * @return Flux 组合后的数据流 + */ + static Flux combineOneToMany(Flux source, + Getter idMapper, + ReactiveQuery fetcher, + Getter mainIdGetter, + Setter> setter) { + return combineOneToMany(source, + idMapper, + list -> fetcher + .copy() + .in(MethodReferenceConverter.convertToColumn(mainIdGetter), list) + .fetch(), + mainIdGetter, + setter); + } + + /** + * 一对多数据组合,通常用于进行一对多的数据查询. + * + * @param source 源数据 + * @param idMapper 主数据的ID获取器,如: MyEntity::getId + * @param fetcher 关联数据获取器,如: ids->infoService.createQuery().in(InfoEntity::getMyId,ids).fetch() + * @param mainIdGetter 关联数据的主数据ID获取器,如: InfoEntity::getMyId + * @param setter 主数据的关联数据设置器,如: MyEntity::setInfos + * @param 主数据类型 + * @param 主数据ID类型 + * @param 关联数据类型 + * @return Flux 组合后的数据流 + */ + static Flux combineOneToMany(Flux source, + Getter idMapper, + Function, Flux> fetcher, + Getter mainIdGetter, + Setter> setter) { + + return source + .buffer(200) + .concatMap(buffer -> { + Map mapping = buffer + .stream() + .collect(Collectors.toMap(idMapper, Function.identity(), (a, b) -> b)); + return fetcher + .apply(mapping.keySet()) + .collect(Collectors.groupingBy(mainIdGetter)) + .flatMapIterable(Map::entrySet) + .doOnNext(e -> { + T main = mapping.get(e.getKey()); + if (main != null) { + setter.accept(main, e.getValue()); + } + }) + .thenMany(Flux.fromIterable(buffer)); + }); + } + + /** + * 转换分页结果中的数据为另外一种数据 + * + * @param source 原始分页数据 + * @param transfer 转换器 + * @param + * @param + * @return 转换后的分页数据 + */ + @SuppressWarnings("all") + static Mono> transformPageResult(Mono> source, + Function, Mono>> transfer) { + return source.flatMap(result -> { + if (result.getTotal() > 0) { + return transfer + .apply(result.getData()) + .map(newDataList -> { + PagerResult pagerResult = PagerResult.of(result.getTotal(), newDataList); + pagerResult.setPageIndex(result.getPageIndex()); + pagerResult.setPageSize(result.getPageSize()); + return pagerResult; + }); + } + //empty + return Mono.just((PagerResult) result); + }); + } + + /** + * 指定ReactiveQuery和QueryParamEntity,执行查询并封装为分页查询结果. + * + * @param param QueryParamEntity + * @param query ReactiveQuery + * @param T + * @return PagerResult + */ + static Mono> queryPager(QueryParamEntity param, + Supplier> query) { + + return queryPager(param, query, Function.identity()); + } + + /** + * 指定ReactiveQuery和QueryParamEntity,执行查询并封装为分页查询结果. + * + * @param param QueryParamEntity + * @param query ReactiveQuery + * @param mapper 转换结果类型 + * @param T + * @return PagerResult + */ + static Mono> queryPager(QueryParamEntity param, + Supplier> query, + Function mapper) { + //如果查询参数指定了总数,表示不需要再进行count操作. + //建议前端在使用分页查询时,切换下一页时,将第一次查询到total结果传入查询参数,可以提升查询性能. + if (param.getTotal() != null) { + return query + .get() + .setParam(param.rePaging(param.getTotal())) + .fetch() + .map(mapper) + .collectList() + .map(list -> PagerResult.of(param.getTotal(), list, param)); + } + //并行分页,更快,所在页码无数据时,会返回空list. + if (param.isParallelPager()) { + return Mono + .zip( + query.get().setParam(param.clone()).count(), + query.get().setParam(param.clone()).fetch().map(mapper).collectList(), + (total, data) -> PagerResult.of(total, data, param) + ); + } + return query + .get() + .setParam(param.clone()) + .count() + .flatMap(total -> { + if (total == 0) { + return Mono.just(PagerResult.of(0, new ArrayList<>(), param)); + } + //查询前根据数据总数进行重新分页:要跳转的页码没有数据则跳转到最后一页 + QueryParamEntity rePagingQuery = param.clone().rePaging(total); + return query + .get() + .setParam(rePagingQuery) + .fetch() + .map(mapper) + .collectList() + .map(list -> PagerResult.of(total, list, rePagingQuery)); + }); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryHelperUtils.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryHelperUtils.java new file mode 100644 index 000000000..220e0bbe9 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/QueryHelperUtils.java @@ -0,0 +1,78 @@ +package org.hswebframework.web.crud.query; + +import io.netty.util.concurrent.FastThreadLocal; +import org.hswebframework.web.exception.BusinessException; + +public class QueryHelperUtils { + + static final FastThreadLocal SHARE = new FastThreadLocal() { + @Override + protected StringBuilder initialValue() throws Exception { + return new StringBuilder(); + } + }; + + public static String toSnake(String col) { + StringBuilder builder = SHARE.get(); + builder.setLength(0); + for (int i = 0, len = col.length(); i < len; i++) { + char c = col.charAt(i); + if (Character.isUpperCase(c)) { + if (i != 0) { + builder.append('_'); + } + builder.append(Character.toLowerCase(c)); + } else { + builder.append(c); + } + } + return builder.toString(); + } + + public static String toHump(String col) { + StringBuilder builder = SHARE.get(); + builder.setLength(0); + boolean hasUpper = false, hasLower = false; + for (int i = 0, len = col.length(); i < len; i++) { + char c = col.charAt(i); + if (Character.isLowerCase(c)) { + hasLower = true; + } + if (Character.isUpperCase(c)) { + hasUpper = true; + } + if (hasUpper && hasLower) { + return col; + } + if (c == '_') { + if (i == len - 1) { + builder.append('_'); + } else { + builder.append(Character.toUpperCase(col.charAt(++i))); + } + } else { + builder.append(Character.toLowerCase(c)); + } + } + return builder.toString(); + + } + + public static void assertLegalColumn(String col) { + if (!isLegalColumn(col)) { + throw new BusinessException.NoStackTrace("error.illegal_column_name", col); + } + } + + public static boolean isLegalColumn(String col) { + int len = col.length(); + for (int i = 0; i < len; i++) { + char c = col.charAt(i); + if (c == '_' || c == '$' || Character.isLetterOrDigit(c)) { + continue; + } + return false; + } + return true; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/ToHumpMap.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/ToHumpMap.java new file mode 100644 index 000000000..0f2dab926 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/query/ToHumpMap.java @@ -0,0 +1,18 @@ +package org.hswebframework.web.crud.query; + +import java.util.LinkedHashMap; + +public class ToHumpMap extends LinkedHashMap { + + @Override + public V put(String key, V value) { + V val = super.put(key, value); + + String humpKey = QueryHelperUtils.toHump(key); + if (!humpKey.equals(key)) { + super.put(humpKey, value); + } + return val; + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/CrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/CrudService.java new file mode 100644 index 000000000..97477f941 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/CrudService.java @@ -0,0 +1,122 @@ +package org.hswebframework.web.crud.service; + +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.ezorm.rdb.mapping.SyncDelete; +import org.hswebframework.ezorm.rdb.mapping.SyncQuery; +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.ezorm.rdb.mapping.SyncUpdate; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public interface CrudService { + SyncRepository getRepository(); + + default SyncQuery createQuery() { + return getRepository().createQuery(); + } + + default SyncUpdate createUpdate() { + return getRepository().createUpdate(); + } + + default SyncDelete createDelete() { + return getRepository().createDelete(); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default Optional findById(K id) { + return getRepository() + .findById(id); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default List findById(Collection id) { + if (CollectionUtils.isEmpty(id)) { + return Collections.emptyList(); + } + return this + .getRepository() + .findById(id); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default SaveResult save(Collection entityArr) { + return getRepository() + .save(entityArr); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default int insert(Collection entityArr) { + return getRepository() + .insertBatch(entityArr); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default void insert(E entityArr) { + getRepository() + .insert(entityArr); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default int updateById(K id, E entityArr) { + return getRepository() + .updateById(id, entityArr); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default SaveResult save(E entity) { + return getRepository() + .save(Collections.singletonList(entity)); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default SaveResult save(List entities) { + return getRepository() + .save(entities); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default int deleteById(Collection idArr) { + return getRepository().deleteById(idArr); + } + + @Transactional(transactionManager = TransactionManagers.jdbcTransactionManager) + default int deleteById(K idArr) { + return deleteById(Collections.singletonList(idArr)); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default List query(QueryParamEntity queryParam) { + return createQuery().setParam(queryParam).fetch(); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default PagerResult queryPager(QueryParamEntity param) { + + int count = param.getTotal() == null ? count(param) : param.getTotal(); + if (count == 0) { + return PagerResult.empty(); + } + param.rePaging(count); + + return PagerResult.of(count, query(param), param); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default int count(QueryParam param) { + return getRepository() + .createQuery() + .setParam(param) + .count(); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/EnableCacheReactiveCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/EnableCacheReactiveCrudService.java new file mode 100644 index 000000000..dbe2f7110 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/EnableCacheReactiveCrudService.java @@ -0,0 +1,147 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveDelete; +import org.hswebframework.ezorm.rdb.mapping.ReactiveUpdate; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.hswebframework.web.cache.ReactiveCache; +import org.hswebframework.web.crud.utils.TransactionUtils; +import org.reactivestreams.Publisher; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.reactive.TransactionSynchronization; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.annotation.Nonnull; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public interface EnableCacheReactiveCrudService extends ReactiveCrudService { + + ReactiveCache getCache(); + + String ALL_DATA_KEY = "@all"; + + default Mono findById(K id) { + return this.getCache().getMono("id:" + id, () -> ReactiveCrudService.super.findById(id)); + } + + @Override + default Mono findById(Mono publisher) { + return publisher.flatMap(this::findById); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono updateById(K id, E data) { + return updateById(id, Mono.just(data)); + } + + @Override + default Mono updateById(K id, Mono entityPublisher) { + return registerClearCache(Collections.singleton("id:" + id)) + .then(ReactiveCrudService.super.updateById(id, entityPublisher)); + } + + @Override + default Mono save(Collection collection) { + return registerClearCache() + .then(ReactiveCrudService.super.save(collection)); + } + + @Override + default Mono save(E data) { + return registerClearCache() + .then(ReactiveCrudService.super.save(data)); + } + + @Override + default Mono save(Publisher entityPublisher) { + return registerClearCache() + .then(ReactiveCrudService.super.save(entityPublisher)); + } + + @Override + default Mono insert(E data) { + return registerClearCache() + .then(ReactiveCrudService.super.insert(data)); + } + + @Override + default Mono insert(Publisher entityPublisher) { + return registerClearCache() + .then(ReactiveCrudService.super.insert(entityPublisher)); + } + + @Override + default Mono insertBatch(Publisher> entityPublisher) { + return registerClearCache() + .then(ReactiveCrudService.super.insertBatch(entityPublisher)); + } + + default Mono registerClearCache() { + return TransactionUtils.registerSynchronization(new TransactionSynchronization() { + @Override + @Nonnull + public Mono afterCommit() { + return getCache().clear(); + } + }, TransactionSynchronization::afterCommit); + } + + default Mono registerClearCache(Collection keys) { + return TransactionUtils.registerSynchronization(new TransactionSynchronization() { + @Override + @Nonnull + public Mono afterCommit() { + Set set = new HashSet<>(keys); + //同步删除全量数据的缓存 + set.add(ALL_DATA_KEY); + return getCache().evictAll(set); + } + }, TransactionSynchronization::afterCommit); + } + + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono deleteById(K id) { + return deleteById(Mono.just(id)); + } + + @Override + default Mono deleteById(Publisher idPublisher) { + Flux cache = Flux.from(idPublisher).cache(); + return cache + .map(id -> "id:" + id) + .collectList() + .flatMap(this::registerClearCache) + .then(ReactiveCrudService.super.deleteById(cache)); + } + + @Override + default ReactiveUpdate createUpdate() { + return ReactiveCrudService.super + .createUpdate() + .onExecute((update, s) -> s.flatMap(i -> { + if (i > 0) { + return getCache().clear().thenReturn(i); + } + return Mono.just(i); + })); + } + + @Override + default ReactiveDelete createDelete() { + return ReactiveCrudService.super + .createDelete() + .onExecute((update, s) -> s.flatMap(i -> { + if (i > 0) { + return getCache().clear().thenReturn(i); + } + return Mono.just(i); + })); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericCrudService.java new file mode 100644 index 000000000..5b519de3a --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericCrudService.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.springframework.beans.factory.annotation.Autowired; + +public abstract class GenericCrudService implements CrudService { + + @Autowired + private SyncRepository repository; + + @Override + public SyncRepository getRepository() { + return repository; + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudService.java new file mode 100644 index 000000000..b4c45b950 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudService.java @@ -0,0 +1,45 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.cache.ReactiveCache; +import org.hswebframework.web.cache.ReactiveCacheManager; +import org.hswebframework.web.cache.supports.UnSupportedReactiveCache; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.core.publisher.Flux; + +public abstract class GenericReactiveCacheSupportCrudService implements EnableCacheReactiveCrudService { + + @Autowired + private ReactiveRepository repository; + + @Override + public ReactiveRepository getRepository() { + return repository; + } + + @Autowired(required = false) + private ReactiveCacheManager cacheManager; + + protected ReactiveCache cache; + + @Override + public ReactiveCache getCache() { + if (cache != null) { + return cache; + } + if (cacheManager == null) { + return cache = UnSupportedReactiveCache.getInstance(); + } + + return cache = cacheManager.getCache(getCacheName()); + } + + public String getCacheName() { + return this.getClass().getSimpleName(); + } + + + public Flux getCacheAll() { + return getCache().getFlux(ALL_DATA_KEY, () -> EnableCacheReactiveCrudService.super.createQuery().fetch()); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveCrudService.java new file mode 100644 index 000000000..7a4f980c4 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveCrudService.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.springframework.beans.factory.annotation.Autowired; + +public abstract class GenericReactiveCrudService implements ReactiveCrudService { + + @Autowired + private ReactiveRepository repository; + + @Override + public ReactiveRepository getRepository() { + return repository; + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveTreeSupportCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveTreeSupportCrudService.java new file mode 100644 index 000000000..684d511c0 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericReactiveTreeSupportCrudService.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; +import org.springframework.beans.factory.annotation.Autowired; + +public abstract class GenericReactiveTreeSupportCrudService, K> implements ReactiveTreeSortEntityService { + + private static final int SAVE_BUFFER_SIZE = Integer.getInteger("tree.save.buffer.size", 200); + + @Autowired + private ReactiveRepository repository; + + @Override + public ReactiveRepository getRepository() { + return repository; + } + + @Override + public int getBufferSize() { + return SAVE_BUFFER_SIZE; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericTreeSupportCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericTreeSupportCrudService.java new file mode 100644 index 000000000..084588869 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/GenericTreeSupportCrudService.java @@ -0,0 +1,17 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; +import org.springframework.beans.factory.annotation.Autowired; + +public abstract class GenericTreeSupportCrudService,K> implements TreeSortEntityService { + + @Autowired + private SyncRepository repository; + + @Override + public SyncRepository getRepository() { + return repository; + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java new file mode 100644 index 000000000..cec6e0ce8 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java @@ -0,0 +1,262 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveDelete; +import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.mapping.ReactiveUpdate; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.reactivestreams.Publisher; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.function.Function; + +/** + * 响应式增删改查通用服务类,增删改查,实现此接口. + * 利用{@link ReactiveRepository}来实现. + * + * @param 实体类类型 + * @param 主键类型 + * @see ReactiveRepository + * @see GenericReactiveCrudService + * @see GenericReactiveTreeSupportCrudService + * @see EnableCacheReactiveCrudService + * @see org.hswebframework.web.crud.query.QueryHelper + * @since 4.0 + */ +public interface ReactiveCrudService { + + /** + * @return 响应式实体操作仓库 + */ + ReactiveRepository getRepository(); + + /** + * 创建一个DSL的动态查询接口,可使用DSL方式进行链式调用来构造动态查询条件.例如: + *
{@code
+     * Flux flux = service
+     *     .createQuery()
+     *     .where(MyEntity::getName,name)
+     *     .in(MyEntity::getState,state1,state2)
+     *     .fetch()
+     * }
+     * 
+ * + * @return 动态查询接口 + */ + default ReactiveQuery createQuery() { + return getRepository().createQuery(); + } + + /** + * 创建一个DSL动态更新接口,可使用DSL方式进行链式调用来构造动态更新条件.例如: + *
{@code
+     * Mono result = service
+     *     .createUpdate()
+     *     .set(entity::getState)
+     *     .where(MyEntity::getName,name)
+     *     .in(MyEntity::getState,state1,state2)
+     *     .execute()
+     *     }
+     * 
+ * + * @return 动态更新接口 + */ + default ReactiveUpdate createUpdate() { + return getRepository().createUpdate(); + } + + /** + * 创建一个DSL动态删除接口,可使用DSL方式进行链式调用来构造动态删除条件.例如: + *
{@code
+     * Mono result = service
+     *     .createDelete()
+     *     .where(MyEntity::getName,name)
+     *     .in(MyEntity::getState,state1,state2)
+     *     .execute()
+     * }
+     * 
+ * + * @return 动态更新接口 + */ + default ReactiveDelete createDelete() { + return getRepository().createDelete(); + } + + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono findById(K id) { + return getRepository() + .findById(id); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux findById(Collection publisher) { + return getRepository() + .findById(publisher); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono findById(Mono publisher) { + return getRepository() + .findById(publisher); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux findById(Flux publisher) { + return getRepository() + .findById(publisher); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono save(Publisher entityPublisher) { + return getRepository() + .save(entityPublisher); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono save(E data) { + return getRepository() + .save(data); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono save(Collection collection) { + return getRepository() + .save(collection); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono updateById(K id, Mono entityPublisher) { + return getRepository() + .updateById(id, entityPublisher); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono updateById(K id, E data) { + return getRepository() + .updateById(id, Mono.just(data)); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono insertBatch(Publisher> entityPublisher) { + return getRepository() + .insertBatch(entityPublisher); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono insert(Publisher entityPublisher) { + return getRepository() + .insert(entityPublisher); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono insert(E data) { + return getRepository() + .insert(Mono.just(data)); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono deleteById(Publisher idPublisher) { + return getRepository() + .deleteById(idPublisher); + } + + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono deleteById(K id) { + return getRepository() + .deleteById(Mono.just(id)); + } + + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux query(Mono queryParamMono) { + return queryParamMono + .flatMapMany(this::query); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux query(QueryParamEntity param) { + return getRepository() + .createQuery() + .setParam(param) + .fetch(); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono> queryPager(QueryParamEntity queryParamMono) { + return queryPager(queryParamMono, Function.identity()); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono> queryPager(QueryParamEntity query, Function mapper) { + //如果查询参数指定了总数,表示不需要再进行count操作. + //建议前端在使用分页查询时,切换下一页时,将第一次查询到total结果传入查询参数,可以提升查询性能. + if (query.getTotal() != null) { + return getRepository() + .createQuery() + .setParam(query.rePaging(query.getTotal())) + .fetch() + .map(mapper) + .collectList() + .map(list -> PagerResult.of(query.getTotal(), list, query)); + } + //并行分页,更快,所在页码无数据时,会返回空list. + if (query.isParallelPager()) { + return Mono + .zip( + createQuery().setParam(query.clone()).count(), + createQuery().setParam(query.clone()).fetch().map(mapper).collectList(), + (total, data) -> PagerResult.of(total, data, query) + ); + } + return getRepository() + .createQuery() + .setParam(query.clone()) + .count() + .flatMap(total -> { + if (total == 0) { + return Mono.just(PagerResult.of(0, new ArrayList<>(), query)); + } + //查询前根据数据总数进行重新分页:要跳转的页码没有数据则跳转到最后一页 + QueryParamEntity rePagingQuery = query.clone().rePaging(total); + return query(rePagingQuery) + .map(mapper) + .collectList() + .map(list -> PagerResult.of(total, list, rePagingQuery)); + }); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono> queryPager(Mono queryParamMono, Function mapper) { + return queryParamMono + .cast(QueryParamEntity.class) + .flatMap(param -> queryPager(param, mapper)); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono> queryPager(Mono queryParamMono) { + return queryPager(queryParamMono, Function.identity()); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono count(QueryParamEntity queryParam) { + return getRepository() + .createQuery() + .setParam(queryParam) + .count(); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono count(Mono queryParamMono) { + return queryParamMono.flatMap(this::count); + } + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java new file mode 100644 index 000000000..97bd0187a --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -0,0 +1,400 @@ +package org.hswebframework.web.crud.service; + +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.ezorm.core.MethodReferenceColumn; +import org.hswebframework.ezorm.core.StaticMethodReferenceColumn; +import org.hswebframework.ezorm.rdb.mapping.ReactiveDelete; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.ezorm.rdb.operator.dml.Terms; +import org.hswebframework.utils.RandomUtil; +import org.hswebframework.web.api.crud.entity.*; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.id.IDGenerator; +import org.hswebframework.web.validator.CreateGroup; +import org.reactivestreams.Publisher; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.math.MathFlux; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * 树形结构的通用增删改查服务 + * + * @param TreeSortSupportEntity + * @param ID + * @see GenericReactiveTreeSupportCrudService + */ +public interface ReactiveTreeSortEntityService, K> + extends ReactiveCrudService { + + /** + * 动态查询并将查询结构转为树形结构 + * + * @param paramEntity 查询参数 + * @return 树形结构 + */ + default Mono> queryResultToTree(Mono paramEntity) { + return paramEntity.flatMap(this::queryResultToTree); + } + + /** + * 动态查询并将查询结构转为树形结构 + * + * @param paramEntity 查询参数 + * @return 树形结构 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono> queryResultToTree(QueryParamEntity paramEntity) { + return query(paramEntity) + .collectList() + .map(list -> TreeSupportEntity.list2tree(list, + this::setChildren, + this::createRootNodePredicate)); + } + + /** + * 动态查询并将查询结构转为树形结构,包含所有子节点 + * + * @param paramEntity 查询参数 + * @return 树形结构 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono> queryIncludeChildrenTree(QueryParamEntity paramEntity) { + return queryIncludeChildren(paramEntity) + .collectList() + .map(list -> TreeSupportEntity.list2tree(list, + this::setChildren, + this::createRootNodePredicate)); + } + + /** + * 查询指定ID的实体以及对应的全部子节点 + * + * @param idList ID集合 + * @return 包含子节点的所有节点 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux queryIncludeChildren(Collection idList) { + return queryIncludeChildren(findById(idList)); + } + + /** + * 根据实体流查询全部子节点(包含原节点) + * + * @param entities 实体流 + * @return 包含子节点的所有节点 + * @since 4.0.18 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux queryIncludeChildren(Flux entities) { + Set duplicateCheck = new HashSet<>(); + return entities + .concatMap(e -> !StringUtils.hasText(e.getPath()) || !duplicateCheck.add(e.getPath()) + ? Mono.just(e) + : createQuery() + .where() + //使用path快速查询 + .like$("path", e.getPath()) + .fetch(), + Integer.MAX_VALUE) + .distinct(TreeSupportEntity::getId); + } + + /** + * 查询指定ID的实体以及对应的全部父节点 + * + * @param idList ID集合 + * @return 包含父节点的所有节点 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux queryIncludeParent(Collection idList) { + return queryIncludeParent(findById(idList)); + } + + /** + * 根据实体流查询全部父节点(包含原节点) + * + * @param entities 实体流 + * @return 包含父节点的所有节点 + * @since 4.0.18 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux queryIncludeParent(Flux entities) { + Set duplicateCheck = new HashSet<>(); + + return entities + .concatMap(e -> !StringUtils.hasText(e.getPath()) || !duplicateCheck.add(e.getPath()) + ? Mono.just(e) + : createQuery() + .where() + //where ? like path and path !='' and path not null + .accept(Terms.Like.reversal("path", e.getPath(), false, true)) + .notEmpty("path") + .notNull("path") + .fetch(), Integer.MAX_VALUE) + .distinct(TreeSupportEntity::getId); + } + + /** + * 动态查询并将查询结构转为树形结构 + * + * @param queryParam 查询参数 + * @return 树形结构 + */ + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + default Flux queryIncludeChildren(QueryParamEntity queryParam) { + Set duplicateCheck = new HashSet<>(); + + return query(queryParam) + .concatMap(e -> !StringUtils.hasText(e.getPath()) || !duplicateCheck.add(e.getPath()) + ? Mono.just(e) + : createQuery() + .as(q -> { + if (CollectionUtils.isNotEmpty(queryParam.getIncludes())) { + q.select(queryParam.getIncludes().toArray(new String[0])); + } + if (CollectionUtils.isNotEmpty(queryParam.getExcludes())) { + q.selectExcludes(queryParam.getExcludes().toArray(new String[0])); + } + return q; + }) + .where() + .like$("path", e.getPath()) + .fetch() + , Integer.MAX_VALUE) + .distinct(TreeSupportEntity::getId); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono insert(Publisher entityPublisher) { + return insertBatch(Flux.from(entityPublisher).collectList()); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono insert(E data) { + return this.insertBatch(Flux.just(Collections.singletonList(data))); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono insertBatch(Publisher> entityPublisher) { + return this + .getRepository() + .insertBatch(new TreeSortServiceHelper<>(this) + .prepare(Flux.from(entityPublisher) + .flatMapIterable(Function.identity())) + // .doOnNext(e -> e.tryValidate(CreateGroup.class)) + .buffer(getBufferSize())); + } + + default int getBufferSize() { + return 200; + } + + @Deprecated + default Mono applyTreeProperty(E ele) { + if (StringUtils.hasText(ele.getPath()) || + ObjectUtils.isEmpty(ele.getParentId())) { + return Mono.just(ele); + } + + return this.checkCyclicDependency(ele.getId(), ele) + .then(this.findById(ele.getParentId()) + .doOnNext(parent -> ele.setPath(parent.getPath() + "-" + RandomUtil.randomChar(4)))) + .thenReturn(ele); + } + + @Deprecated + //校验是否有循环依赖,修改父节点为自己的子节点? + default Mono checkCyclicDependency(K id, E ele) { + if (ObjectUtils.isEmpty(id)) { + return Mono.empty(); + } + return this + .queryIncludeChildren(Collections.singletonList(id)) + .doOnNext(e -> { + if (Objects.equals(ele.getParentId(), e.getId())) { + throw new ValidationException.NoStackTrace("parentId", "error.tree_entity_cyclic_dependency"); + } + }) + .then(Mono.just(ele)); + } + + @Deprecated + default Mono> checkParentId(Collection source) { + + Set idSet = source + .stream() + .map(TreeSupportEntity::getId) + .filter(e -> !ObjectUtils.isEmpty(e)) + .collect(Collectors.toSet()); + + if (idSet.isEmpty()) { + return Mono.just(source); + } + + Set readyToCheck = source + .stream() + .map(TreeSupportEntity::getParentId) + .filter(e -> !ObjectUtils.isEmpty(e) && !idSet.contains(e)) + .collect(Collectors.toSet()); + + if (readyToCheck.isEmpty()) { + return Mono.just(source); + } + + return this + .createQuery() + .select("id") + .in("id", readyToCheck) + .fetch() + .doOnNext(e -> readyToCheck.remove(e.getId())) + .then(Mono.fromSupplier(() -> { + if (!readyToCheck.isEmpty()) { + throw new ValidationException( + "error.tree_entity_parent_id_not_exist", + Collections.singletonList( + new ValidationException.Detail( + "parentId", + "error.tree_entity_parent_id_not_exist", + readyToCheck)) + ); + } + return source; + })); + + } + + @Deprecated + //重构子节点的path + default void refactorChildPath(K id, Function> childGetter, String path, Consumer pathAccepter) { + + Collection children = childGetter.apply(id); + if (CollectionUtils.isEmpty(children)) { + return; + } + for (E child : children) { + if (ObjectUtils.isEmpty(path)) { + child.setPath(RandomUtil.randomChar(4)); + } else { + child.setPath(path + "-" + RandomUtil.randomChar(4)); + } + pathAccepter.accept(child); + this.refactorChildPath(child.getId(), childGetter, child.getPath(), pathAccepter); + } + + } + + @Override + @Transactional(rollbackFor = Throwable.class, + transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono save(Publisher entityPublisher) { + return new TreeSortServiceHelper<>(this) + .prepare(Flux.from(entityPublisher)) +// .doOnNext(e -> e.tryValidate(CreateGroup.class)) + .buffer(getBufferSize()) + .concatMap(this.getRepository()::save) + .reduce(SaveResult::merge); + + } + + @Deprecated + default Flux tryRefactorPath(Flux stream) { + return new TreeSortServiceHelper<>(this).prepare(stream); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono save(Collection collection) { + return save(Flux.fromIterable(collection)); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono save(E data) { + return save(Flux.just(data)); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono updateById(K id, Mono entityPublisher) { + return this + .findById(id) + .map(e -> this + .save(entityPublisher.doOnNext(data -> data.setId(id))) + .map(SaveResult::getTotal)) + .defaultIfEmpty(Mono.just(0)) + .flatMap(Function.identity()); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono deleteById(K id) { + return this.deleteById(Flux.just(id)); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono deleteById(Publisher idPublisher) { + return this + .findById(Flux.from(idPublisher)) + .concatMap(e -> StringUtils.hasText(e.getPath()) + ? getRepository().createDelete().where().like$(e::getPath).execute() + : getRepository().deleteById(e.getId()), Integer.MAX_VALUE) + .as(MathFlux::sumInt); + } + + IDGenerator getIDGenerator(); + + void setChildren(E entity, List children); + + default List getChildren(E entity) { + return entity.getChildren(); + } + + default Predicate createRootNodePredicate(TreeSupportEntity.TreeHelper helper) { + return node -> { + if (isRootNode(node)) { + return true; + } + //有父节点,但是父节点不存在 + if (!ObjectUtils.isEmpty(node.getParentId())) { + return helper.getNode(node.getParentId()) == null; + } + return false; + }; + } + + default boolean isRootNode(E entity) { + return ObjectUtils.isEmpty(entity.getParentId()) || "-1".equals(String.valueOf(entity.getParentId())); + } + + @Override + @SuppressWarnings("all") + default ReactiveDelete createDelete() { + return ReactiveCrudService.super + .createDelete() + .onExecute((delete, executor) -> this + .queryIncludeChildren(delete.toQueryParam(QueryParamEntity::new) + .includes("id", "path", "parentId")) + .map(TreeSupportEntity::getId) + .buffer(200) + .concatMap(list -> getRepository() + .createDelete() + .where() + .in("id", list) + .execute(), Integer.MAX_VALUE) + //.concatWith(executor) + .reduce(0, Math::addExact)); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java new file mode 100644 index 000000000..4f0507228 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java @@ -0,0 +1,171 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.utils.RandomUtil; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; +import org.hswebframework.web.api.crud.entity.TreeSupportEntity; +import org.hswebframework.web.id.IDGenerator; +import org.reactivestreams.Publisher; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * @param TreeSortSupportEntity + * @param ID + * @see GenericReactiveTreeSupportCrudService + */ +public interface TreeSortEntityService, K> + extends CrudService { + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default List queryResultToTree(QueryParamEntity paramEntity) { + return TreeSupportEntity + .list2tree(query(paramEntity), + this::setChildren, + this::createRootNodePredicate); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default List queryIncludeChildrenTree(QueryParamEntity paramEntity) { + + return TreeSupportEntity + .list2tree(queryIncludeChildren(paramEntity), + this::setChildren, + this::createRootNodePredicate); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default List queryIncludeChildren(Collection idList) { + return findById(idList) + .stream() + .flatMap(e -> createQuery() + .where() + .like$("path", e.getPath()) + .fetch() + .stream()) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + default List queryIncludeChildren(QueryParamEntity queryParam) { + return query(queryParam) + .stream() + .flatMap(e -> createQuery() + .where() + .like$("path", e.getPath()) + .fetch() + .stream()) + .collect(Collectors.toList()); + } + + @Override + default void insert(E entityPublisher) { + insert(Collections.singletonList(entityPublisher)); + } + + @Override + default int insert(Collection entityPublisher) { + return this + .getRepository() + .insertBatch(entityPublisher + .stream() + .flatMap(this::applyTreeProperty) + .flatMap(e -> TreeSupportEntity + .expandTree2List(e, getIDGenerator()) + .stream()) + .collect(Collectors.toList()) + ); + } + + default Stream applyTreeProperty(E ele) { + if (StringUtils.hasText(ele.getPath()) || + StringUtils.isEmpty(ele.getParentId())) { + return Stream.of(ele); + } + + this.checkCyclicDependency(ele.getId(), ele); + this.findById(ele.getParentId()) + .ifPresent(parent -> ele.setPath(parent.getPath() + "-" + RandomUtil.randomChar(4))); + return Stream.of(ele); + } + + //校验是否有循环依赖,修改父节点为自己的子节点? + default void checkCyclicDependency(K id, E ele) { + if (StringUtils.isEmpty(id)) { + return; + } + for (E e : this.queryIncludeChildren(Collections.singletonList(id))) { + if (Objects.equals(ele.getParentId(), e.getId())) { + throw new IllegalArgumentException("不能修改父节点为自己或者自己的子节点"); + } + } + + } + + @Override + default SaveResult save(List entities) { + return this.getRepository() + .save(entities + .stream() + .flatMap(this::applyTreeProperty) + //把树结构平铺 + .flatMap(e -> TreeSupportEntity + .expandTree2List(e, getIDGenerator()) + .stream()) + .collect(Collectors.toList()) + ); + } + + @Override + default int updateById(K id, E entity) { + entity.setId(id); + return this.save(entity).getTotal(); + } + + @Override + default int deleteById(Collection idPublisher) { + List dataList = findById(idPublisher); + return dataList + .stream() + .map(e -> createDelete() + .where() + .like$(e::getPath) + .execute()) + .mapToInt(Integer::intValue) + .sum(); + } + + IDGenerator getIDGenerator(); + + void setChildren(E entity, List children); + + default List getChildren(E entity) { + return entity.getChildren(); + } + + default Predicate createRootNodePredicate(TreeSupportEntity.TreeHelper helper) { + return node -> { + if (isRootNode(node)) { + return true; + } + //有父节点,但是父节点不存在 + if (!StringUtils.isEmpty(node.getParentId())) { + return helper.getNode(node.getParentId()) == null; + } + return false; + }; + } + + default boolean isRootNode(E entity) { + return StringUtils.isEmpty(entity.getParentId()) || "-1".equals(String.valueOf(entity.getParentId())); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java new file mode 100644 index 000000000..0da90387b --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -0,0 +1,274 @@ +package org.hswebframework.web.crud.service; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; +import org.hswebframework.utils.RandomUtil; +import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; +import org.hswebframework.web.api.crud.entity.TreeSupportEntity; +import org.hswebframework.web.exception.ValidationException; +import org.springframework.util.ObjectUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class TreeSortServiceHelper, PK> { + + //包含子节点的数据 + private Map allData; + + private Map oldData; + + private Map thisTime; + + private Map readyToSave; + + private final Map> childrenMapping = new LinkedHashMap<>(); + + private final ReactiveTreeSortEntityService service; + + TreeSortServiceHelper(ReactiveTreeSortEntityService service) { + this.service = service; + } + + Flux prepare(Flux source) { + Flux cache = source + .flatMapIterable(e -> TreeSupportEntity.expandTree2List(e, service.getIDGenerator())) + .collectList() + .flatMapIterable(list -> { + + Map map = list + .stream() + .filter(e -> e.getId() != null) + .collect(Collectors.toMap( + TreeSupportEntity::getId, + Function.identity(), + (a, b) -> a + )); + //重新组装树结构 + TreeSupportEntity.list2tree(list, + service::setChildren, + (Predicate) e -> service.isRootNode(e) || map.get(e.getParentId()) == null); + + return list; + }) + .cache(); + + return init(cache) + .then(Mono.defer(this::checkParentId)) + .then(Mono.fromRunnable(this::checkCyclicDependency)) + .then(Mono.fromRunnable(this::refactorPath)) + .thenMany(Flux.defer(() -> Flux.fromIterable(readyToSave.values()))) + .doOnNext(this::refactor); + } + + private Mono init(Flux source) { + oldData = new LinkedHashMap<>(); + thisTime = new LinkedHashMap<>(); + allData = new LinkedHashMap<>(); + readyToSave = new LinkedHashMap<>(); + + Mono> allDataFetcher = + source + .mapNotNull(e -> { + + if (e.getId() != null) { + thisTime.put(e.getId(), e); + } + + return e.getId(); + }) + .collect(Collectors.toSet()) + .flatMap(list -> service + .queryIncludeChildren(list) + .collectMap(TreeSupportEntity::getId, Function.identity())); + return allDataFetcher + .doOnNext(includeChildren -> { + //旧的数据 + for (E value : thisTime.values()) { + E old = includeChildren.get(value.getId()); + if (null != old) { + this.oldData.put(value.getId(), old); + } + } + + readyToSave.putAll(thisTime); + + allData.putAll(includeChildren); + allData.putAll(this.thisTime); + initChildren(); + + }) + .then(); + } + + private void initChildren() { + childrenMapping.clear(); + + for (E value : allData.values()) { + if (service.isRootNode(value) || value.getId() == null) { + continue; + } + childrenMapping + .computeIfAbsent(value.getParentId(), ignore -> new LinkedHashMap<>()) + .put(value.getId(), value); + } + } + + private void checkCyclicDependency() { + for (E value : readyToSave.values()) { + checkCyclicDependency(value, new LinkedHashSet<>()); + } + } + + private void checkCyclicDependency(E val, Set container) { + if (!container.add(val.getId())) { + throw new ValidationException("parentId", "error.tree_entity_cyclic_dependency"); + } + Map children = childrenMapping.get(val.getId()); + if (MapUtils.isNotEmpty(children)) { + for (Map.Entry entry : children.entrySet()) { + checkCyclicDependency(entry.getValue(), container); + } + } + } + + private Mono checkParentId() { + + if (allData.isEmpty()) { + return Mono.empty(); + } + + Set readyToCheck = thisTime + .values() + .stream() + .map(TreeSupportEntity::getParentId) + .filter(e -> !ObjectUtils.isEmpty(e) && !allData.containsKey(e)) + .collect(Collectors.toSet()); + + if (readyToCheck.isEmpty()) { + return Mono.empty(); + } + return service + .createQuery() + .in("id", readyToCheck) + .fetch() + .doOnNext(e -> { + allData.put(e.getId(), e); + readyToCheck.remove(e.getId()); + }) + .then(Mono.fromRunnable(() -> { + if (!readyToCheck.isEmpty()) { + throw new ValidationException( + "error.tree_entity_parent_id_not_exist", + Collections.singletonList( + new ValidationException.Detail( + "parentId", + "error.tree_entity_parent_id_not_exist", + readyToCheck)) + ); + } + initChildren(); + })); + } + + private void refactorPath() { + Function> childGetter + = id -> childrenMapping + .getOrDefault(id, Collections.emptyMap()) + .values(); + + for (E data : thisTime.values()) { + E old = data.getId() == null ? null : oldData.get(data.getId()); + PK parentId = old != null ? old.getParentId() : data.getParentId(); + E oldParent = parentId == null ? null : allData.get(parentId); + //编辑节点 + if (old != null) { + PK newParentId = data.getParentId(); + //父节点发生变化,更新所有子节点path + if (newParentId != null && !newParentId.equals(parentId)) { + Consumer childConsumer = child -> { + //更新了父节点,但是同时也传入的对应的子节点 + E readyToUpdate = thisTime.get(child.getId()); + if (null != readyToUpdate) { + readyToUpdate.setPath(child.getPath()); + } + }; + + //变更到了顶级节点 + if (service.isRootNode(data)) { + data.setPath(RandomUtil.randomChar(4)); + this.refactorChildPath(old.getId(), data.getPath(), childConsumer); + //重新保存所有子节点 + putChildToReadyToSave(childGetter, old); + + } else { + E newParent = allData.get(newParentId); + if (null != newParent) { + data.setPath(newParent.getPath() + "-" + RandomUtil.randomChar(4)); + this.refactorChildPath(data.getId(), data.getPath(), childConsumer); + //重新保存所有子节点 + putChildToReadyToSave(childGetter, data); + } + } + } else { + if (oldParent != null) { + if (old.getPath().startsWith(oldParent.getPath())) { + data.setPath(old.getPath()); + } else { + data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + } + } else { + data.setPath(old.getPath()); + } + } + } + + //新增节点 + else if (parentId != null) { + if (oldParent != null) { + data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + } + } + } + + } + + private void putChildToReadyToSave(Function> childGetter, E data) { + childGetter + .apply(data.getId()) + .forEach(e -> { + readyToSave.put(e.getId(), e); + putChildToReadyToSave(childGetter, e); + }); + } + + private void refactor(E e) { + if (e.getPath() != null) { + e.setLevel(e.getPath().split("-").length); + } + } + + //重构子节点的path + private void refactorChildPath(PK id, String path, Consumer pathAccepter) { + + Collection children = childrenMapping.getOrDefault(id, Collections.emptyMap()).values(); + if (CollectionUtils.isEmpty(children)) { + return; + } + for (E child : children) { + if (ObjectUtils.isEmpty(path)) { + child.setPath(RandomUtil.randomChar(4)); + } else { + child.setPath(path + "-" + RandomUtil.randomChar(4)); + } + pathAccepter.accept(child); + this.refactorChildPath(child.getId(), child.getPath(), pathAccepter); + } + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultJdbcExecutor.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultJdbcExecutor.java new file mode 100644 index 000000000..abfb9eb58 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultJdbcExecutor.java @@ -0,0 +1,83 @@ +package org.hswebframework.web.crud.sql; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.jdbc.JdbcSyncSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.hswebframework.web.datasource.DataSourceHolder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * @author zhouhao + */ + +@Slf4j +public class DefaultJdbcExecutor extends JdbcSyncSqlExecutor { + + @Autowired + private DataSource dataSource; + + protected String getDatasourceId() { + return DataSourceHolder.switcher().datasource().current().orElse("default"); + } + + @Override + public Connection getConnection(SqlRequest sqlRequest) { + + DataSource dataSource = DataSourceHolder.isDynamicDataSourceReady() ? + DataSourceHolder.currentDataSource().getNative() : + this.dataSource; + Connection connection = DataSourceUtils.getConnection(dataSource); + boolean isConnectionTransactional = DataSourceUtils.isConnectionTransactional(connection, dataSource); + if (log.isDebugEnabled()) { + log.debug("DataSource ({}) JDBC Connection [{}] will {}be managed by Spring", getDatasourceId(), connection, (isConnectionTransactional ? "" : "not ")); + } + return connection; + } + + @Override + public void releaseConnection(Connection connection, SqlRequest sqlRequest) { + if (log.isDebugEnabled()) { + log.debug("Releasing DataSource ({}) JDBC Connection [{}]", getDatasourceId(), connection); + } + try { + DataSource dataSource = DataSourceHolder.isDynamicDataSourceReady() ? + DataSourceHolder.currentDataSource().getNative() : + this.dataSource; + DataSourceUtils.doReleaseConnection(connection, dataSource); + } catch (SQLException e) { + log.error(e.getMessage(), e); + try { + connection.close(); + } catch (Exception e2) { + log.error(e2.getMessage(), e2); + } + } + } + + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED, transactionManager = TransactionManagers.jdbcTransactionManager) + public void execute(SqlRequest request) { + super.execute(request); + } + + @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.jdbcTransactionManager) + @Override + public int update(SqlRequest request) { + return super.update(request); + } + + @Override + @Transactional(readOnly = true, transactionManager = TransactionManagers.jdbcTransactionManager) + public R select(SqlRequest request, ResultWrapper wrapper) { + return super.select(request, wrapper); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultJdbcReactiveExecutor.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultJdbcReactiveExecutor.java new file mode 100644 index 000000000..7bff2e938 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultJdbcReactiveExecutor.java @@ -0,0 +1,89 @@ +package org.hswebframework.web.crud.sql; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.jdbc.JdbcReactiveSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.hswebframework.web.datasource.DataSourceHolder; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import javax.sql.DataSource; +import java.sql.Connection; + +@Slf4j +public class DefaultJdbcReactiveExecutor extends JdbcReactiveSqlExecutor { + @Autowired + private DataSource dataSource; + + protected String getDatasourceId() { + return DataSourceHolder.switcher().datasource().current().orElse("default"); + } + + private Tuple2 getDataSourceAndConnection() { + DataSource dataSource = DataSourceHolder.isDynamicDataSourceReady() ? + DataSourceHolder.currentDataSource().getNative() : + this.dataSource; + Connection connection = DataSourceUtils.getConnection(dataSource); + boolean isConnectionTransactional = DataSourceUtils.isConnectionTransactional(connection, dataSource); + if (log.isDebugEnabled()) { + log.debug("DataSource ({}) JDBC Connection [{}] will {}be managed by Spring", getDatasourceId(), connection, (isConnectionTransactional ? "" : "not ")); + } + return Tuples.of(dataSource, connection); + } + + @Override + public Mono getConnection() { + return Mono + .using( + this::getDataSourceAndConnection + , + tp2 -> Mono.just(tp2.getT2()), + tp2 -> DataSourceUtils.releaseConnection(tp2.getT2(), tp2.getT1()), + false + ); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager,readOnly = true) + public Flux select(String sql, ResultWrapper wrapper) { + return super.select(sql,wrapper); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager,rollbackFor = Throwable.class) + public Mono update(Publisher request) { + return super.update(request); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager,rollbackFor = Throwable.class) + public Mono update(String sql, Object... args) { + return super.update(sql,args); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager,rollbackFor = Throwable.class) + public Mono update(SqlRequest request) { + return super.update(request); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager,rollbackFor = Throwable.class) + public Mono execute(Publisher request) { + return super.execute(request); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager,rollbackFor = Throwable.class) + public Mono execute(SqlRequest request) { + return super.execute(request); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultR2dbcExecutor.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultR2dbcExecutor.java new file mode 100644 index 000000000..166d0645b --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/DefaultR2dbcExecutor.java @@ -0,0 +1,159 @@ +package org.hswebframework.web.crud.sql; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Statement; +import lombok.Setter; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.reactive.r2dbc.R2dbcReactiveSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; +import org.hswebframework.web.api.crud.entity.TransactionManagers; +import org.hswebframework.web.datasource.DataSourceHolder; +import org.hswebframework.web.datasource.R2dbcDataSource; +import org.hswebframework.web.exception.I18nSupportException; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.r2dbc.connection.ConnectionFactoryUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.Map; + +public class DefaultR2dbcExecutor extends R2dbcReactiveSqlExecutor { + + @Autowired + @Setter + private ConnectionFactory defaultFactory; + + @Setter + private boolean bindCustomSymbol = false; + + @Setter + private String bindSymbol = "$"; + + @Override + public String getBindSymbol() { + return bindSymbol; + } + + @Override + protected SqlRequest convertRequest(SqlRequest sqlRequest) { + if (bindCustomSymbol) { + return super.convertRequest(sqlRequest); + } + return sqlRequest; + } + + @Override + protected Statement prepareStatement(Statement statement, SqlRequest request) { + try { + return super.prepareStatement(statement, request); + } catch (Throwable e) { + throw new I18nSupportException + .NoStackTrace("error.sql.prepare", e) + .withSource("sql.prepare", request); + } + } + + protected void bindNull(Statement statement, int index, Class type) { + if (type == Date.class) { + type = LocalDateTime.class; + } + if (bindCustomSymbol) { + statement.bindNull(getBindSymbol() + (index + getBindFirstIndex()), type); + return; + } + statement.bindNull(index, type); + } + + protected void bind(Statement statement, int index, Object value) { + + if (value instanceof Date) { + value = ((Date) value) + .toInstant() + .atZone(ZoneOffset.systemDefault()) + .toLocalDateTime(); + } + if (bindCustomSymbol) { + statement.bind(getBindSymbol() + (index + getBindFirstIndex()), value); + return; + } + statement.bind(index, value); + } + + @Override + protected Mono getConnection() { + if (DataSourceHolder.isDynamicDataSourceReady()) { + return DataSourceHolder.currentR2dbc() + .flatMap(R2dbcDataSource::getNative) + .flatMap(ConnectionFactoryUtils::getConnection); + } else { + return ConnectionFactoryUtils.getConnection(defaultFactory); + } + } + + @Override + protected void releaseConnection(SignalType type, Connection connection) { + //所有方法都被事务接管,不用手动释放 + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono execute(SqlRequest request) { + return super.execute(request); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono execute(Publisher request) { + return super.execute(request); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono update(Publisher request) { + return super.update(request); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono update(SqlRequest request) { + return super.update(request); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono update(String sql, Object... args) { + return super.update(sql, args); + } + + @Override + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + public Flux select(Publisher request, ResultWrapper wrapper) { + return super.select(request, wrapper); + } + + @Override + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + public Flux> select(String sql, Object... args) { + return super.select(sql, args); + } + + @Override + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + public Flux select(String sql, ResultWrapper wrapper) { + return super.select(sql, wrapper); + } + + @Override + @Transactional(readOnly = true, transactionManager = TransactionManagers.reactiveTransactionManager) + public Flux select(SqlRequest sqlRequest, ResultWrapper wrapper) { + return super.select(sqlRequest, wrapper); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/terms/TreeChildTermBuilder.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/terms/TreeChildTermBuilder.java new file mode 100644 index 000000000..99dac1107 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/sql/terms/TreeChildTermBuilder.java @@ -0,0 +1,65 @@ +package org.hswebframework.web.crud.sql.terms; + +import org.hswebframework.ezorm.core.param.Term; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.BatchSqlFragments; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.PrepareSqlFragments; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.SqlFragments; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.term.AbstractTermFragmentBuilder; + +import java.util.Arrays; +import java.util.List; + +/** + * 树结构相关数据查询条件构造器,用于构造根据树结构数据以及子节点查询相关联的数据, + * 如查询某个地区以及下级地区的数据. + * + * @author zhouhao + * @since 4.0.17 + */ +public abstract class TreeChildTermBuilder extends AbstractTermFragmentBuilder { + public TreeChildTermBuilder(String termType, String name) { + super(termType, name); + } + + protected abstract String tableName(); + + @Override + public SqlFragments createFragments(String columnFullName, RDBColumnMetadata column, Term term) { + List id = convertList(column, term); + + String tableName = getTableName(tableName(), column); + + String[] args = new String[id.size()]; + Arrays.fill(args, "?"); + + RDBColumnMetadata pathColumn = column + .getOwner() + .getSchema() + .getTable(tableName) + .flatMap(t -> t.getColumn("path")) + .orElseThrow(() -> new IllegalArgumentException("not found 'path' column")); + + RDBColumnMetadata idColumn = column + .getOwner() + .getSchema() + .getTable(tableName) + .flatMap(t -> t.getColumn("id")) + .orElseThrow(() -> new IllegalArgumentException("not found 'id' column")); + + BatchSqlFragments fragments = new BatchSqlFragments(2, 1); + if (term.getOptions().contains("not")) { + fragments.add(SqlFragments.NOT); + } + + return fragments + .addSql( + "exists(select 1 from", tableName, "_p join", tableName, + "_c on", idColumn.getFullName("_c"), "in(", String.join(",", args), ")", + "and", pathColumn.getFullName("_p"), "like concat(" + pathColumn.getFullName("_c") + ",'%')", + "where", columnFullName, "=", idColumn.getFullName("_p"), ")" + ) + .addParameter(id); + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/utils/TransactionUtils.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/utils/TransactionUtils.java new file mode 100644 index 000000000..84e38bb16 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/utils/TransactionUtils.java @@ -0,0 +1,49 @@ +package org.hswebframework.web.crud.utils; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.NoTransactionException; +import org.springframework.transaction.reactive.TransactionContextManager; +import org.springframework.transaction.reactive.TransactionSynchronization; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +@Slf4j +public class TransactionUtils { + + public static Mono afterCommit(Mono task) { + return TransactionUtils.registerSynchronization( + new TransactionSynchronization() { + @Override + @NonNull + public Mono afterCommit() { + return task; + } + }, + TransactionSynchronization::afterCommit + ); + } + + public static Mono registerSynchronization(TransactionSynchronization synchronization, + Function> whenNoTransaction) { + return TransactionSynchronizationManager + .forCurrentTransaction() + .flatMap(manager -> { + if (manager.isSynchronizationActive()) { + try { + manager.registerSynchronization(synchronization); + } catch (Throwable err) { + log.warn("register TransactionSynchronization [{}] error", synchronization, err); + return whenNoTransaction.apply(synchronization); + } + return Mono.empty(); + } else { + log.info("transaction is not active,execute TransactionSynchronization [{}] immediately.", synchronization); + return whenNoTransaction.apply(synchronization); + } + }) + .onErrorResume(NoTransactionException.class, err -> whenNoTransaction.apply(synchronization)); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java new file mode 100644 index 000000000..070cf27af --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java @@ -0,0 +1,278 @@ +package org.hswebframework.web.crud.web; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.CodeConstants; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.exception.AuthenticationException; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.authorization.token.TokenState; +import org.hswebframework.web.exception.BusinessException; +import org.hswebframework.web.exception.I18nSupportException; +import org.hswebframework.web.exception.NotFoundException; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.i18n.LocaleUtils; +import org.hswebframework.web.logger.ReactiveLogger; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.TransactionException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.server.*; +import reactor.core.publisher.Mono; + +import javax.validation.ConstraintViolationException; +import java.util.List; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +/** + * 统一错误处理 + * + * @author zhouhao + * @since 4.0 + */ +@RestControllerAdvice +@Slf4j +@Order +public class CommonErrorControllerAdvice { + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Mono> handleException(TransactionException e) { + log.warn(e.getLocalizedMessage(), e); + return LocaleUtils + .resolveMessageReactive("error.internal_server_error") + .map(msg -> ResponseMessage.error(500, "error." + e.getClass().getSimpleName(), msg)); + } + + @ExceptionHandler + public Mono>> handleException(BusinessException e) { + return LocaleUtils + .resolveThrowable(e, + (err, msg) -> ResponseMessage.error(err.getStatus(), err.getCode(), msg)) + .map(msg -> { + HttpStatus status = HttpStatus.resolve(msg.getStatus()); + return ResponseEntity + .status(status == null ? HttpStatus.BAD_REQUEST : status) + .body(msg); + }); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono> handleException(UnsupportedOperationException e) { + log.warn(e.getLocalizedMessage(), e); + return LocaleUtils + .resolveThrowable(e, (err, msg) -> + (ResponseMessage.error(400, CodeConstants.Error.unsupported, msg))); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public Mono> handleException(UnAuthorizedException e) { + return LocaleUtils + .resolveThrowable(e, (err, msg) -> (ResponseMessage + .error(401, CodeConstants.Error.unauthorized, msg) + .result(e.getState()))); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.FORBIDDEN) + public Mono> handleException(AccessDenyException e) { + return LocaleUtils + .resolveThrowable(e, (err, msg) -> ResponseMessage.error(403, e.getCode(), msg)) + ; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_FOUND) + public Mono> handleException(NotFoundException e) { + return LocaleUtils + .resolveThrowable(e, (err, msg) -> ResponseMessage.error(404, CodeConstants.Error.not_found, msg)) + ; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono>> handleException(ValidationException e) { + return LocaleUtils + .currentReactive() + .map(locale -> ResponseMessage + .>error(400, + CodeConstants.Error.illegal_argument, + e.getLocalizedMessage(locale)) + .result(e.getDetails(locale))); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono>> handleException(ConstraintViolationException e) { + return handleException(new ValidationException(e.getConstraintViolations())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + @SuppressWarnings("all") + public Mono>> handleException(BindException e) { + return handleBindingResult(e.getBindingResult()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + @SuppressWarnings("all") + public Mono>> handleException(WebExchangeBindException e) { + return handleBindingResult(e.getBindingResult()); + } + + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + @SuppressWarnings("all") + public Mono>> handleException(MethodArgumentNotValidException e) { + return handleBindingResult(e.getBindingResult()); + } + + private Mono>> handleBindingResult(BindingResult result) { + String message; + FieldError fieldError = result.getFieldError(); + ObjectError globalError = result.getGlobalError(); + + if (null != fieldError) { + message = fieldError.getDefaultMessage(); + } else if (null != globalError) { + message = globalError.getDefaultMessage(); + } else { + message = CodeConstants.Error.illegal_argument; + } + List details = result + .getFieldErrors() + .stream() + .map(err -> new ValidationException.Detail(err.getField(), err.getDefaultMessage(), null)) + .collect(Collectors.toList()); + return handleException(new ValidationException(message, details)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono> handleException(javax.validation.ValidationException e) { + return Mono.just(ResponseMessage.error(400, CodeConstants.Error.illegal_argument, e.getMessage())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.GATEWAY_TIMEOUT) + public Mono> handleException(TimeoutException e) { + return LocaleUtils + .resolveThrowable(e, (err, msg) -> ResponseMessage.error(504, CodeConstants.Error.timeout, msg)) + .doOnEach(ReactiveLogger.onNext(r -> log.warn(e.getLocalizedMessage(), e))); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @Order + public Mono> handleException(RuntimeException e) { + return LocaleUtils + .resolveThrowable(e, (err, msg) -> { + log.warn(msg, e); + return ResponseMessage.error(msg); + }); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Mono> handleException(NullPointerException e) { + log.warn(e.getLocalizedMessage(), e); + return Mono.just(ResponseMessage.error(e.getMessage())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono> handleException(IllegalArgumentException e) { + + return LocaleUtils + .resolveThrowable(e, (err, msg) -> { + log.warn(msg, e); + return ResponseMessage.error(400, CodeConstants.Error.illegal_argument, msg); + }); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono> handleException(AuthenticationException e) { + return LocaleUtils + .resolveThrowable(e, (err, msg) -> ResponseMessage.error(400, err.getCode(), msg)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + public Mono> handleException(UnsupportedMediaTypeStatusException e) { + log.warn(e.getLocalizedMessage(), e); + + return LocaleUtils + .resolveMessageReactive("error.unsupported_media_type") + .map(msg -> ResponseMessage + .error(415, "unsupported_media_type", msg) + .result(e.getSupportedMediaTypes())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) + public Mono> handleException(NotAcceptableStatusException e) { + log.warn(e.getLocalizedMessage(), e); + + return LocaleUtils + .resolveMessageReactive("error.not_acceptable_media_type") + .map(msg -> ResponseMessage + .error(406, "not_acceptable_media_type", msg) + .result(e.getSupportedMediaTypes())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) + public Mono> handleException(MethodNotAllowedException e) { + log.warn(e.getLocalizedMessage(), e); + + return LocaleUtils + .resolveMessageReactive("error.method_not_allowed") + .map(msg -> ResponseMessage + .error(406, "method_not_allowed", msg) + .result(e.getSupportedMethods())); + } + + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono>> handleException(ServerWebInputException e) { + Throwable exception = e; + do { + exception = exception.getCause(); + if (exception instanceof ValidationException) { + return handleException(((ValidationException) exception)); + } + + } while (exception != null && exception != e); + if (exception == null) { + return Mono.just( + ResponseMessage.error(400, CodeConstants.Error.illegal_argument, e.getMessage()) + ); + } + return LocaleUtils + .resolveThrowable(exception, + (err, msg) -> ResponseMessage.error(400, CodeConstants.Error.illegal_argument, msg)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Mono> handleException(I18nSupportException e) { + return e.getLocalizedMessageReactive() + .map(msg -> ResponseMessage.error(400, e.getI18nCode(), msg)); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java new file mode 100644 index 000000000..562cef16a --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java @@ -0,0 +1,48 @@ +package org.hswebframework.web.crud.web; + +import org.hswebframework.web.i18n.WebFluxLocaleFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.server.WebFilter; + +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +public class CommonWebFluxConfiguration { + + @Bean + @ConditionalOnMissingBean + public CommonErrorControllerAdvice commonErrorControllerAdvice() { + return new CommonErrorControllerAdvice(); + } + + @Bean + @ConditionalOnClass(name = "io.r2dbc.spi.R2dbcException") + @ConditionalOnMissingBean + public R2dbcErrorControllerAdvice r2dbcErrorControllerAdvice() { + return new R2dbcErrorControllerAdvice(); + } + + @Bean + @ConditionalOnProperty(prefix = "hsweb.webflux.response-wrapper", name = "enabled", havingValue = "true", matchIfMissing = true) + @ConfigurationProperties(prefix = "hsweb.webflux.response-wrapper") + public ResponseMessageWrapper responseMessageWrapper(ServerCodecConfigurer codecConfigurer, + RequestedContentTypeResolver resolver, + ReactiveAdapterRegistry registry) { + return new ResponseMessageWrapper(codecConfigurer.getWriters(), resolver, registry); + } + + @Bean + public WebFilter localeWebFilter() { + return new WebFluxLocaleFilter(); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebMvcConfiguration.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebMvcConfiguration.java new file mode 100644 index 000000000..7268e4f67 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebMvcConfiguration.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.crud.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass(org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice.class) +public class CommonWebMvcConfiguration { + + @Bean + @ConditionalOnMissingBean + public CommonWebMvcErrorControllerAdvice commonErrorControllerAdvice() { + return new CommonWebMvcErrorControllerAdvice(); + } + + + @SuppressWarnings("all") + @Bean + @ConditionalOnProperty(prefix = "hsweb.webflux.response-wrapper", name = "enabled", havingValue = "true", matchIfMissing = true) + @ConfigurationProperties(prefix = "hsweb.webflux.response-wrapper") + public ResponseMessageWrapperAdvice responseMessageWrapper() { + return new ResponseMessageWrapperAdvice(); + } + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebMvcErrorControllerAdvice.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebMvcErrorControllerAdvice.java new file mode 100644 index 000000000..f18dcb352 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebMvcErrorControllerAdvice.java @@ -0,0 +1,234 @@ +package org.hswebframework.web.crud.web; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.CodeConstants; +import org.hswebframework.web.authorization.exception.AccessDenyException; +import org.hswebframework.web.authorization.exception.AuthenticationException; +import org.hswebframework.web.authorization.exception.UnAuthorizedException; +import org.hswebframework.web.authorization.token.TokenState; +import org.hswebframework.web.exception.BusinessException; +import org.hswebframework.web.exception.I18nSupportException; +import org.hswebframework.web.exception.NotFoundException; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.i18n.LocaleUtils; +import org.hswebframework.web.logger.ReactiveLogger; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.server.MethodNotAllowedException; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; +import reactor.core.publisher.Mono; + +import javax.validation.ConstraintViolationException; +import java.util.List; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +@Order +public class CommonWebMvcErrorControllerAdvice { + + private String resolveMessage(Throwable e) { + if (e instanceof I18nSupportException) { + return LocaleUtils.resolveMessage(((I18nSupportException) e).getI18nCode()); + } + return e.getMessage() == null ? null : LocaleUtils.resolveMessage(e.getMessage()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseMessage handleException(BusinessException err) { + String msg = resolveMessage(err); + return ResponseMessage.error(err.getStatus(), err.getCode(), msg); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseMessage handleException(UnsupportedOperationException e) { + log.warn(e.getLocalizedMessage(), e); + String msg = resolveMessage(e); + return ResponseMessage.error(500, CodeConstants.Error.unsupported, msg); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ResponseMessage handleException(UnAuthorizedException e) { + return ResponseMessage + .error(401, CodeConstants.Error.unauthorized, resolveMessage(e)) + .result(e.getState()); + + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseMessage handleException(AccessDenyException e) { + return ResponseMessage.error(403, e.getCode(), resolveMessage(e)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_FOUND) + public ResponseMessage handleException(NotFoundException e) { + return ResponseMessage.error(404, CodeConstants.Error.not_found, resolveMessage(e)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage> handleException(ValidationException e) { + + return ResponseMessage + .>error(400, CodeConstants.Error.illegal_argument, resolveMessage(e)) + .result(e.getDetails()) + ; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage> handleException(ConstraintViolationException e) { + return handleException(new ValidationException(e.getConstraintViolations())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage> handleException(BindException e) { + return handleException(new ValidationException(e.getMessage(), e + .getBindingResult().getAllErrors() + .stream() + .filter(FieldError.class::isInstance) + .map(FieldError.class::cast) + .map(err -> new ValidationException.Detail(err.getField(), err.getDefaultMessage(), null)) + .collect(Collectors.toList()))); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage> handleException(WebExchangeBindException e) { + return handleException(new ValidationException(e.getMessage(), e + .getBindingResult().getAllErrors() + .stream() + .filter(FieldError.class::isInstance) + .map(FieldError.class::cast) + .map(err -> new ValidationException.Detail(err.getField(), err.getDefaultMessage(), null)) + .collect(Collectors.toList()))); + } + + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage> handleException(MethodArgumentNotValidException e) { + return handleException(new ValidationException(e.getMessage(), e + .getBindingResult().getAllErrors() + .stream() + .filter(FieldError.class::isInstance) + .map(FieldError.class::cast) + .map(err -> new ValidationException.Detail(err.getField(), err.getDefaultMessage(), null)) + .collect(Collectors.toList()))); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage handleException(javax.validation.ValidationException e) { + return ResponseMessage.error(400, CodeConstants.Error.illegal_argument, e.getMessage()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.GATEWAY_TIMEOUT) + public ResponseMessage handleException(TimeoutException e) { + return ResponseMessage.error(504, CodeConstants.Error.timeout, resolveMessage(e)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @Order + public ResponseMessage handleException(RuntimeException e) { + log.warn(e.getLocalizedMessage(), e); + return ResponseMessage.error(resolveMessage(e)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseMessage handleException(NullPointerException e) { + log.warn(e.getLocalizedMessage(), e); + return ResponseMessage.error(e.getMessage()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage handleException(IllegalArgumentException e) { + log.warn(e.getLocalizedMessage(), e); + + return ResponseMessage.error(400, CodeConstants.Error.illegal_argument, resolveMessage(e)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage handleException(AuthenticationException e) { + log.warn(e.getLocalizedMessage(), e); + + return ResponseMessage.error(400, e.getCode(), resolveMessage(e)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + public ResponseMessage handleException(UnsupportedMediaTypeStatusException e) { + log.warn(e.getLocalizedMessage(), e); + + return ResponseMessage + .error(415, "unsupported_media_type", LocaleUtils.resolveMessage("error.unsupported_media_type")) + .result(e.getSupportedMediaTypes()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) + public ResponseMessage handleException(NotAcceptableStatusException e) { + log.warn(e.getLocalizedMessage(), e); + + return ResponseMessage + .error(406, "not_acceptable_media_type", LocaleUtils + .resolveMessage("error.not_acceptable_media_type")) + .result(e.getSupportedMediaTypes()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) + public ResponseMessage handleException(MethodNotAllowedException e) { + log.warn(e.getLocalizedMessage(), e); + + return ResponseMessage + .error(406, "method_not_allowed", LocaleUtils.resolveMessage("error.method_not_allowed")) + .result(e.getSupportedMethods()); + } + + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage> handleException(ServerWebInputException e) { + Throwable exception = e; + do { + exception = exception.getCause(); + if (exception instanceof ValidationException) { + return handleException(((ValidationException) exception)); + } + + } while (exception != null && exception != e); + if (exception == null) { + return ResponseMessage.error(400, CodeConstants.Error.illegal_argument, e.getMessage()); + } + return ResponseMessage.error(400, CodeConstants.Error.illegal_argument, resolveMessage(exception)); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ResponseMessage handleException(I18nSupportException e) { + return ResponseMessage.error(400, e.getI18nCode(), resolveMessage(e)); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CrudController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CrudController.java new file mode 100644 index 000000000..4799dadee --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CrudController.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.crud.web; + +public interface CrudController extends + SaveController, + QueryController, + DeleteController { +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/DeleteController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/DeleteController.java new file mode 100644 index 000000000..95c35bd68 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/DeleteController.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.crud.web; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.DeleteAction; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.Collections; + +public interface DeleteController { + @Authorize(ignore = true) + SyncRepository getRepository(); + + @DeleteMapping("/{id:.+}") + @DeleteAction + @Operation(summary = "根据ID删除") + default E delete(@PathVariable K id) { + E data = getRepository() + .findById(id) + .orElseThrow(NotFoundException::new); + getRepository().deleteById(Collections.singletonList(id)); + return data; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/QueryController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/QueryController.java new file mode 100644 index 000000000..5bb2b4f3a --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/QueryController.java @@ -0,0 +1,175 @@ +package org.hswebframework.web.crud.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryNoPagingOperation; +import org.hswebframework.web.api.crud.entity.QueryOperation; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.QueryAction; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.List; + +/** + * 基于{@link SyncRepository}的查询控制器. + * + * @param 实体类 + * @param 主键类型 + * @see SyncRepository + */ +public interface QueryController { + + @Authorize(ignore = true) + SyncRepository getRepository(); + + /** + * 查询,但是不返回分页结果. + * + *
+     *     GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 动态查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @GetMapping("/_query/no-paging") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default List query(@Parameter(hidden = true) QueryParamEntity query) { + return getRepository() + .createQuery() + .setParam(query) + .fetch(); + } + + /** + * POST方式查询.不返回分页结果 + * + *
+     *     POST /_query/no-paging
+     *
+     *     {
+     *         "pageIndex":0,
+     *         "pageSize":20,
+     *         "where":"name like 张%", //放心使用,没有SQL注入
+     *         "orderBy":"id desc",
+     *         "terms":[ //高级条件
+     *             {
+     *                 "column":"name",
+     *                 "termType":"like",
+     *                 "value":"张%"
+     *             }
+     *         ]
+     *     }
+     * 
+ * + * @param query 查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @PostMapping("/_query/no-paging") + @QueryAction + @QueryNoPagingOperation(summary = "使用POST方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default List postQuery(@Parameter(hidden = true) @RequestBody QueryParamEntity query) { + return this.query(query); + } + + + /** + * GET方式分页查询 + * + *
+     *    GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 查询条件 + * @return 分页查询结果 + * @see PagerResult + */ + @GetMapping("/_query") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询") + default PagerResult queryPager(@Parameter(hidden = true) QueryParamEntity query) { + if (query.getTotal() != null) { + return PagerResult + .of(query.getTotal(), + getRepository() + .createQuery() + .setParam(query.rePaging(query.getTotal())) + .fetch(), query) + ; + } + int total = getRepository().createQuery().setParam(query.clone()).count(); + if (total == 0) { + return PagerResult.of(0, Collections.emptyList(), query); + } + query.rePaging(total); + + return PagerResult + .of(total, + getRepository() + .createQuery() + .setParam(query.rePaging(query.getTotal())) + .fetch(), query); + } + + + @PostMapping("/_query") + @QueryAction + @SuppressWarnings("all") + @QueryOperation(summary = "使用POST方式分页动态查询") + default PagerResult postQueryPager(@Parameter(hidden = true) @RequestBody QueryParamEntity query) { + return queryPager(query); + } + + @PostMapping("/_count") + @QueryAction + @QueryNoPagingOperation(summary = "使用POST方式查询总数") + default int postCount(@Parameter(hidden = true) @RequestBody QueryParamEntity query) { + return this.count(query); + } + + /** + * 统计查询 + * + *
+     *     GET /_count
+     * 
+ * + * @param query 查询条件 + * @return 统计结果 + */ + @GetMapping("/_count") + @QueryAction + @QueryNoPagingOperation(summary = "使用GET方式查询总数") + default int count(@Parameter(hidden = true) QueryParamEntity query) { + return getRepository() + .createQuery() + .setParam(query) + .count(); + } + + @GetMapping("/{id:.+}") + @QueryAction + @Operation(summary = "根据ID查询") + default E getById(@PathVariable K id) { + return getRepository() + .findById(id) + .orElseThrow(NotFoundException.NoStackTrace::new); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/R2dbcErrorControllerAdvice.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/R2dbcErrorControllerAdvice.java new file mode 100644 index 000000000..01c103dcc --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/R2dbcErrorControllerAdvice.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.crud.web; + +import io.r2dbc.spi.R2dbcException; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.i18n.LocaleUtils; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import reactor.core.publisher.Mono; + +/** + * 统一r2dbc错误处理 + * + * @author zhouhao + * @since 4.0 + */ +@RestControllerAdvice +@Slf4j +@Order +public class R2dbcErrorControllerAdvice { + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Mono> handleException(R2dbcException e) { + log.error(e.getLocalizedMessage(), e); + return LocaleUtils + .resolveMessageReactive("error.internal_server_error") + .map(msg -> ResponseMessage.error(500, "error." + e.getClass().getSimpleName(), msg)); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessage.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessage.java new file mode 100644 index 000000000..76ff70584 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessage.java @@ -0,0 +1,76 @@ +package org.hswebframework.web.crud.web; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.api.crud.entity.EntityFactoryHolder; + +import java.io.Serializable; + +@Getter +@Setter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResponseMessage implements Serializable { + + private static final long serialVersionUID = 8992436576262574064L; + + @Schema(description = "消息提示") + private String message; + + @Schema(description = "数据内容") + private T result; + + @Schema(description = "状态码") + private int status; + + @Schema(description = "业务码") + private String code; + + @Schema(description = "时间戳(毫秒)") + private Long timestamp = System.currentTimeMillis(); + + public ResponseMessage() { + } + + public static ResponseMessage ok() { + return ok(null); + } + + @SuppressWarnings("all") + public static ResponseMessage ok(T result) { + return of("success", result, 200, null, System.currentTimeMillis()); + } + + public static ResponseMessage error(String message) { + return error("error", message); + } + + public static ResponseMessage error(String code, String message) { + return error(500, code, message); + } + + public static ResponseMessage error(int status, String code, String message) { + return of(message, null, status, code, System.currentTimeMillis()); + } + + public static ResponseMessage of(String message, + T result, + int status, + String code, + Long timestamp) { + @SuppressWarnings("all") + ResponseMessage msg = EntityFactoryHolder.newInstance(ResponseMessage.class, ResponseMessage::new); + msg.setMessage(message); + msg.setResult(result); + msg.setStatus(status); + msg.setCode(code); + msg.setTimestamp(timestamp); + return msg; + } + + public ResponseMessage result(T result) { + this.result = result; + return this; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessageWrapper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessageWrapper.java new file mode 100644 index 000000000..9610bc1a5 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessageWrapper.java @@ -0,0 +1,130 @@ +package org.hswebframework.web.crud.web; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.lang.NonNull; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class ResponseMessageWrapper extends ResponseBodyResultHandler { + + public ResponseMessageWrapper(List> writers, + RequestedContentTypeResolver resolver, + ReactiveAdapterRegistry registry) { + super(writers, resolver, registry); + setOrder(90); + } + + private static MethodParameter param; + + static { + try { + param = new MethodParameter(ResponseMessageWrapper.class + .getDeclaredMethod("methodForParams"), -1); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } + } + + private static Mono> methodForParams() { + return Mono.empty(); + } + + @Setter + @Getter + private Set excludes = new HashSet<>(); + + @Override + public boolean supports(@NonNull HandlerResult result) { + + if (!CollectionUtils.isEmpty(excludes) && result.getHandler() instanceof HandlerMethod) { + HandlerMethod method = (HandlerMethod) result.getHandler(); + + String typeName = method.getMethod().getDeclaringClass().getName() + "." + method.getMethod().getName(); + for (String exclude : excludes) { + if (typeName.startsWith(exclude)) { + return false; + } + } + } + Class gen = result.getReturnType().resolveGeneric(0); + + boolean isAlreadyResponse = gen == ResponseMessage.class || gen == ResponseEntity.class; + + boolean isStream = result.getReturnType().resolve() == Mono.class + || result.getReturnType().resolve() == Flux.class; + + RequestMapping mapping = result.getReturnTypeSource() + .getMethodAnnotation(RequestMapping.class); + if (mapping == null) { + return false; + } + for (String produce : mapping.produces()) { + MimeType mimeType = MimeType.valueOf(produce); + if (MediaType.TEXT_EVENT_STREAM.includes(mimeType) || + MediaType.APPLICATION_NDJSON.includes(mimeType)) { + return false; + } + } + + return isStream + && super.supports(result) + && !isAlreadyResponse; + } + + @Override + @SuppressWarnings("all") + public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + Object body = result.getReturnValue(); + + List accept = exchange.getRequest().getHeaders().getAccept(); + + if (accept.contains(MediaType.TEXT_EVENT_STREAM)|| + accept.contains(MediaType.APPLICATION_NDJSON)) { + return writeBody(body, result.getReturnTypeSource(), exchange); + } + + String ignoreWrapper = exchange + .getRequest() + .getHeaders() + .getFirst("X-Response-Wrapper"); + if ("Ignore".equals(ignoreWrapper)) { + return writeBody(body, result.getReturnTypeSource(), exchange); + } + + if (body instanceof Mono) { + body = ((Mono) body) + .map(ResponseMessage::ok) + .switchIfEmpty(Mono.just(ResponseMessage.ok())); + } + if (body instanceof Flux) { + body = ((Flux) body) + .collectList() + .map(ResponseMessage::ok) + .switchIfEmpty(Mono.just(ResponseMessage.ok())); + + } + if (body == null) { + body = Mono.just(ResponseMessage.ok()); + } + return writeBody(body, param, exchange); + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessageWrapperAdvice.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessageWrapperAdvice.java new file mode 100644 index 000000000..d4f32299c --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ResponseMessageWrapperAdvice.java @@ -0,0 +1,105 @@ +package org.hswebframework.web.crud.web; + +import com.alibaba.fastjson.JSON; +import lombok.Getter; +import lombok.Setter; +import org.reactivestreams.Publisher; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.annotation.Nonnull; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +@RestControllerAdvice +public class ResponseMessageWrapperAdvice implements ResponseBodyAdvice { + @Setter + @Getter + private Set excludes = new HashSet<>(); + + @Override + public boolean supports(@Nonnull MethodParameter methodParameter, @Nonnull Class> aClass) { + + if (methodParameter.getMethod() == null) { + return true; + } + + RequestMapping mapping = methodParameter.getMethodAnnotation(RequestMapping.class); + if (mapping == null) { + return false; + } + for (String produce : mapping.produces()) { + MimeType mimeType = MimeType.valueOf(produce); + if (MediaType.TEXT_EVENT_STREAM.includes(mimeType) || + MediaType.APPLICATION_STREAM_JSON.includes(mimeType)) { + return false; + } + } + + if (!CollectionUtils.isEmpty(excludes) && methodParameter.getMethod() != null) { + + String typeName = methodParameter.getMethod().getDeclaringClass().getName() + "." + methodParameter + .getMethod() + .getName(); + for (String exclude : excludes) { + if (typeName.startsWith(exclude)) { + return false; + } + } + } + + Class returnType = methodParameter.getMethod().getReturnType(); + + boolean isStream = Publisher.class.isAssignableFrom(returnType); + if (isStream) { + ResolvableType type = ResolvableType.forMethodParameter(methodParameter); + returnType = type.resolveGeneric(0); + } + boolean isAlreadyResponse = returnType == ResponseMessage.class || returnType == ResponseEntity.class; + + return !isAlreadyResponse; + } + + @Override + public Object beforeBodyWrite(Object body, + @Nonnull MethodParameter returnType, + @Nonnull MediaType selectedContentType, + @Nonnull Class> selectedConverterType, + @Nonnull ServerHttpRequest request, + @Nonnull ServerHttpResponse response) { + if (body instanceof Mono) { + return ((Mono) body) + .map(ResponseMessage::ok) + .switchIfEmpty(Mono.fromSupplier(ResponseMessage::ok)); + } + if (body instanceof Flux) { + return ((Flux) body) + .collectList() + .map(ResponseMessage::ok) + .switchIfEmpty(Mono.fromSupplier(ResponseMessage::ok)); + } + + Method method = returnType.getMethod(); + + if (method != null && returnType.getMethod().getReturnType() == String.class) { + return JSON.toJSONString(ResponseMessage.ok(body)); + } + + return ResponseMessage.ok(body); + } + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/SaveController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/SaveController.java new file mode 100644 index 000000000..5d818d8b5 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/SaveController.java @@ -0,0 +1,109 @@ +package org.hswebframework.web.crud.web; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.RecordCreationEntity; +import org.hswebframework.web.api.crud.entity.RecordModifierEntity; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.SaveAction; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +public interface SaveController { + + @Authorize(ignore = true) + SyncRepository getRepository(); + + @Authorize(ignore = true) + default E applyCreationEntity(Authentication authentication, E entity) { + RecordCreationEntity creationEntity = ((RecordCreationEntity) entity); + creationEntity.setCreateTimeNow(); + creationEntity.setCreatorId(authentication.getUser().getId()); + creationEntity.setCreatorName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyModifierEntity(Authentication authentication, E entity) { + RecordModifierEntity modifierEntity = ((RecordModifierEntity) entity); + modifierEntity.setModifyTimeNow(); + modifierEntity.setModifierId(authentication.getUser().getId()); + modifierEntity.setModifierName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyAuthentication(E entity, Authentication authentication) { + if (entity instanceof RecordCreationEntity) { + entity = applyCreationEntity(authentication, entity); + } + if (entity instanceof RecordModifierEntity) { + entity = applyModifierEntity(authentication, entity); + } + return entity; + } + + @PatchMapping + @SaveAction + @Operation(summary = "保存数据", description = "如果传入了id,并且对应数据存在,则尝试覆盖,不存在则新增.") + default SaveResult save(@RequestBody List payload) { + return getRepository() + .save(Authentication + .current() + .map(auth -> { + for (E e : payload) { + applyAuthentication(e, auth); + } + return payload; + }) + .orElse(payload) + ); + } + + @PostMapping("/_batch") + @SaveAction + @Operation(summary = "批量新增数据") + default int add(@RequestBody List payload) { + return getRepository() + .insertBatch(Authentication + .current() + .map(auth -> { + for (E e : payload) { + applyAuthentication(e, auth); + } + return payload; + }) + .orElse(payload) + ); + } + + @PostMapping + @SaveAction + @Operation(summary = "新增单个数据,并返回新增后的数据.") + default E add(@RequestBody E payload) { + this.getRepository() + .insert(Authentication + .current() + .map(auth -> applyAuthentication(payload, auth)) + .orElse(payload)); + return payload; + } + + + @PutMapping("/{id}") + @SaveAction + @Operation(summary = "根据ID修改数据") + default boolean update(@PathVariable K id, @RequestBody E payload) { + + return getRepository() + .updateById(id, Authentication + .current() + .map(auth -> applyAuthentication(payload, auth)) + .orElse(payload)) + > 0; + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceCrudController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceCrudController.java new file mode 100644 index 000000000..feb22bc20 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceCrudController.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.crud.web; + +public interface ServiceCrudController extends + ServiceSaveController, + ServiceQueryController, + ServiceDeleteController { +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceDeleteController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceDeleteController.java new file mode 100644 index 000000000..41290a5f3 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceDeleteController.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.crud.web; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.DeleteAction; +import org.hswebframework.web.crud.service.CrudService; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; + +public interface ServiceDeleteController { + @Authorize(ignore = true) + CrudService getService(); + + @DeleteMapping("/{id:.+}") + @DeleteAction + @Operation(summary = "根据ID删除") + default E delete(@PathVariable K id) { + E data = getService() + .findById(id) + .orElseThrow(NotFoundException::new); + getService() + .deleteById(id); + return data; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceQueryController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceQueryController.java new file mode 100644 index 000000000..369364486 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceQueryController.java @@ -0,0 +1,171 @@ +package org.hswebframework.web.crud.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryNoPagingOperation; +import org.hswebframework.web.api.crud.entity.QueryOperation; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.QueryAction; +import org.hswebframework.web.crud.service.CrudService; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.Collections; +import java.util.List; + +/** + * 基于{@link CrudService}的查询控制器. + * + * @param 实体类 + * @param 主键类型 + * @see CrudService + */ +public interface ServiceQueryController { + + @Authorize(ignore = true) + CrudService getService(); + + /** + * 查询,但是不返回分页结果. + * + *
+     *     GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 动态查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @GetMapping("/_query/no-paging") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default List query(@Parameter(hidden = true) QueryParamEntity query) { + return getService() + .createQuery() + .setParam(query) + .fetch(); + } + + /** + * POST方式查询.不返回分页结果 + * + *
+     *     POST /_query/no-paging
+     *
+     *     {
+     *         "pageIndex":0,
+     *         "pageSize":20,
+     *         "where":"name like 张%", //放心使用,没有SQL注入
+     *         "orderBy":"id desc",
+     *         "terms":[ //高级条件
+     *             {
+     *                 "column":"name",
+     *                 "termType":"like",
+     *                 "value":"张%"
+     *             }
+     *         ]
+     *     }
+     * 
+ * + * @param query 查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @PostMapping("/_query/no-paging") + @QueryAction + @Operation(summary = "使用POST方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default List postQuery(@RequestBody QueryParamEntity query) { + return this.query(query); + } + + + /** + * GET方式分页查询 + * + *
+     *    GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 查询条件 + * @return 分页查询结果 + * @see PagerResult + */ + @GetMapping("/_query") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询") + default PagerResult queryPager(@Parameter(hidden = true) QueryParamEntity query) { + if (query.getTotal() != null) { + return PagerResult + .of(query.getTotal(), + getService() + .createQuery() + .setParam(query.rePaging(query.getTotal())) + .fetch(), query) + ; + } + int total = getService().createQuery().setParam(query.clone()).count(); + if (total == 0) { + return PagerResult.of(0, Collections.emptyList(), query); + } + return PagerResult + .of(total, + getService() + .createQuery() + .setParam(query.rePaging(total)) + .fetch(), query); + } + + + @PostMapping("/_query") + @QueryAction + @SuppressWarnings("all") + @Operation(summary = "使用POST方式分页动态查询") + default PagerResult postQueryPager(@RequestBody QueryParamEntity query) { + return queryPager(query); + } + + @PostMapping("/_count") + @QueryAction + @Operation(summary = "使用POST方式查询总数") + default int postCount(@RequestBody QueryParamEntity query) { + return this.count(query); + } + + /** + * 统计查询 + * + *
+     *     GET /_count
+     * 
+ * + * @param query 查询条件 + * @return 统计结果 + */ + @GetMapping("/_count") + @QueryAction + @QueryNoPagingOperation(summary = "使用GET方式查询总数") + default int count(@Parameter(hidden = true) QueryParamEntity query) { + return getService() + .createQuery() + .setParam(query) + .count(); + } + + @GetMapping("/{id:.+}") + @QueryAction + @Operation(summary = "根据ID查询") + default E getById(@PathVariable K id) { + return getService() + .findById(id) + .orElseThrow(NotFoundException::new); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceSaveController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceSaveController.java new file mode 100644 index 000000000..df3be1af5 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/ServiceSaveController.java @@ -0,0 +1,109 @@ +package org.hswebframework.web.crud.web; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.RecordCreationEntity; +import org.hswebframework.web.api.crud.entity.RecordModifierEntity; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.SaveAction; +import org.hswebframework.web.crud.service.CrudService; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +public interface ServiceSaveController { + + @Authorize(ignore = true) + CrudService getService(); + + @Authorize(ignore = true) + default E applyCreationEntity(Authentication authentication, E entity) { + RecordCreationEntity creationEntity = ((RecordCreationEntity) entity); + creationEntity.setCreateTimeNow(); + creationEntity.setCreatorId(authentication.getUser().getId()); + creationEntity.setCreatorName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyModifierEntity(Authentication authentication, E entity) { + RecordModifierEntity modifierEntity = ((RecordModifierEntity) entity); + modifierEntity.setModifyTimeNow(); + modifierEntity.setModifierId(authentication.getUser().getId()); + modifierEntity.setModifierName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyAuthentication(E entity, Authentication authentication) { + if (entity instanceof RecordCreationEntity) { + entity = applyCreationEntity(authentication, entity); + } + if (entity instanceof RecordModifierEntity) { + entity = applyModifierEntity(authentication, entity); + } + return entity; + } + + @PatchMapping + @SaveAction + @Operation(summary = "保存数据", description = "如果传入了id,并且对应数据存在,则尝试覆盖,不存在则新增.") + default SaveResult save(@RequestBody List payload) { + return getService() + .save(Authentication + .current() + .map(auth -> { + for (E e : payload) { + applyAuthentication(e, auth); + } + return payload; + }) + .orElse(payload) + ); + } + + @PostMapping("/_batch") + @SaveAction + @Operation(summary = "批量新增数据") + default int add(@RequestBody List payload) { + return getService() + .insert(Authentication + .current() + .map(auth -> { + for (E e : payload) { + applyAuthentication(e, auth); + } + return payload; + }) + .orElse(payload) + ); + } + + @PostMapping + @SaveAction + @Operation(summary = "新增单个数据,并返回新增后的数据.") + default E add(@RequestBody E payload) { + this.getService() + .insert(Authentication + .current() + .map(auth -> applyAuthentication(payload, auth)) + .orElse(payload)); + return payload; + } + + + @PutMapping("/{id}") + @SaveAction + @Operation(summary = "根据ID修改数据") + default boolean update(@PathVariable K id, @RequestBody E payload) { + + return getService() + .updateById(id, Authentication + .current() + .map(auth -> applyAuthentication(payload, auth)) + .orElse(payload)) + > 0; + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveCrudController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveCrudController.java new file mode 100644 index 000000000..16c31e46f --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveCrudController.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.crud.web.reactive; + +/** + * 通用响应式增删该查Controller,实现本接口来默认支持增删改查相关操作. + * + * @param 实体类型 + * @param 主键类型 + * @see ReactiveSaveController + * @see ReactiveQueryController + * @see ReactiveDeleteController + */ +public interface ReactiveCrudController extends + ReactiveSaveController, + ReactiveQueryController, + ReactiveDeleteController { +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveDeleteController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveDeleteController.java new file mode 100644 index 000000000..75f06da11 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveDeleteController.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.DeleteAction; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import reactor.core.publisher.Mono; + +public interface ReactiveDeleteController { + @Authorize(ignore = true) + ReactiveRepository getRepository(); + + @DeleteMapping("/{id:.+}") + @DeleteAction + @Operation(summary = "根据ID删除") + default Mono delete(@PathVariable K id) { + return getRepository() + .findById(Mono.just(id)) + .switchIfEmpty(Mono.error(NotFoundException.NoStackTrace::new)) + .flatMap(e -> getRepository() + .deleteById(Mono.just(id)) + .thenReturn(e)); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveQueryController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveQueryController.java new file mode 100644 index 000000000..6f9e2b5cc --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveQueryController.java @@ -0,0 +1,166 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryNoPagingOperation; +import org.hswebframework.web.api.crud.entity.QueryOperation; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.QueryAction; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * 基于{@link ReactiveRepository}的响应式查询控制器. + * + * @param 实体类 + * @param 主键类型 + * @see ReactiveRepository + */ +public interface ReactiveQueryController { + + @Authorize(ignore = true) + ReactiveRepository getRepository(); + + /** + * 查询,但是不返回分页结果. + * + *
+     *     GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 动态查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @GetMapping("/_query/no-paging") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default Flux query(@Parameter(hidden = true) QueryParamEntity query) { + return getRepository() + .createQuery() + .setParam(query) + .fetch(); + } + + /** + * POST方式查询.不返回分页结果 + * + *
+     *     POST /_query/no-paging
+     *
+     *     {
+     *         "pageIndex":0,
+     *         "pageSize":20,
+     *         "where":"name like 张%", //放心使用,没有SQL注入
+     *         "orderBy":"id desc",
+     *         "terms":[ //高级条件
+     *             {
+     *                 "column":"name",
+     *                 "termType":"like",
+     *                 "value":"张%"
+     *             }
+     *         ]
+     *     }
+     * 
+ * + * @param query 查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @PostMapping("/_query/no-paging") + @QueryAction + @Operation(summary = "使用POST方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default Flux query(@RequestBody Mono query) { + return query.flatMapMany(this::query); + } + + + /** + * GET方式分页查询 + * + *
+     *    GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 查询条件 + * @return 分页查询结果 + * @see PagerResult + */ + @GetMapping("/_query") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询") + default Mono> queryPager(@Parameter(hidden = true) QueryParamEntity query) { + if (query.getTotal() != null) { + return getRepository() + .createQuery() + .setParam(query.rePaging(query.getTotal())) + .fetch() + .collectList() + .map(list -> PagerResult.of(query.getTotal(), list, query)); + } + + return Mono + .zip( + getRepository().createQuery().setParam(query.clone()).count(), + query(query.clone()).collectList(), + (total, data) -> PagerResult.of(total, data, query) + ); + + } + + + @PostMapping("/_query") + @QueryAction + @SuppressWarnings("all") + @Operation(summary = "使用POST方式分页动态查询") + default Mono> queryPager(@RequestBody Mono query) { + return query.flatMap(q -> queryPager(q)); + } + + @PostMapping("/_count") + @QueryAction + @QueryNoPagingOperation(summary = "使用POST方式查询总数") + default Mono count(@Parameter(hidden = true) @RequestBody Mono query) { + return query.flatMap(this::count); + } + + /** + * 统计查询 + * + *
+     *     GET /_count
+     * 
+ * + * @param query 查询条件 + * @return 统计结果 + */ + @GetMapping("/_count") + @QueryAction + @Operation(summary = "使用GET方式查询总数") + default Mono count(QueryParamEntity query) { + return getRepository() + .createQuery() + .setParam(query) + .count(); + } + + @GetMapping("/{id:.+}") + @QueryAction + @Operation(summary = "根据ID查询") + default Mono getById(@PathVariable K id) { + return getRepository() + .findById(Mono.just(id)) + .switchIfEmpty(Mono.error(NotFoundException.NoStackTrace::new)); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveSaveController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveSaveController.java new file mode 100644 index 000000000..cad2f6dcc --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveSaveController.java @@ -0,0 +1,189 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.RecordCreationEntity; +import org.hswebframework.web.api.crud.entity.RecordModifierEntity; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.Permission; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.SaveAction; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.validation.Valid; + +/** + * 响应式保存接口,基于{@link ReactiveRepository}提供默认的新增,保存,修改接口. + * + * @param 实体类型 + * @param 主键类型 + */ +public interface ReactiveSaveController { + + @Authorize(ignore = true) + ReactiveRepository getRepository(); + + @Authorize(ignore = true) + default E applyCreationEntity(Authentication authentication, E entity) { + RecordCreationEntity creationEntity = ((RecordCreationEntity) entity); + creationEntity.setCreateTimeNow(); + creationEntity.setCreatorId(authentication.getUser().getId()); + creationEntity.setCreatorName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyModifierEntity(Authentication authentication, E entity) { + RecordModifierEntity modifierEntity = ((RecordModifierEntity) entity); + modifierEntity.setModifyTimeNow(); + modifierEntity.setModifierId(authentication.getUser().getId()); + modifierEntity.setModifierName(authentication.getUser().getName()); + return entity; + } + + /** + * 尝试设置登陆用户信息到实体中 + * + * @param entity 实体 + * @param authentication 权限信息 + * @see RecordCreationEntity + * @see RecordModifierEntity + */ + @Authorize(ignore = true) + default E applyAuthentication(E entity, Authentication authentication) { + if (entity instanceof RecordCreationEntity) { + entity = applyCreationEntity(authentication, entity); + } + if (entity instanceof RecordModifierEntity) { + entity = applyModifierEntity(authentication, entity); + } + return entity; + } + + /** + * 保存数据,如果传入了id,并且对应数据存在,则尝试覆盖,不存在则新增. + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * PATCH /api/test
+     * Content-Type: application/json
+     *
+     * [
+     *  {
+     *   "name":"value"
+     *  }
+     * ]
+     * }
+     * 
+ * + * @param payload payload + * @return 保存结果 + */ + @PatchMapping + @SaveAction + @Operation(summary = "保存数据", description = "如果传入了id,并且对应数据存在,则尝试覆盖,不存在则新增.") + default Mono save(@RequestBody Flux payload) { + return Authentication + .currentReactive() + .flatMapMany(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .as(getRepository()::save); + } + + /** + * 批量新增 + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * POST /api/test/_batch
+     * Content-Type: application/json
+     *
+     * [
+     *  {
+     *   "name":"value"
+     *  }
+     * ]
+     * }
+     * 
+ * + * @param payload payload + * @return 保存结果 + */ + @PostMapping("/_batch") + @SaveAction + @Operation(summary = "批量新增数据") + default Mono add(@RequestBody Flux payload) { + return Authentication + .currentReactive() + .flatMapMany(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .collectList() + .as(getRepository()::insertBatch); + } + + /** + * 新增单个数据,并返回新增后的数据. + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * POST /api/test
+     * Content-Type: application/json
+     *
+     *  {
+     *   "name":"value"
+     *  }
+     * }
+     * 
+ * + * @param payload payload + * @return 新增后的数据 + */ + @PostMapping + @SaveAction + @Operation(summary = "新增单个数据,并返回新增后的数据.") + default Mono add(@RequestBody Mono payload) { + return Authentication + .currentReactive() + .flatMap(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .flatMap(entity -> getRepository().insert(Mono.just(entity)).thenReturn(entity)); + } + + + /** + * 根据ID修改数据 + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * PUT /api/test/{id}
+     * Content-Type: application/json
+     *
+     *  {
+     *   "name":"value"
+     *  }
+     * }
+     * 
+ * + * @param payload payload + * @return 是否成功 + */ + @PutMapping("/{id}") + @SaveAction + @Operation(summary = "根据ID修改数据") + default Mono update(@PathVariable K id, @RequestBody Mono payload) { + return Authentication + .currentReactive() + .flatMap(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .flatMap(entity -> getRepository().updateById(id, Mono.just(entity))) + .thenReturn(true); + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceCrudController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceCrudController.java new file mode 100644 index 000000000..451f1c54f --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceCrudController.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.crud.web.reactive; + +public interface ReactiveServiceCrudController extends + ReactiveServiceSaveController, + ReactiveServiceQueryController, + ReactiveServiceDeleteController { + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceDeleteController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceDeleteController.java new file mode 100644 index 000000000..ba3074f0e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceDeleteController.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.DeleteAction; +import org.hswebframework.web.crud.service.ReactiveCrudService; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import reactor.core.publisher.Mono; + +public interface ReactiveServiceDeleteController { + @Authorize(ignore = true) + ReactiveCrudService getService(); + + @DeleteMapping("/{id:.+}") + @DeleteAction + @Operation(summary = "根据ID删除") + default Mono delete(@PathVariable K id) { + return getService() + .findById(Mono.just(id)) + .switchIfEmpty(Mono.error(NotFoundException.NoStackTrace::new)) + .flatMap(e -> getService() + .deleteById(Mono.just(id)) + .thenReturn(e)); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceQueryController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceQueryController.java new file mode 100644 index 000000000..227849213 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceQueryController.java @@ -0,0 +1,251 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.hswebframework.web.api.crud.entity.PagerResult; +import org.hswebframework.web.api.crud.entity.QueryNoPagingOperation; +import org.hswebframework.web.api.crud.entity.QueryOperation; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.QueryAction; +import org.hswebframework.web.crud.service.ReactiveCrudService; +import org.hswebframework.web.exception.NotFoundException; +import org.hswebframework.web.exception.TraceSourceException; +import org.springframework.util.ClassUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + + +public interface ReactiveServiceQueryController { + + @Authorize(ignore = true) + ReactiveCrudService getService(); + + /** + * 查询,但是不返回分页结果. + * + *
+     *     GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 动态查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @GetMapping("/_query/no-paging") + @QueryAction + @QueryNoPagingOperation(summary = "使用GET方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default Flux query(@Parameter(hidden = true) QueryParamEntity query) { + return getService() + .createQuery() + .setParam(query) + .fetch(); + } + + /** + * POST方式查询.不返回分页结果 + * + *
+     *     POST /_query/no-paging
+     *
+     *     {
+     *         "pageIndex":0,
+     *         "pageSize":20,
+     *         "where":"name like 张%", //放心使用,没有SQL注入
+     *         "orderBy":"id desc",
+     *         "terms":[ //高级条件
+     *             {
+     *                 "column":"name",
+     *                 "termType":"like",
+     *                 "value":"张%"
+     *             }
+     *         ]
+     *     }
+     * 
+ * + * @param query 查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @PostMapping("/_query/no-paging") + @QueryAction + @Operation(summary = "使用POST方式分页动态查询(不返回总数)", + description = "此操作不返回分页总数,如果需要获取全部数据,请设置参数paging=false") + default Flux query(@RequestBody Mono query) { + return query.flatMapMany(this::query); + } + + /** + * GET方式分页查询 + * + *
+     *    GET /_query/no-paging?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     * 
+ * + * @param query 查询条件 + * @return 分页查询结果 + * @see PagerResult + */ + @GetMapping("/_query") + @QueryAction + @QueryOperation(summary = "使用GET方式分页动态查询") + default Mono> queryPager(@Parameter(hidden = true) QueryParamEntity query) { + if (query.getTotal() != null) { + return getService() + .createQuery() + .setParam(query.rePaging(query.getTotal())) + .fetch() + .collectList() + .map(list -> PagerResult.of(query.getTotal(), list, query)); + } + return getService().queryPager(query); + + } + + /** + * POST方式动态查询. + * + *
+     *     POST /_query
+     *
+     *     {
+     *         "pageIndex":0,
+     *         "pageSize":20,
+     *         "where":"name like 张%", //放心使用,没有SQL注入
+     *         "orderBy":"id desc",
+     *         "terms":[ //高级条件
+     *             {
+     *                 "column":"name",
+     *                 "termType":"like",
+     *                 "value":"张%"
+     *             }
+     *         ]
+     *     }
+     * 
+ * + * @param query 查询条件 + * @return 结果流 + * @see QueryParamEntity + */ + @PostMapping("/_query") + @QueryAction + @SuppressWarnings("all") + @Operation(summary = "使用POST方式分页动态查询") + default Mono> queryPager(@RequestBody Mono query) { + return query.flatMap(q -> queryPager(q)); + } + + /** + * POST方式动态查询数量. + * + *
+     *     POST /_count
+     *
+     *     {
+     *         "pageIndex":0,
+     *         "pageSize":20,
+     *         "where":"name like 张%", //放心使用,没有SQL注入
+     *         "orderBy":"id desc",
+     *         "terms":[ //高级条件
+     *             {
+     *                 "column":"name",
+     *                 "termType":"like",
+     *                 "value":"张%"
+     *             }
+     *         ]
+     *     }
+     * 
+ * + * @param query 查询条件 + * @return 查询结果 + * @see QueryParamEntity + */ + @PostMapping("/_count") + @QueryAction + @Operation(summary = "使用POST方式查询总数") + default Mono count(@RequestBody Mono query) { + return getService().count(query); + } + + /** + * GET方式动态查询数量. + * + *
+     *
+     *    GET /_count?pageIndex=0&pageSize=20&where=name is 张三&orderBy=id desc
+     *
+     * 
+ * + * @param query 查询条件 + * @return 查询结果 + * @see QueryParamEntity + */ + @GetMapping("/_count") + @QueryAction + @QueryNoPagingOperation(summary = "使用GET方式查询总数") + default Mono count(@Parameter(hidden = true) QueryParamEntity query) { + return Mono.defer(() -> getService().count(query)); + } + + @PostMapping("/_exists") + @QueryAction + @Operation(summary = "使用POST方式判断数据是否存在") + default Mono exists(@RequestBody Mono query) { + return query + .flatMap(param -> getService() + .createQuery() + .setParam(param) + .fetchOne() + .hasElement()); + } + + /** + * 使用GET方式判断数据是否存在. + * + *
+     *
+     *    GET /_exists?where=name is 张三
+     *
+     * 
+ * + * @param query 查询条件 + * @return 查询结果 + * @see QueryParamEntity + */ + @GetMapping("/_exists") + @QueryAction + @QueryNoPagingOperation(summary = "使用GET方式判断数据是否存在") + default Mono exists(@Parameter(hidden = true) QueryParamEntity query) { + return exists(Mono.just(query)); + } + + /** + * 根据ID查询. + *
+     * {@code
+     *     GET /{id}
+     * }
+     * 
+ * + * @param id ID + * @return 结果流 + * @see QueryParamEntity + */ + @GetMapping("/{id:.+}") + @QueryAction + @Operation(summary = "根据ID查询") + default Mono getById(@PathVariable K id) { + return getService() + .findById(id) + .switchIfEmpty(Mono.error(() -> new NotFoundException + .NoStackTrace("error.data.find.not_found", id) + .withSource(ClassUtils.getUserClass(this).getCanonicalName() + ".getById", id))); + } + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceSaveController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceSaveController.java new file mode 100644 index 000000000..7d86b4510 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveServiceSaveController.java @@ -0,0 +1,186 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.RecordCreationEntity; +import org.hswebframework.web.api.crud.entity.RecordModifierEntity; +import org.hswebframework.web.authorization.Authentication; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.SaveAction; +import org.hswebframework.web.crud.service.ReactiveCrudService; +import org.hswebframework.web.exception.NotFoundException; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * 响应式保存接口,基于{@link ReactiveCrudService}提供默认的新增,保存,修改接口. + * + * @param 实体类型 + * @param 主键类型 + */ +public interface ReactiveServiceSaveController { + + @Authorize(ignore = true) + ReactiveCrudService getService(); + + @Authorize(ignore = true) + default E applyCreationEntity(Authentication authentication, E entity) { + RecordCreationEntity creationEntity = ((RecordCreationEntity) entity); + creationEntity.setCreateTimeNow(); + creationEntity.setCreatorId(authentication.getUser().getId()); + creationEntity.setCreatorName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyModifierEntity(Authentication authentication, E entity) { + RecordModifierEntity modifierEntity = ((RecordModifierEntity) entity); + modifierEntity.setModifyTimeNow(); + modifierEntity.setModifierId(authentication.getUser().getId()); + modifierEntity.setModifierName(authentication.getUser().getName()); + return entity; + } + + @Authorize(ignore = true) + default E applyAuthentication(E entity, Authentication authentication) { + if (entity instanceof RecordCreationEntity) { + entity = applyCreationEntity(authentication, entity); + } + if (entity instanceof RecordModifierEntity) { + entity = applyModifierEntity(authentication, entity); + } + return entity; + } + + /** + * 保存数据,如果传入了id,并且对应数据存在,则尝试覆盖,不存在则新增. + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * PATCH /api/test
+     * Content-Type: application/json
+     *
+     * [
+     *  {
+     *   "name":"value"
+     *  }
+     * ]
+     * }
+     * 
+ * + * @param payload payload + * @return 保存结果 + */ + @PatchMapping + @SaveAction + @Operation(summary = "保存数据", description = "如果传入了id,并且对应数据存在,则尝试覆盖,不存在则新增.") + default Mono save(@RequestBody Flux payload) { + return Authentication + .currentReactive() + .flatMapMany(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .as(getService()::save); + } + + /** + * 批量新增 + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * POST /api/test/_batch
+     * Content-Type: application/json
+     *
+     * [
+     *  {
+     *   "name":"value"
+     *  }
+     * ]
+     * }
+     * 
+ * + * @param payload payload + * @return 保存结果 + */ + @PostMapping("/_batch") + @SaveAction + @Operation(summary = "批量新增数据") + default Mono add(@RequestBody Flux payload) { + + return Authentication + .currentReactive() + .flatMapMany(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .collectList() + .as(getService()::insertBatch); + } + + /** + * 新增单个数据,并返回新增后的数据. + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * POST /api/test
+     * Content-Type: application/json
+     *
+     *  {
+     *   "name":"value"
+     *  }
+     * }
+     * 
+ * + * @param payload payload + * @return 新增后的数据 + */ + @PostMapping + @SaveAction + @Operation(summary = "新增单个数据,并返回新增后的数据.") + default Mono add(@RequestBody Mono payload) { + return Authentication + .currentReactive() + .flatMap(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .flatMap(entity -> getService().insert(Mono.just(entity)).thenReturn(entity)); + } + + /** + * 根据ID修改数据 + *

+ * 以类注解{@code @RequestMapping("/api/test")}为例: + *
{@code
+     *
+     * PUT /api/test/{id}
+     * Content-Type: application/json
+     *
+     *  {
+     *   "name":"value"
+     *  }
+     * }
+     * 
+ * + * @param payload payload + * @return 是否成功 + */ + @PutMapping("/{id}") + @SaveAction + @Operation(summary = "根据ID修改数据") + default Mono update(@PathVariable K id, @RequestBody Mono payload) { + + return Authentication + .currentReactive() + .flatMap(auth -> payload.map(entity -> applyAuthentication(entity, auth))) + .switchIfEmpty(payload) + .flatMap(entity -> getService().updateById(id, Mono.just(entity))) + .doOnNext(i -> { + if (i == 0) { + throw new NotFoundException(); + } + }) + .thenReturn(true); + + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveTreeServiceQueryController.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveTreeServiceQueryController.java new file mode 100644 index 000000000..28db0e77e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/reactive/ReactiveTreeServiceQueryController.java @@ -0,0 +1,66 @@ +package org.hswebframework.web.crud.web.reactive; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.hswebframework.web.api.crud.entity.QueryOperation; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; +import org.hswebframework.web.authorization.annotation.Authorize; +import org.hswebframework.web.authorization.annotation.QueryAction; +import org.hswebframework.web.crud.service.ReactiveTreeSortEntityService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +public interface ReactiveTreeServiceQueryController, K> { + + @Authorize(ignore = true) + ReactiveTreeSortEntityService getService(); + + @GetMapping("/_query/tree") + @QueryAction + @QueryOperation(summary = "使用GET动态查询并返回树形结构") + default Mono> findAllTree(@Parameter(hidden = true) QueryParamEntity paramEntity) { + return getService().queryResultToTree(paramEntity); + } + + @GetMapping("/_query/_children") + @QueryAction + @QueryOperation(summary = "使用GET动态查询并返回子节点数据") + default Flux findAllChildren(@Parameter(hidden = true) QueryParamEntity paramEntity) { + return getService().queryIncludeChildren(paramEntity); + } + + @GetMapping("/_query/_children/tree") + @QueryAction + @QueryOperation(summary = "使用GET动态查询并返回子节点树形结构数据") + default Mono> findAllChildrenTree(@Parameter(hidden = true) QueryParamEntity paramEntity) { + return getService().queryIncludeChildrenTree(paramEntity); + } + + @PostMapping("/_query/tree") + @QueryAction + @Operation(summary = "使用POST动态查询并返回树形结构") + default Mono> findAllTree(@RequestBody Mono paramEntity) { + return getService().queryResultToTree(paramEntity); + } + + @PostMapping("/_query/_children") + @QueryAction + @Operation(summary = "使用POST动态查询并返回子节点数据") + default Flux findAllChildren(@RequestBody Mono paramEntity) { + return paramEntity.flatMapMany(param -> getService().queryIncludeChildren(param)); + } + + @PostMapping("/_query/_children/tree") + @QueryAction + @Operation(summary = "使用POST动态查询并返回子节点树形结构数据") + default Mono> findAllChildrenTree(@RequestBody Mono paramEntity) { + return paramEntity.flatMap(param -> getService().queryIncludeChildrenTree(param)); + } + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/main/resources/META-INF/services/org.hswebframework.web.exception.analyzer.ExceptionAnalyzer b/hsweb-commons/hsweb-commons-crud/src/main/resources/META-INF/services/org.hswebframework.web.exception.analyzer.ExceptionAnalyzer new file mode 100644 index 000000000..bc8746972 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/resources/META-INF/services/org.hswebframework.web.exception.analyzer.ExceptionAnalyzer @@ -0,0 +1 @@ +org.hswebframework.web.crud.exception.DatabaseExceptionAnalyzerReporter \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/hsweb-commons/hsweb-commons-crud/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..b54296e48 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,5 @@ +org.hswebframework.web.crud.configuration.EasyormConfiguration +org.hswebframework.web.crud.configuration.JdbcSqlExecutorConfiguration +org.hswebframework.web.crud.configuration.R2dbcSqlExecutorConfiguration +org.hswebframework.web.crud.web.CommonWebFluxConfiguration +org.hswebframework.web.crud.web.CommonWebMvcConfiguration \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_en.properties b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_en.properties new file mode 100644 index 000000000..e8142ce63 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_en.properties @@ -0,0 +1,10 @@ +error.unsupported_media_type=Unsupported media type +error.not_acceptable_media_type=Not acceptable media type +error.method_not_allowed=Method not allowed +error.duplicate_data=Duplicate data +error.data_error=Data error +error.internal_server_error = Internal server error +error.tree_entity_cyclic_dependency=Cannot modify parent node as oneself or one's own child node +error.tree_entity_parent_id_not_exist=Parent node does not exist or has been deleted +error.data.find.not_found=Data not found +error.sql.prepare.failed.IndexOutOfBoundsException=Execute SQL failed, try check config: `easyorm.dialect`. \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_zh.properties b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_zh.properties new file mode 100644 index 000000000..28b2e85ae --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_zh.properties @@ -0,0 +1,10 @@ +error.unsupported_media_type=\u4E0D\u652F\u6301\u7684\u8BF7\u6C42\u7C7B\u578B +error.not_acceptable_media_type=\u4E0D\u652F\u6301\u7684\u5A92\u4F53\u7C7B\u578B +error.method_not_allowed=\u4E0D\u652F\u6301\u7684\u8BF7\u6C42\u65B9\u6CD5 +error.duplicate_data=\u91CD\u590D\u7684\u6570\u636E +error.data_error=\u6570\u636E\u9519\u8BEF +error.internal_server_error=\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF +error.tree_entity_cyclic_dependency=\u4E0D\u80FD\u4FEE\u6539\u7236\u8282\u70B9\u4E3A\u81EA\u5DF1\u6216\u8005\u81EA\u5DF1\u7684\u5B50\u8282\u70B9 +error.tree_entity_parent_id_not_exist=\u7236\u8282\u70B9\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5220\u9664 +error.data.find.not_found=\u6570\u636E\u4E0D\u5B58\u5728 +error.sql.prepare.failed.IndexOutOfBoundsException=SQL\u6267\u884C\u5931\u8D25,\u8BF7\u5C1D\u8BD5\u68C0\u67E5`easyorm.dialect`\u914D\u7F6E. \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CrudTests.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CrudTests.java new file mode 100644 index 000000000..697f7eeff --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CrudTests.java @@ -0,0 +1,55 @@ +package org.hswebframework.web.crud; + +import org.hswebframework.web.crud.entity.CustomTestEntity; +import org.hswebframework.web.crud.entity.TestEntity; +import org.hswebframework.web.crud.events.EntityBeforeModifyEvent; +import org.hswebframework.web.crud.service.TestEntityService; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@SpringBootTest +@RunWith(SpringJUnit4ClassRunner.class) +public class CrudTests { + + @Autowired + private TestEntityService service; + + + @Test + public void test() { + + CustomTestEntity entity = new CustomTestEntity(); + entity.setExt("xxx"); + entity.setAge(1); + entity.setName("test"); + + entity.setExtension("extName", "test"); + + service.insert(entity) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + Assert.assertNotNull(entity.getId()); + + service.findById(entity.getId()) + .doOnNext(System.out::println) + .as(StepVerifier::create) + .expectNextMatches(e -> e instanceof CustomTestEntity) + .verifyComplete(); + + service.createUpdate() + .set("name", "test2") + .where("id", entity.getId()) + .execute() + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/TestApplication.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/TestApplication.java new file mode 100644 index 000000000..ebe8f483b --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/TestApplication.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.crud; + +import org.hswebframework.web.api.crud.entity.EntityFactory; +import org.hswebframework.web.crud.entity.factory.EntityMappingCustomizer; +import org.hswebframework.web.crud.entity.factory.MapperEntityFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; + +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@Configuration +public class TestApplication { + + @Bean + public EntityFactory entityFactory(ObjectProvider customizers) { + MapperEntityFactory factory = new MapperEntityFactory(); + customizers.forEach(customizer -> customizer.custom(factory)); + return factory; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/CustomTestEntity.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/CustomTestEntity.java new file mode 100644 index 000000000..f9be2488d --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/CustomTestEntity.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.crud.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.api.crud.entity.GenericEntity; +import org.hswebframework.web.bean.ToString; +import org.hswebframework.web.crud.annotation.EnableEntityEvent; +import org.hswebframework.web.crud.generator.Generators; + +import javax.persistence.Column; +import javax.persistence.GeneratedValue; +import javax.persistence.Table; + +@Getter +@Setter +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@EnableEntityEvent +public class CustomTestEntity extends TestEntity { + + + @Column + @ToString.Ignore + private String ext; + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/EventTestEntity.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/EventTestEntity.java new file mode 100644 index 000000000..023da3343 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/EventTestEntity.java @@ -0,0 +1,34 @@ +package org.hswebframework.web.crud.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.api.crud.entity.GenericEntity; +import org.hswebframework.web.crud.annotation.EnableEntityEvent; +import org.hswebframework.web.crud.generator.Generators; + +import javax.persistence.Column; +import javax.persistence.GeneratedValue; +import javax.persistence.Table; + +@Getter +@Setter +@Table(name = "s_test_event") +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@EnableEntityEvent +public class EventTestEntity extends GenericEntity { + + @Column(length = 32) + private String name; + + @Column + private Integer age; + + @Override + @GeneratedValue(generator = Generators.DEFAULT_ID_GENERATOR) + public String getId() { + return super.getId(); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestEntity.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestEntity.java new file mode 100644 index 000000000..ee36be473 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestEntity.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.crud.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hswebframework.web.api.crud.entity.ExtendableEntity; +import org.hswebframework.web.crud.annotation.EnableEntityEvent; + +import javax.persistence.Column; +import javax.persistence.Table; + +@Getter +@Setter +@Table(name = "s_test") +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@EnableEntityEvent +public class TestEntity extends ExtendableEntity { + + @Column(length = 32) + private String name; + + @Column + private Integer age; + + @Column + private String testName; + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java new file mode 100644 index 000000000..62c8ac40f --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java @@ -0,0 +1,36 @@ +package org.hswebframework.web.crud.entity; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.ezorm.rdb.mapping.annotation.DefaultValue; +import org.hswebframework.web.api.crud.entity.GenericTreeSortSupportEntity; +import org.hswebframework.web.api.crud.entity.TreeSupportEntity; +import org.hswebframework.web.validator.CreateGroup; + +import javax.persistence.Column; +import javax.persistence.Table; +import javax.validation.constraints.NotBlank; +import java.util.List; + +@Getter +@Setter +@Table(name = "test_tree_sort") +public class TestTreeSortEntity extends GenericTreeSortSupportEntity { + + + @Column + private String name; + + @Column(nullable = false) + @NotBlank(groups = CreateGroup.class) + @DefaultValue("test") + private String defaultTest; + + private List children; + + + @Override + public String toString() { + return "TestTreeSortEntity{}"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/DefaultEntityEventListenerConfigureTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/DefaultEntityEventListenerConfigureTest.java new file mode 100644 index 000000000..55281eca3 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/DefaultEntityEventListenerConfigureTest.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.web.crud.entity.EventTestEntity; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class DefaultEntityEventListenerConfigureTest { + + @Test + public void test() { + DefaultEntityEventListenerConfigure configure = new DefaultEntityEventListenerConfigure(); + configure.enable(EventTestEntity.class); + configure.disable(EventTestEntity.class, EntityEventType.create, EntityEventPhase.after); + + + assertTrue(configure.isEnabled(EventTestEntity.class)); + assertTrue(configure.isEnabled(EventTestEntity.class, EntityEventType.create, EntityEventPhase.before)); + + assertFalse(configure.isEnabled(EventTestEntity.class, EntityEventType.create, EntityEventPhase.after)); + + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/EntityEventListenerTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/EntityEventListenerTest.java new file mode 100644 index 000000000..3fe9c75da --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/EntityEventListenerTest.java @@ -0,0 +1,216 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.NativeSql; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.EventTestEntity; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import javax.annotation.PostConstruct; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = TestApplication.class) +public class EntityEventListenerTest { + + @Autowired + private ReactiveRepository reactiveRepository; + + @Autowired + private TransactionalOperator transactionalOperator; + + @Autowired + private TestEntityListener listener; + + @Before + public void before() { + listener.reset(); + } + + @Test + public void test() { + Mono.just(EventTestEntity.of("test", 1)) + .as(reactiveRepository::insert) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + Assert.assertEquals(listener.created.getAndSet(0), 1); + + + } + + @Test + public void testPrepareModifySetNull() { + EventTestEntity entity = EventTestEntity.of("prepare-setNull", 20); + reactiveRepository + .insert(entity) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + Assert.assertEquals(listener.created.getAndSet(0), 1); + + reactiveRepository + .createUpdate() + .set("name", "prepare-setNull-set") + .setNull("age") + .where("id", entity.getId()) + .execute() + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + reactiveRepository + .findById(entity.getId()) + .mapNotNull(EventTestEntity::getAge) + .as(StepVerifier::create) + .expectComplete() + .verify(); + + } + + @Test + public void testPrepareModify() { + EventTestEntity entity = EventTestEntity.of("prepare", 10); + reactiveRepository + .insert(entity) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + Assert.assertEquals(listener.created.getAndSet(0), 1); + + reactiveRepository + .createUpdate() + .set("name", "prepare-xx") + .set("age", 20) + .where("id", entity.getId()) + .execute() + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + reactiveRepository + .findById(entity.getId()) + .map(EventTestEntity::getName) + .as(StepVerifier::create) + .expectNext("prepare-0") + .verifyComplete(); + + } + + @Test + public void testUpdateNative() { + EventTestEntity entity = EventTestEntity.of("test-update-native", null); + reactiveRepository + .insert(entity) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + Assert.assertEquals(listener.created.getAndSet(0), 1); + + reactiveRepository + .createUpdate() + .set(EventTestEntity::getAge, NativeSql.of("coalesce(age+1,?)", 10)) + .where() + .is(entity::getName) + .execute() + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + Assert.assertEquals(1, listener.modified.getAndSet(0)); + + } + + @Test + public void testInsertBatch() { + reactiveRepository.createQuery() + .where(EventTestEntity::getId, "test") + .fetch() + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + Assert.assertEquals(listener.beforeQuery.getAndSet(0), 1); + + + Flux.just(EventTestEntity.of("test2", 1), EventTestEntity.of("test3", 2)) + .as(reactiveRepository::insert) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + Assert.assertEquals(listener.created.getAndSet(0), 2); + Assert.assertEquals(listener.beforeCreate.getAndSet(0), 2); + + reactiveRepository + .createUpdate().set("age", 3).where().in("name", "test2", "test3").execute() + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + Assert.assertEquals(listener.modified.getAndSet(0), 2); + Assert.assertEquals(listener.beforeModify.getAndSet(0), 2); + + reactiveRepository.createDelete().where().in("name", "test2", "test3").execute() + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + Assert.assertEquals(listener.deleted.getAndSet(0), 2); + Assert.assertEquals(listener.beforeDelete.getAndSet(0), 2); + + reactiveRepository.save(EventTestEntity.of("test2", 1)) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + Assert.assertEquals(listener.saved.getAndSet(0), 1); + Assert.assertEquals(listener.beforeSave.getAndSet(0), 1); + + + } + + @Test + @Ignore + public void testInsertError() { + Flux.just(EventTestEntity.of("test2", 1), EventTestEntity.of("test3", 2)) + .as(reactiveRepository::insert) + .flatMap(i -> Mono.error(new RuntimeException())) + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .verifyError(); + + Assert.assertEquals(listener.created.getAndSet(0), 0); + } + + + @Test + public void testDoNotFire() { + Mono.just(EventTestEntity.of("test", 1)) + .as(reactiveRepository::insert) + .as(EntityEventHelper::setDoNotFireEvent) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + Assert.assertEquals(listener.created.getAndSet(0), 0); + + + } + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/TestEntityListener.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/TestEntityListener.java new file mode 100644 index 000000000..33f097fa0 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/TestEntityListener.java @@ -0,0 +1,122 @@ +package org.hswebframework.web.crud.events; + +import org.hswebframework.web.crud.entity.EventTestEntity; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import javax.annotation.PostConstruct; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +public class TestEntityListener { + + AtomicInteger beforeCreate = new AtomicInteger(); + AtomicInteger beforeDelete = new AtomicInteger(); + AtomicInteger created = new AtomicInteger(); + AtomicInteger deleted = new AtomicInteger(); + + AtomicInteger modified = new AtomicInteger(); + AtomicInteger beforeModify = new AtomicInteger(); + + AtomicInteger saved = new AtomicInteger(); + AtomicInteger beforeSave = new AtomicInteger(); + AtomicInteger beforeQuery = new AtomicInteger(); + + void reset(){ + beforeCreate.set(0); + beforeDelete.set(0); + created.set(0); + deleted.set(0); + modified.set(0); + beforeModify.set(0); + saved.set(0); + beforeSave.set(0); + beforeQuery.set(0); + + } + + @EventListener + public void handleBeforeQuery(EntityBeforeQueryEvent event){ + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + beforeQuery.addAndGet(1); + })); + } + + @EventListener + public void handleBeforeSave(EntityBeforeSaveEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + beforeSave.addAndGet(event.getEntity().size()); + })); + } + + @EventListener + public void handleBeforeDelete(EntityBeforeModifyEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + beforeModify.addAndGet(event.getBefore().size()); + })); + } + + @EventListener + public void handleBeforeDelete(EntityBeforeDeleteEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + beforeDelete.addAndGet(event.getEntity().size()); + })); + } + + @EventListener + public void handleBeforeCreated(EntityBeforeCreateEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + beforeCreate.addAndGet(event.getEntity().size()); + })); + } + + @EventListener + public void handleCreated(EntityCreatedEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + created.addAndGet(event.getEntity().size()); + })); + } + + @EventListener + public void handleCreated(EntityDeletedEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + deleted.addAndGet(event.getEntity().size()); + })); + } + + @EventListener + public void handlePrepareModify(EntityPrepareModifyEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + for (EventTestEntity eventTestEntity : event.getAfter()) { + if(eventTestEntity.getName().equals("prepare-xx")){ + eventTestEntity.setName("prepare-0"); + eventTestEntity.setAge(null); + } + } + })); + } + @EventListener + public void handleModify(EntityModifyEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + modified.addAndGet(event.getAfter().size()); + })); + } + + @EventListener + public void handleSave(EntitySavedEvent event) { + event.async(Mono.fromRunnable(() -> { + System.out.println(event); + saved.addAndGet(event.getEntity().size()); + })); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/expr/SpelSqlExpressionInvokerTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/expr/SpelSqlExpressionInvokerTest.java new file mode 100644 index 000000000..3a9ea8172 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/events/expr/SpelSqlExpressionInvokerTest.java @@ -0,0 +1,76 @@ +package org.hswebframework.web.crud.events.expr; + +import org.hswebframework.ezorm.rdb.mapping.EntityColumnMapping; +import org.hswebframework.ezorm.rdb.operator.builder.fragments.NativeSql; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import reactor.function.Function3; + +import java.util.Collections; +import java.util.Map; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.*; + +class SpelSqlExpressionInvokerTest { + + + @Test + void test() { + SpelSqlExpressionInvoker invoker = new SpelSqlExpressionInvoker(); + + Function3, Object> func = invoker.compile("name + 1 + ?"); + + EntityColumnMapping mapping = Mockito.mock(EntityColumnMapping.class); + + assertEquals(13, func.apply(mapping, new Object[]{2}, Collections.singletonMap("name", 10))); + + } + + @Test + void testFunction() { + SpelSqlExpressionInvoker invoker = new SpelSqlExpressionInvoker(); + EntityColumnMapping mapping = Mockito.mock(EntityColumnMapping.class); + + Function3, Object> func = invoker.compile("coalesce(name,?)"); + + assertEquals(2, func.apply(mapping, new Object[]{2}, Collections.emptyMap())); + + assertEquals(3, func.apply(mapping, null, Collections.singletonMap("name", 3))); + + } + + @Test + void testAddNull(){ + SpelSqlExpressionInvoker invoker = new SpelSqlExpressionInvoker(); + EntityColumnMapping mapping = Mockito.mock(EntityColumnMapping.class); + + Function3, Object> func = invoker.compile("IFNULL(test,0) + ?"); + assertEquals(2, func.apply(mapping, new Object[]{2}, Collections.emptyMap())); + + } + + @Test + void testSnake() { + SpelSqlExpressionInvoker invoker = new SpelSqlExpressionInvoker(); + EntityColumnMapping mapping = Mockito.mock(EntityColumnMapping.class); + + { + Function3, Object> func = invoker.compile("count_value + ?"); + + assertEquals(12, func.apply(mapping,new Object[]{2}, Collections.singletonMap("countValue", 10))); + + } + { + Mockito.when(mapping.getPropertyByColumnName("_count_v")) + .thenReturn(java.util.Optional.of("countValue")); + Function3, Object> func = invoker.compile("_count_v + ?"); + + assertEquals(12, func.apply(mapping,new Object[]{2}, Collections.singletonMap("countValue", 10))); + + } + + + } + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/exception/DatabaseExceptionAnalyzerReporterTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/exception/DatabaseExceptionAnalyzerReporterTest.java new file mode 100644 index 000000000..54e4d84f6 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/exception/DatabaseExceptionAnalyzerReporterTest.java @@ -0,0 +1,52 @@ +package org.hswebframework.web.crud.exception; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.Assert.*; + +public class DatabaseExceptionAnalyzerReporterTest { + + DatabaseExceptionAnalyzerReporter reporter=new DatabaseExceptionAnalyzerReporter(); + + @Test + void testTimeout(){ + + Assertions.assertTrue(reporter.doReportException( + new IllegalStateException("Timeout on blocking read for 10000 MILLISECONDS") + )); + + } + + @Test + void testBinding(){ + Assertions.assertTrue(reporter.doReportException( + new IndexOutOfBoundsException("Binding index 0 when only 0 parameters are expected ") + )); + + Assertions.assertTrue(reporter.doReportException( + new IndexOutOfBoundsException("Binding parameters is not supported for simple statement") + )); + } + + @Test + void testUnknownDatabase(){ + Assertions.assertTrue(reporter.doReportException( + new IndexOutOfBoundsException("Unknown database 'jetlinks' ") + )); + } + + + @Test + void testPgsqlUnknownDatabase(){ + Assertions.assertTrue(reporter.doReportException( + new IndexOutOfBoundsException("[3D000] database \"jetlinks22\" does not exist") + )); + } + @Test + void testPgsqlUnknownSchema(){ + Assertions.assertTrue(reporter.doReportException( + new IndexOutOfBoundsException("[3F000] schema \"jetlinks22\" does not exist") + )); + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/DefaultQueryHelperTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/DefaultQueryHelperTest.java new file mode 100644 index 000000000..39b87d9a9 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/DefaultQueryHelperTest.java @@ -0,0 +1,400 @@ +package org.hswebframework.web.crud.query; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hswebframework.ezorm.core.param.Sort; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.SqlRequests; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.crud.entity.EventTestEntity; +import org.hswebframework.web.crud.entity.TestEntity; +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.*; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@RunWith(SpringJUnit4ClassRunner.class) +class DefaultQueryHelperTest { + + @Autowired + private DatabaseOperator database; + + + @Test + public void testLoadTable() { + database + .sql() + .reactive() + .execute(SqlRequests.of("create table \"NATIVE_TEST\"( " + + "\"id\" varchar(32) primary key" + + ",name varchar(32)" + + ",\"testName\" varchar(32)" + + ")")) + .as(StepVerifier::create) + .expectComplete() + .verify(); + + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database + .dml() + .insert("native_test") + .value("id", "test") + .value("NAME", "test") + .value("testName", "test") + .execute() + .sync(); + + helper.select("select id,name,testName from native_test") + .fetch() + .doOnNext(System.out::println) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void testPage() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database.dml() + .insert("s_test") + .value("id", "page-test") + .value("name", "page") + .value("age", 22) + .execute() + .sync(); + + database.dml() + .insert("s_test") + .value("id", "page-test2") + .value("name", "page") + .value("age", 22) + .execute() + .sync(); + + helper.select("select * from s_test") + .where(dsl -> { + dsl.doPaging(0, 1); + }) + .fetch() + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void testAgg() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database.dml() + .insert("s_test") + .value("id", "agg-test") + .value("name", "agg") + .value("age", 111) + .execute() + .sync(); + + helper.select("select sum(age) num from s_test t") + .where(dsl -> dsl.is("name", "agg")) + .fetch() + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void testGroup() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database.dml() + .insert("s_test") + .value("id", "group-test") + .value("name", "group") + .value("age", 31) + .execute() + .sync(); + + helper + .select("select name as \"name\",count(1) totalResult from s_test group by name having count(1) > ? ", GroupResult::new, 0) + .where(dsl -> dsl + .is("age", "31") + .orderByAsc(GroupResult::getTotalResult)) + .fetch() + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void testDistinct() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database.dml() + .insert("s_test") + .value("id", "distinct-test") + .value("name", "testDistinct") + .value("testName", "distinct") + .value("age", 33) + .execute() + .sync(); + + + helper.select("select distinct name from s_test ", 0) + .fetchPaged(0, 10) + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void testInner() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database.dml() + .insert("s_test") + .value("id", "inner-test") + .value("name", "inner") + .value("testName", "inner") + .value("age", 31) + .execute() + .sync(); + + + helper.select("select age,count(1) c from ( select *,'1' as x from s_test ) a group by age ", 0) + .where(dsl -> dsl + .is("x", "1") + .is("name", "inner") + .is("a.testName", "inner") + .is("age", 31)) + .fetchPaged(0, 10) + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + public void testJoinSubQuery() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + database.dml() + .insert("s_test") + .value("id", "join_sub") + .value("name", "join_sub") + .value("testName", "join_sub") + .value("age", 31) + .execute() + .sync(); + + helper + .select("select * from s_test t1 join (select * from s_test s where name = ? ) t2 on t2.id = t1.id ", "join_sub") + .fetch() + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Getter + @Setter + public static class GroupResult { + private String name; + private BigDecimal totalResult; + } + + @Test + public void testNative() { + database.dml() + .insert("s_test_event") + .value("id", "helper_testNative") + .value("name", "Ename2") + .execute() + .sync(); + + database.dml() + .insert("s_test") + .value("id", "helper_testNative") + .value("name", "main2") + .value("age", 20) + .execute() + .sync(); + + DefaultQueryHelper helper = new DefaultQueryHelper(database); + QueryParamEntity param = QueryParamEntity + .newQuery() + .is("e.id", "helper_testNative") + .is("t.age", "20") + .orderByAsc("t.age") + .getParam(); + + { + Sort sortByValue = new Sort(); + sortByValue.setName("t.id"); + sortByValue.setValue("1"); + param.getSorts().add(sortByValue); + } + { + Sort sortByValue = new Sort(); + sortByValue.setName("t.id"); + sortByValue.setValue("2"); + param.getSorts().add(sortByValue); + } + + + helper.select("select t.*,e.*,e.name ename,e.id `x.id` from s_test t " + + "left join s_test_event e on e.id = t.id " + + "where t.age = ?", 20) + .logger(LoggerFactory.getLogger("org.hswebframework.test.native")) + .where(param) + .fetchPaged() + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + helper.select("select id,name from s_test t " + + "union all select id,name from s_test_event") + .where(dsl -> dsl + .is("id", "helper_testNative") + .orderByAsc("name")) + .fetchPaged() + .doOnNext(v -> System.out.println(JSON.toJSONString(v, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + + } + + @Test + public void testCustomFirstPageIndex() { + DefaultQueryHelper helper = new DefaultQueryHelper(database); + QueryParamEntity e = new QueryParamEntity(); + e.and("id", "eq", "testCustomFirstPageIndex"); + e.setFirstPageIndex(1); + e.setPageIndex(2); + + { + helper.select(TestInfo.class) + .from(TestEntity.class) + .where(e) + .fetchPaged() + .doOnNext(info -> System.out.println(JSON.toJSONString(info, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextMatches(p -> p.getPageIndex() == 1) + .verifyComplete(); + } + + { + helper.select("select * from s_test") + .where(e) + .fetchPaged() + .doOnNext(info -> System.out.println(JSON.toJSONString(info, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextMatches(p -> p.getPageIndex() == 1) + .verifyComplete(); + } + } + + @Test + public void test() { + + database.dml() + .insert("s_test_event") + .value("id", "helper_test") + .value("name", "main") + .value("age", 10) + .execute() + .sync(); + + database.dml() + .insert("s_test") + .value("id", "helper_test") + .value("name", "main") + .value("testName", "testName") + .value("age", 10) + .execute() + .sync(); + + DefaultQueryHelper helper = new DefaultQueryHelper(database); + + helper.select(TestInfo.class) + // .all(EventTestEntity.class, TestInfo::setEventList) + .all("e2", TestInfo::setEvent) + .all(TestEntity.class) + .from(TestEntity.class) +// .leftJoin(EventTestEntity.class, +// join -> join +// .alias("e1") +// .is(EventTestEntity::getId, TestEntity::getId) +//// .is(EventTestEntity::getName, TestEntity::getId) +// .notNull(EventTestEntity::getAge)) + .leftJoin(EventTestEntity.class, + join -> join + .alias("e2") + .is(EventTestEntity::getId, TestEntity::getId) + .nest() + .is(EventTestEntity::getId,TestEntity::getId) + .is(EventTestEntity::getAge,10) + .end() + ) + .where(dsl -> dsl.is(EventTestEntity::getName, "Ename") + .is("e1.name", "Ename") + .orNest() + .is(TestEntity::getName, "main") + .is("e1.name", "Ename") + .end() + ) + .orderByAsc(TestEntity::getAge) + .orderByDesc(EventTestEntity::getAge) + .fetchPaged(0, 10) + .doOnNext(info -> System.out.println(JSON.toJSONString(info, SerializerFeature.PrettyFormat))) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + } + + @Getter + @Setter + @ToString + public static class TestInfo extends TestInfoSuper { + + private String id; + + private String name; + + private Integer age; + + private String testName; + + private EventTestEntity event; + + } + + @Getter + @Setter + public static class TestInfoSuper { + private List eventList; + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/QueryAnalyzerImplTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/QueryAnalyzerImplTest.java new file mode 100644 index 000000000..31498cd96 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/QueryAnalyzerImplTest.java @@ -0,0 +1,219 @@ +package org.hswebframework.web.crud.query; + +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrappers; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@RunWith(SpringJUnit4ClassRunner.class) +class QueryAnalyzerImplTest { + @Autowired + private DatabaseOperator database; + + + @Test + void testInject() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl(database, + "select count(distinct time) t2, \"name\" n from \"s_test\" t"); + SqlRequest request = analyzer.refactor( + QueryParamEntity + .newQuery() + .and("name", "123") + .getParam()); + + System.out.println(request); + + SqlRequest sql = analyzer.refactorCount( + QueryParamEntity + .newQuery() + .and("name", "123") + .getParam()); + System.out.println(sql); + + } + + + @Test + void testUnion() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl(database, + "select name n from s_test t " + + "union select name n from s_test t"); + + assertNotNull(analyzer.select().table.alias, "t"); + assertNotNull(analyzer.select().table.metadata.getName(), "s_test"); + + assertNotNull(analyzer.select().getColumns().get("n")); + + } + + @Test + void test() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl(database, + "select name n from s_test t"); + + assertNotNull(analyzer.select().table.alias, "t"); + assertNotNull(analyzer.select().table.metadata.getName(), "s_test"); + + assertNotNull(analyzer.select().getColumns().get("n")); + + + } + + @Test + void testSub() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl(database, + "select * from ( select distinct(name) as n from s_test ) t"); + + assertEquals(analyzer.select().table.alias, "t"); + + assertNotNull(analyzer.select().getColumns().get("n")); + + SqlRequest request = analyzer + .refactor(QueryParamEntity + .newQuery() + .where("n", "123") + .getParam()); + + System.out.println(request); + + database.sql() + .reactive() + .select(request, ResultWrappers.map()) + .as(StepVerifier::create) + .expectComplete() + .verify(); + } + + @Test + void testJoin() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select *,t2.c from s_test t " + + "left join (select z.id id, count(1) c from s_test z) t2 on t2.id = t.id"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity + .of() + .and("t2.c", "is", "xyz")); + + System.out.println(request); + + } + + @Test + void testPrepare() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select * from (select substring(id,9) id from s_test where left(id,1) = ?) t"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of(), 33); + + System.out.println(request); + } + + @Test + void testWith() { + + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "WITH RECURSIVE Tree AS (\n" + + "\n" + + " SELECT id\n" + + " FROM s_test\n" + + " WHERE id = ? \n" + + "\t\n" + + " UNION ALL\n" + + "\t\n" + + " SELECT ai.id\n" + + " FROM s_test AS ai\n" + + " INNER JOIN Tree AS tr ON ai.id = tr.id\n" + + ")\n" + + "SELECT t1.id\n" + + "FROM Tree AS t1;"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("id", "eq", "test"), 1); + + System.out.println(request); + } + + @Test + void testTableFunction() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select t.key from json_each_text('{\"name\":\"test\"}') t"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("key", "like", "test%"), 1); + System.out.println(request); + } + + @Test + void testTableFunctionJoin() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select t1.*,t2.key from s_test t1 left join json_each_text('{\"name\":\"test\"}') t2 on t2.key='test' and t2.value='test1'"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("t2.key", "like", "test%"), 1); + System.out.println(request); + } + + @Test + void testValues() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select * from (values (1,2),(3,4)) t(\"a\",b)"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("a", "eq", 1), 1); + System.out.println(request); + } + + @Test + void testLateralSubSelect() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select * from s_test t, lateral(select * from s_test where id = t.id) t2"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("t2.id", "eq", "test"), 1); + System.out.println(request); + } + + @Test + void testParenthesisFrom() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select * from (s_test) t"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("t.id", "eq", "test"), 1); + System.out.println(request); + } + + + @Test + void testDistinct() { + QueryAnalyzerImpl analyzer = new QueryAnalyzerImpl( + database, + "select distinct upper(t.id) v from s_test t group by t.name"); + + SqlRequest request = analyzer + .refactor(QueryParamEntity.of().and("t.id", "eq", "test"), 1); + + System.out.println(request); + + System.out.println(analyzer.refactorCount(QueryParamEntity.of())); + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/QueryHelperUtilsTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/QueryHelperUtilsTest.java new file mode 100644 index 000000000..3d875c0b3 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/query/QueryHelperUtilsTest.java @@ -0,0 +1,42 @@ +package org.hswebframework.web.crud.query; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class QueryHelperUtilsTest { + + + @Test + void testToHump(){ + + assertEquals("testName",QueryHelperUtils.toHump("test_name")); + + + assertEquals("ruownum_",QueryHelperUtils.toHump("RUOWNUM_")); + + } + + @Test + void testToSnake(){ + + assertEquals("test_name",QueryHelperUtils.toSnake("testName")); + + assertEquals("test_name",QueryHelperUtils.toSnake("TestName")); + + + + } + + + @Test + void testLegal(){ + + assertTrue(QueryHelperUtils.isLegalColumn("test_name")); + assertFalse(QueryHelperUtils.isLegalColumn("test-name")); + + assertFalse(QueryHelperUtils.isLegalColumn("test\nname")); + + + } +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/CustomTestCustom.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/CustomTestCustom.java new file mode 100644 index 000000000..69ba5cddb --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/CustomTestCustom.java @@ -0,0 +1,48 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.metadata.DataType; +import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; +import org.hswebframework.web.crud.configuration.TableMetadataCustomizer; +import org.hswebframework.web.crud.entity.CustomTestEntity; +import org.hswebframework.web.crud.entity.TestEntity; +import org.hswebframework.web.crud.entity.factory.EntityMappingCustomizer; +import org.hswebframework.web.crud.entity.factory.MapperEntityFactory; +import org.springframework.stereotype.Component; + +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.sql.JDBCType; +import java.util.Set; + +@Component +public class CustomTestCustom implements EntityMappingCustomizer, TableMetadataCustomizer { + @Override + public void custom(MapperEntityFactory factory) { + factory.addMapping(TestEntity.class, new MapperEntityFactory.Mapper<>(CustomTestEntity.class, CustomTestEntity::new)); + } + + @Override + public void customColumn(Class entityType, + PropertyDescriptor descriptor, + Field field, + Set annotations, + RDBColumnMetadata column) { + + } + + @Override + public void customTable(Class entityType, RDBTableMetadata table) { + if (TestEntity.class.isAssignableFrom(entityType)) { + + RDBColumnMetadata col = table.newColumn(); + col.setName("ext_name"); + col.setAlias("extName"); + col.setLength(32); + col.setType(DataType.jdbc(JDBCType.VARCHAR, String.class)); + table.addColumn(col); + + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java new file mode 100644 index 000000000..d13c9bed5 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java @@ -0,0 +1,126 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestEntity; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@SpringBootTest(classes = TestApplication.class, args = "--hsweb.cache.type=guava") +@RunWith(SpringRunner.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +public class GenericReactiveCacheSupportCrudServiceTest { + + @Autowired + private TestCacheEntityService entityService; + + @Test + public void test() { + + TestEntity entity = TestEntity.of("test2",100,"testName"); + + entityService.insert(Mono.just(entity)) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + entityService.findById(Mono.just(entity.getId())) + .map(TestEntity::getId) + .as(StepVerifier::create) + .expectNext(entity.getId()) + .verifyComplete(); + + entityService.getCache() + .getMono("id:".concat(entity.getId())) + .map(TestEntity::getId) + .as(StepVerifier::create) + .expectNext(entity.getId()) + .verifyComplete(); + + entityService.createUpdate() + .set("age",120) + .where("id",entity.getId()) + .execute() + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + entityService.getCache() + .getMono("id:".concat(entity.getId())) + .switchIfEmpty(Mono.error(NullPointerException::new)) + .as(StepVerifier::create) + .expectError(NullPointerException.class) + .verify(); + + + } + + @Test + public void test2() { + + TestEntity entity = TestEntity.of("test1",100,"testName"); + + entityService + .createDelete() + .notNull(TestEntity::getId) + .execute() + .block(); + + entityService + .insert(Mono.just(entity)) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + entityService + .getCacheAll() + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + entity.setAge(120); + entityService + .updateById(entity.getId(), entity) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + entityService + .getCacheAll() + .switchIfEmpty(Mono.error(NullPointerException::new)) + .as(StepVerifier::create) + .expectNextMatches(t -> t.getAge().equals(120)) + .verifyComplete(); + + entity.setId(null); + entityService + .insert(Mono.just(entity)) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + entityService + .getCacheAll() + .as(StepVerifier::create) + .expectNextCount(2) + .verifyComplete(); + + entityService + .deleteById(entity.getId()) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + entityService + .getCacheAll() + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityServiceTest.java new file mode 100644 index 000000000..4d0f262cf --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityServiceTest.java @@ -0,0 +1,301 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.core.param.QueryParam; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.exception.ValidationException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +@SpringBootTest +@RunWith(SpringJUnit4ClassRunner.class) +public class ReactiveTreeSortEntityServiceTest { + + @Autowired + private TestTreeSortEntityService sortEntityService; + + + @Test + public void testCreateDefaultId() { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setName("Simple-test"); + + sortEntityService + .insert(Mono.just(entity)) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + } + + @Test + public void testCrud() { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setId("Crud-test"); + entity.setName("Crud-test"); + + TestTreeSortEntity entity2 = new TestTreeSortEntity(); + entity2.setName("Crud-test2"); + + entity.setChildren(Arrays.asList(entity2)); + + sortEntityService.insert(Mono.just(entity)) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + sortEntityService.save(Mono.just(entity)) + .map(SaveResult::getTotal) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + sortEntityService.queryResultToTree(QueryParamEntity.of().and("id", "like", "Crud-%")) + .map(List::size) + .as(StepVerifier::create) + .expectNext(1) + .verifyComplete(); + + sortEntityService.queryIncludeParent(Arrays.asList(entity2.getId())) + .as(StepVerifier::create) + .expectNextCount(2) + .verifyComplete(); + + + sortEntityService.deleteById(Mono.just(entity.getId())) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + } + + @Test + public void testChangeParent() { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setId("test_p1"); + entity.setName("test1"); + + TestTreeSortEntity entity_0 = new TestTreeSortEntity(); + entity_0.setId("test_p0"); + entity_0.setName("test0"); + + TestTreeSortEntity entity2 = new TestTreeSortEntity(); + entity2.setId("test_p2"); + entity2.setName("test2"); + entity2.setParentId(entity.getId()); + + TestTreeSortEntity entity3 = new TestTreeSortEntity(); + entity3.setId("test_p3"); + entity3.setName("test3"); + entity3.setParentId(entity2.getId()); + + sortEntityService + .save(Arrays.asList(entity, entity_0, entity2, entity3)) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + entity2.setChildren(null); + entity2.setParentId(entity_0.getId()); + + sortEntityService + .save(Arrays.asList(entity2)) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + sortEntityService + .queryIncludeChildren(Arrays.asList(entity_0.getId())) + .as(StepVerifier::create) + .expectNextCount(3) + .verifyComplete(); + + } + + @Test + public void testSave() { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setId("test_path"); + entity.setName("test-path"); + + sortEntityService + .save(entity) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + String firstPath = entity.getPath(); + assertNotNull(firstPath); + entity.setPath(null); + + sortEntityService + .save(entity) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + sortEntityService + .findById(entity.getId()) + .map(TestTreeSortEntity::getPath) + .as(StepVerifier::create) + .expectNext(firstPath) + .verifyComplete(); + } + + @Test + public void testNotExistParentId() { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setId("NotExistParentIdTest"); + entity.setName("NotExistParentIdTest"); + entity.setParentId("NotExistParentId"); + + sortEntityService + .insert(entity) + .then() + .as(StepVerifier::create) + .expectError(ValidationException.class) + .verify(); + + TestTreeSortEntity entity2 = new TestTreeSortEntity(); + entity2.setId("NotExistParentId"); + entity2.setName("NotExistParentId"); + + sortEntityService + .save(Flux.just(entity, entity2)) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + } + + + @Test + public void testCyclicDependency() { + + TestTreeSortEntity root = new TestTreeSortEntity(); + root.setId("testCyclicDependency-root"); + root.setName("testCyclicDependency"); + + + TestTreeSortEntity node1 = new TestTreeSortEntity(); + node1.setId("testCyclicDependency-node1"); + node1.setName("testCyclicDependency-node1"); + node1.setParentId(root.getId()); + + root.setParentId(node1.getId()); + sortEntityService + .insert(Flux.just(root, node1)) + .as(StepVerifier::create) + .expectErrorMatches(err -> err.getMessage().contains("tree_entity_cyclic_dependency")) + .verify(); + + root.setParentId(null); + root.setChildren(null); + node1.setChildren(null); + + sortEntityService + .insert(Flux.just(root, node1)) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + root.setParentId(node1.getId()); + root.setChildren(null); + node1.setChildren(null); + + sortEntityService + .save(Flux.just(root)) + .as(StepVerifier::create) + .expectErrorMatches(err -> err.getMessage().contains("tree_entity_cyclic_dependency")) + .verify(); + } + + + @Test + public void testDelete() { + TestTreeSortEntity root = new TestTreeSortEntity(); + root.setId("delete-root"); + root.setName("deleteRoot"); + + + TestTreeSortEntity node1 = new TestTreeSortEntity(); + node1.setId("delete-node1"); + node1.setName("delete-node1"); + node1.setParentId(root.getId()); + + sortEntityService + .save(Flux.just(root, node1)) + .map(SaveResult::getTotal) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + sortEntityService + .createDelete() + .where(TestTreeSortEntity::getId, "delete-root") + .execute() + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + sortEntityService + .save(Flux.just(root, node1)) + .map(SaveResult::getTotal) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + sortEntityService + .deleteById(root.getId()) + .as(StepVerifier::create) + .expectNext(2) + .verifyComplete(); + + } + + @Test + public void testChild() { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setId("ChildQuery"); + entity.setName("ChildQuery"); + + TestTreeSortEntity entity2 = new TestTreeSortEntity(); + entity2.setId("ChildQuery2"); + entity2.setName("ChildQuery2"); + entity2.setParentId(entity.getId()); + + TestTreeSortEntity entity3 = new TestTreeSortEntity(); + entity3.setId("ChildQuery3"); + entity3.setName("ChildQuery3"); + + + sortEntityService + .save(Flux.just(entity, entity2, entity3)) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + sortEntityService + .createQuery() + .accept("id", "test-child", entity.getId()) + .fetch() + .as(StepVerifier::create) + .expectNextCount(2) + .verifyComplete(); + } + +} \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestCacheEntityService.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestCacheEntityService.java new file mode 100644 index 000000000..d7d6403eb --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestCacheEntityService.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.entity.TestEntity; +import org.springframework.stereotype.Service; + +@Service +public class TestCacheEntityService extends GenericReactiveCacheSupportCrudService { + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestEntityService.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestEntityService.java new file mode 100644 index 000000000..e5e24711d --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestEntityService.java @@ -0,0 +1,29 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.entity.TestEntity; +import org.hswebframework.web.crud.events.EntityBeforeModifyEvent; +import org.hswebframework.web.crud.events.EntityCreatedEvent; +import org.hswebframework.web.crud.events.EntityPrepareModifyEvent; +import org.hswebframework.web.id.IDGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +public class TestEntityService extends GenericReactiveCrudService { + + + @EventListener + public void handleEvent(EntityCreatedEvent event){ + + System.out.println(event.getEntity()); + } + + + @EventListener + public void listener(EntityPrepareModifyEvent event){ + System.out.println(event); + event.async(Mono.empty()); + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestTreeChildTermBuilder.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestTreeChildTermBuilder.java new file mode 100644 index 000000000..f234c6f94 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestTreeChildTermBuilder.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.sql.terms.TreeChildTermBuilder; +import org.springframework.stereotype.Component; + +@Component +public class TestTreeChildTermBuilder extends TreeChildTermBuilder { + public TestTreeChildTermBuilder() { + super("test-child", "测试子节点"); + } + + @Override + protected String tableName() { + return "test_tree_sort"; + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestTreeSortEntityService.java new file mode 100644 index 000000000..7b187ffd5 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TestTreeSortEntityService.java @@ -0,0 +1,29 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.id.IDGenerator; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class TestTreeSortEntityService extends GenericReactiveCrudService + implements ReactiveTreeSortEntityService { + + @Override + public IDGenerator getIDGenerator() { + return IDGenerator.MD5; + } + + @Override + public void setChildren(TestTreeSortEntity entity, List children) { + entity.setChildren(children); + } + + @Override + public List getChildren(TestTreeSortEntity entity) { + return entity.getChildren(); + } + + +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/resources/application.yml b/hsweb-commons/hsweb-commons-crud/src/test/resources/application.yml new file mode 100644 index 000000000..02694136e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/resources/application.yml @@ -0,0 +1,11 @@ +logging: + level: + org.hswebframework: debug + org.springframework.transaction: debug + org.springframework.data.r2dbc.connectionfactory: debug +#spring: +# r2dbc: +# +easyorm: + default-schema: PUBLIC + dialect: h2 \ No newline at end of file diff --git a/hsweb-commons/pom.xml b/hsweb-commons/pom.xml new file mode 100644 index 000000000..7ebea61e5 --- /dev/null +++ b/hsweb-commons/pom.xml @@ -0,0 +1,39 @@ + + + + + + hsweb-framework + org.hswebframework.web + 4.0.19-SNAPSHOT + ../pom.xml + + 4.0.0 + 通用模块 + + hsweb-commons + pom + + hsweb-commons-crud + hsweb-commons-api + + + \ No newline at end of file diff --git a/hsweb-concurrent/hsweb-concurrent-cache/pom.xml b/hsweb-concurrent/hsweb-concurrent-cache/pom.xml new file mode 100644 index 000000000..0089af942 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/pom.xml @@ -0,0 +1,67 @@ + + + + hsweb-concurrent + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-concurrent-cache + + + + + org.springframework + spring-aspects + + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework.data + spring-data-redis + true + + + + + com.github.ben-manes.caffeine + caffeine + 2.8.0 + true + + + + com.google.guava + guava + true + + + + io.projectreactor.addons + reactor-extra + + + + org.springframework + spring-test + test + + + org.springframework.boot + spring-boot-test + test + + + org.springframework.boot + spring-boot-starter-data-redis + test + + + \ No newline at end of file diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCache.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCache.java new file mode 100644 index 000000000..dafa7b648 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCache.java @@ -0,0 +1,63 @@ +package org.hswebframework.web.cache; + +import org.reactivestreams.Publisher; +import reactor.cache.CacheFlux; +import reactor.cache.CacheMono; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collection; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * 响应式缓存 + * + * @param 缓存元素类型 + */ +public interface ReactiveCache { + + Flux getFlux(Object key); + + Flux getFlux(Object key, Supplier> loader); + + Mono getMono(Object key); + + Mono getMono(Object key, Supplier> loader); + + Mono put(Object key, Publisher data); + + Mono evict(Object key); + + Flux getAll(Object... keys); + + Mono evictAll(Iterable key); + + Mono clear(); + + /** + * @deprecated https://github.com/reactor/reactor-addons/issues/237 + */ + @Deprecated + default CacheFlux.FluxCacheBuilderMapMiss flux(Object key) { + return otherSupplier -> Flux + .defer(() -> this + .getFlux(key) + .switchIfEmpty(otherSupplier.get() + .collectList() + .flatMapMany(values -> put(key, Flux.fromIterable(values)) + .thenMany(Flux.fromIterable(values))))); + } + + /** + * @deprecated https://github.com/reactor/reactor-addons/issues/237 + */ + @Deprecated + default CacheMono.MonoCacheBuilderMapMiss mono(Object key) { + return otherSupplier -> Mono + .defer(() -> this + .getMono(key) + .switchIfEmpty(otherSupplier.get() + .flatMap(value -> put(key, Mono.just(value)).thenReturn(value)))); + } +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCacheManager.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCacheManager.java new file mode 100644 index 000000000..fe11f5bca --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCacheManager.java @@ -0,0 +1,6 @@ +package org.hswebframework.web.cache; + +public interface ReactiveCacheManager { + + ReactiveCache getCache(String name); +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCacheResolver.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCacheResolver.java new file mode 100644 index 000000000..648ebcf9b --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/ReactiveCacheResolver.java @@ -0,0 +1,11 @@ +package org.hswebframework.web.cache; + +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheOperationInvocationContext; + +import java.util.Collection; + +public interface ReactiveCacheResolver { + Collection resolveCaches(CacheOperationInvocationContext context); + +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/configuration/ReactiveCacheManagerConfiguration.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/configuration/ReactiveCacheManagerConfiguration.java new file mode 100644 index 000000000..5b764fa74 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/configuration/ReactiveCacheManagerConfiguration.java @@ -0,0 +1,24 @@ +package org.hswebframework.web.cache.configuration; + +import org.hswebframework.web.cache.ReactiveCacheManager; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnMissingBean(ReactiveCacheManager.class) +@EnableConfigurationProperties(ReactiveCacheProperties.class) +public class ReactiveCacheManagerConfiguration { + + + @Bean + public ReactiveCacheManager reactiveCacheManager(ReactiveCacheProperties properties, ApplicationContext context) { + + return properties.createCacheManager(context); + + } + + +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/configuration/ReactiveCacheProperties.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/configuration/ReactiveCacheProperties.java new file mode 100644 index 000000000..dca180203 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/configuration/ReactiveCacheProperties.java @@ -0,0 +1,169 @@ +package org.hswebframework.web.cache.configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.cache.CacheBuilder; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.cache.ReactiveCache; +import org.hswebframework.web.cache.ReactiveCacheManager; +import org.hswebframework.web.cache.supports.CaffeineReactiveCacheManager; +import org.hswebframework.web.cache.supports.GuavaReactiveCacheManager; +import org.hswebframework.web.cache.supports.RedisLocalReactiveCacheManager; +import org.hswebframework.web.cache.supports.UnSupportedReactiveCache; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "hsweb.cache") +@Getter +@Setter +public class ReactiveCacheProperties { + + + private Type type = Type.none; + + private GuavaProperties guava = new GuavaProperties(); + + private CaffeineProperties caffeine = new CaffeineProperties(); + + private RedisProperties redis = new RedisProperties(); + + + public boolean anyProviderPresent() { + return ClassUtils.isPresent("com.google.common.cache.Cache", this.getClass().getClassLoader()) + || ClassUtils.isPresent("com.github.benmanes.caffeine.cache.Cache", this.getClass().getClassLoader()) + || ClassUtils.isPresent("org.springframework.data.redis.core.ReactiveRedisOperations", this.getClass().getClassLoader()); + } + + + private ReactiveCacheManager createUnsupported() { + return new ReactiveCacheManager() { + @Override + public ReactiveCache getCache(String name) { + return UnSupportedReactiveCache.getInstance(); + } + }; + } + + @SuppressWarnings("all") + public ReactiveCacheManager createCacheManager(ApplicationContext context) { + if (!anyProviderPresent()) { + return new ReactiveCacheManager() { + @Override + public ReactiveCache getCache(String name) { + return UnSupportedReactiveCache.getInstance(); + } + }; + } + + if (type == Type.redis) { + ReactiveRedisOperations operations; + if (StringUtils.hasText(redis.getBeanName())) { + operations = context.getBean(redis.getBeanName(), ReactiveRedisOperations.class); + } else { + operations = (ReactiveRedisOperations) context.getBeanProvider(ResolvableType.forClassWithGenerics(ReactiveRedisOperations.class, Object.class, Object.class)).getIfAvailable(); + } + return new RedisLocalReactiveCacheManager(operations, createCacheManager(redis.localCacheType)); + } + + return createCacheManager(type); + } + + private ReactiveCacheManager createCacheManager(Type type) { + switch (type) { + case guava: + return getGuava().createCacheManager(); + case caffeine: + return getCaffeine().createCacheManager(); + + } + return createUnsupported(); + } + + + @Getter + @Setter + public static class RedisProperties { + private String beanName; + + private Type localCacheType = Type.caffeine; + + } + + @Getter + @Setter + public static class GuavaProperties { + long maximumSize = 1024; + int initialCapacity = 64; + Duration expireAfterWrite = Duration.ofHours(6); + Duration expireAfterAccess = Duration.ofHours(1); + Strength keyStrength = Strength.SOFT; + Strength valueStrength = Strength.SOFT; + + ReactiveCacheManager createCacheManager() { + return new GuavaReactiveCacheManager(createBuilder()); + } + + CacheBuilder createBuilder() { + CacheBuilder builder = CacheBuilder.newBuilder() + .expireAfterAccess(expireAfterAccess) + .expireAfterWrite(expireAfterWrite) + .maximumSize(maximumSize); + if (valueStrength == Strength.SOFT) { + builder.softValues(); + } else { + builder.weakValues(); + } + if (keyStrength == Strength.WEAK) { + builder.weakKeys(); + } + return builder; + } + } + + @Getter + @Setter + public static class CaffeineProperties { + long maximumSize = 1024; + int initialCapacity = 64; + Duration expireAfterWrite = Duration.ofHours(6); + Duration expireAfterAccess = Duration.ofHours(1); + Strength keyStrength = Strength.SOFT; + Strength valueStrength = Strength.SOFT; + + ReactiveCacheManager createCacheManager() { + return new CaffeineReactiveCacheManager(createBuilder()); + } + + Caffeine createBuilder() { + Caffeine builder = Caffeine.newBuilder() + .expireAfterAccess(expireAfterAccess) + .expireAfterWrite(expireAfterWrite) + .maximumSize(maximumSize); + if (valueStrength == Strength.SOFT) { + builder.softValues(); + } else { + builder.weakValues(); + } + if (keyStrength == Strength.WEAK) { + builder.weakKeys(); + } + return builder; + } + } + + enum Strength {WEAK, SOFT} + + public enum Type { + redis, + caffeine, + guava, + none, + } + +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/AbstractReactiveCache.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/AbstractReactiveCache.java new file mode 100644 index 000000000..5bceac178 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/AbstractReactiveCache.java @@ -0,0 +1,175 @@ +package org.hswebframework.web.cache.supports; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.cache.ReactiveCache; +import org.reactivestreams.Publisher; +import reactor.core.CoreSubscriber; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoOperator; +import reactor.core.publisher.Sinks; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +@Slf4j +public abstract class AbstractReactiveCache implements ReactiveCache { + static final Sinks.EmitFailureHandler emitFailureHandler = Sinks.EmitFailureHandler.busyLooping(Duration.ofSeconds(30)); + + private final Map cacheLoading = new ConcurrentHashMap<>(); + + protected static class CacheLoader extends MonoOperator { + + private final AbstractReactiveCache parent; + + private final Object key; + private Mono defaultValue; + + private final Sinks.One holder = Sinks.one(); + + private volatile Disposable loading; + + protected CacheLoader(AbstractReactiveCache parent, Object key, Mono source) { + super(source.cache()); + this.parent = parent; + this.key = key; + } + + protected void defaultValue(Mono defaultValue, ContextView context) { + if (this.defaultValue != null) { + return; + } + this.defaultValue = defaultValue; + tryLoad(context); + } + + + @SuppressWarnings("all") + private void tryLoad(ContextView context) { + if (holder.currentSubscriberCount() == 1 && loading == null) { + Mono source = this.source; + if (defaultValue != null) { + source = source + .switchIfEmpty((Mono) defaultValue + .flatMap(val -> { + return parent.putNow(key, val).thenReturn(val); + })); + } + loading = source.subscribe( + val -> { + complete(); + holder.emitValue(val, emitFailureHandler); + }, + err -> { + complete(); + holder.emitError(err, emitFailureHandler); + }, + () -> { + complete(); + holder.emitEmpty(emitFailureHandler); + }, + Context.of(context)); + } + } + + @Override + public void subscribe(CoreSubscriber actual) { + holder.asMono().subscribe(actual); + tryLoad(actual.currentContext()); + } + + private void complete() { + parent.cacheLoading.remove(key, this); + } + + + } + + protected abstract Mono getNow(Object key); + + public abstract Mono putNow(Object key, Object value); + + @Override + @SuppressWarnings("all") + public final Mono getMono(Object key) { + return (Mono) cacheLoading + .computeIfAbsent(key, _key -> new CacheLoader(this, _key, getNow(_key))) + .onErrorResume(err -> handleLoaderError(key, err)); + } + + @Override + @SuppressWarnings("all") + public final Mono getMono(Object key, Supplier> loader) { + + return Mono + .deferContextual(ctx -> { + CacheLoader cacheLoader = cacheLoading.compute(key, (_key, old) -> { + CacheLoader cl = new CacheLoader(this, _key, getNow(_key)); + cl.defaultValue(loader.get(), ctx); + return cl; + }); + return (Mono) cacheLoader; + }) + .onErrorResume(err -> handleLoaderError(key, err)); + } + + + @Override + public final Flux getFlux(Object key) { + return (cacheLoading.computeIfAbsent(key, _key -> new CacheLoader(this, _key, getNow(_key)))) + .flatMapIterable(e -> ((List) e)) + .onErrorResume(err -> handleLoaderError(key, err)); + } + + @Override + public final Flux getFlux(Object key, Supplier> loader) { + return Flux.deferContextual(ctx -> { + CacheLoader cacheLoader = cacheLoading.compute(key, (_key, old) -> { + CacheLoader cl = new CacheLoader(this, _key, getNow(_key)); + cl.defaultValue(loader.get().collectList(), ctx); + return cl; + }); + return cacheLoader.flatMapIterable(e -> ((List) e)); + }) + .onErrorResume(err -> handleLoaderError(key, err)); + } + + protected Mono handleLoaderError(Object key, Throwable err) { + log.warn("load cache error,key:{},evict it.", key, err); + return evict(key) + .then(Mono.empty()); + } + + @Override + public final Mono put(Object key, Publisher data) { + + if (data instanceof Mono) { + return Mono.from(data) + .flatMap(e -> putNow(key, e)); + } + return Flux.from(data) + .collectList() + .flatMap(e -> putNow(key, e)); + } + + @Override + public abstract Mono evict(Object key); + + @Override + public Flux getAll(Object... keys) { + return Flux.just(keys) + .flatMap(this::getMono); + } + + @Override + public abstract Mono evictAll(Iterable key); + + @Override + public abstract Mono clear(); +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/AbstractReactiveCacheManager.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/AbstractReactiveCacheManager.java new file mode 100644 index 000000000..73c658ab5 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/AbstractReactiveCacheManager.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.cache.supports; + +import org.hswebframework.web.cache.ReactiveCache; +import org.hswebframework.web.cache.ReactiveCacheManager; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public abstract class AbstractReactiveCacheManager implements ReactiveCacheManager { + private Map caches = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("all") + public ReactiveCache getCache(String name) { + return caches.computeIfAbsent(name, this::createCache); + } + + protected abstract ReactiveCache createCache(String name); +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/CaffeineReactiveCache.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/CaffeineReactiveCache.java new file mode 100644 index 000000000..84b425213 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/CaffeineReactiveCache.java @@ -0,0 +1,56 @@ +package org.hswebframework.web.cache.supports; + +import com.github.benmanes.caffeine.cache.Cache; +import lombok.AllArgsConstructor; +import org.hswebframework.web.cache.ReactiveCache; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.Collection; + +@SuppressWarnings("all") +@AllArgsConstructor +public class CaffeineReactiveCache extends AbstractReactiveCache { + + private Cache cache; + + @Override + public Mono evictAll(Iterable key) { + return Mono.fromRunnable(() -> cache.invalidateAll(key)); + } + + @Override + public Flux getAll(Object... keys) { + return Flux.defer(() -> { + if (keys == null || keys.length == 0) { + return Flux.fromIterable(cache.asMap().values()) + .map(e -> (E) e); + } + return Flux.fromIterable(cache.getAllPresent(Arrays.asList(keys)).values()) + .map(e -> (E) e); + }); + } + + @Override + protected Mono getNow(Object key) { + return Mono.justOrEmpty(cache.getIfPresent(key)); + } + + @Override + public Mono putNow(Object key, Object value) { + cache.put(key, value); + return Mono.empty(); + } + + @Override + public Mono evict(Object key) { + return Mono.fromRunnable(() -> cache.invalidate(key)); + } + + @Override + public Mono clear() { + return Mono.fromRunnable(() -> cache.invalidateAll()); + } +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/CaffeineReactiveCacheManager.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/CaffeineReactiveCacheManager.java new file mode 100644 index 000000000..32bbc3f0d --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/CaffeineReactiveCacheManager.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.cache.supports; + +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.AllArgsConstructor; +import org.hswebframework.web.cache.ReactiveCache; + +import java.time.Duration; + +@AllArgsConstructor +public class CaffeineReactiveCacheManager extends AbstractReactiveCacheManager { + + private Caffeine builder; + + + @Override + protected ReactiveCache createCache(String name) { + return new CaffeineReactiveCache<>(builder.build()); + } + +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/GuavaReactiveCache.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/GuavaReactiveCache.java new file mode 100644 index 000000000..fa4245283 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/GuavaReactiveCache.java @@ -0,0 +1,56 @@ +package org.hswebframework.web.cache.supports; + +import com.google.common.cache.Cache; +import lombok.AllArgsConstructor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Arrays; + +@SuppressWarnings("all") +@AllArgsConstructor +public class GuavaReactiveCache extends AbstractReactiveCache { + + private Cache cache; + + + @Override + public Mono evictAll(Iterable key) { + return Mono.fromRunnable(() -> cache.invalidateAll(key)); + } + + @Override + protected Mono getNow(Object key) { + return Mono.justOrEmpty(cache.getIfPresent(key)); + } + + @Override + public Mono putNow(Object key, Object value) { + cache.put(key, value); + return Mono.empty(); + } + + @Override + public Mono evict(Object key) { + return Mono.fromRunnable(() -> cache.invalidate(key)); + } + + @Override + public Flux getAll(Object... keys) { + return Flux.defer(() -> { + if (keys == null || keys.length == 0) { + return Flux + .fromIterable(cache.asMap().values()) + .map(e -> (E) e); + } + return Flux.fromIterable(cache.getAllPresent(Arrays.asList(keys)).values()) + .map(e -> (E) e); + }); + } + + + @Override + public Mono clear() { + return Mono.fromRunnable(() -> cache.invalidateAll()); + } +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/GuavaReactiveCacheManager.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/GuavaReactiveCacheManager.java new file mode 100644 index 000000000..7897ca1ce --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/GuavaReactiveCacheManager.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.cache.supports; + +import com.google.common.cache.CacheBuilder; +import lombok.AllArgsConstructor; +import org.hswebframework.web.cache.ReactiveCache; + +import java.time.Duration; + +@AllArgsConstructor +public class GuavaReactiveCacheManager extends AbstractReactiveCacheManager { + + private CacheBuilder builder; + + @Override + protected ReactiveCache createCache(String name) { + return new GuavaReactiveCache<>(builder.build()); + } + +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/NullValue.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/NullValue.java new file mode 100644 index 000000000..9d7d2cc32 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/NullValue.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.cache.supports; + +import java.io.Serializable; + +public class NullValue implements Serializable { + private static final long serialVersionUID = -1; + + public static final NullValue INSTANCE = new NullValue(); + +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/RedisLocalReactiveCacheManager.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/RedisLocalReactiveCacheManager.java new file mode 100644 index 000000000..d9aa251f2 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/RedisLocalReactiveCacheManager.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.cache.supports; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.cache.ReactiveCache; +import org.hswebframework.web.cache.ReactiveCacheManager; +import org.springframework.data.redis.core.ReactiveRedisOperations; + +public class RedisLocalReactiveCacheManager extends AbstractReactiveCacheManager { + + private ReactiveRedisOperations operations; + + private ReactiveCacheManager localCacheManager; + + public RedisLocalReactiveCacheManager(ReactiveRedisOperations operations, ReactiveCacheManager localCacheManager) { + this.operations = operations; + this.localCacheManager = localCacheManager; + } + + @Setter + @Getter + private String redisCachePrefix = "spring-cache:"; + + @Override + protected ReactiveCache createCache(String name) { + return new RedisReactiveCache<>(redisCachePrefix.concat(name), operations, localCacheManager.getCache(name)); + } +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/RedisReactiveCache.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/RedisReactiveCache.java new file mode 100644 index 000000000..c72c4dda0 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/RedisReactiveCache.java @@ -0,0 +1,119 @@ +package org.hswebframework.web.cache.supports; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.cache.ReactiveCache; +import org.reactivestreams.Publisher; +import org.springframework.data.redis.connection.ReactiveSubscription; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.function.Function; +import java.util.stream.StreamSupport; + +@SuppressWarnings("all") +@Slf4j +public class RedisReactiveCache extends AbstractReactiveCache { + + private ReactiveRedisOperations operations; + + private String redisKey; + + private ReactiveCache localCache; + + private String topicName; + + public RedisReactiveCache(String redisKey, ReactiveRedisOperations operations, ReactiveCache localCache) { + this.operations = operations; + this.localCache = localCache; + this.redisKey = redisKey; + operations + .listenToChannel(topicName = ("_cache_changed:" + redisKey)) + .map(ReactiveSubscription.Message::getMessage) + .cast(String.class) + .subscribe(s -> { + if (s.equals("___all")) { + localCache.clear().subscribe(); + return; + } + //清空本地缓存 + localCache.evict(s).subscribe(); + }); + } + + @Override + protected Mono getNow(Object key) { + return (Mono) localCache.getMono(key, () -> (Mono) operations.opsForHash().get(redisKey, key)); + } + + @Override + public Mono putNow(Object key, Object value) { + return operations + .opsForHash() + .put(redisKey, key, value) + .then(localCache.evict(key)) + .then(operations.convertAndSend(topicName, key)) + .then(); + } + + + protected Mono handleError(Throwable error) { + log.error(error.getMessage(), error); + return Mono.empty(); + } + + @Override + public Mono evictAll(Iterable key) { + return operations + .opsForHash() + .remove(redisKey, StreamSupport.stream(key.spliterator(), false).toArray()) + .then(localCache.evictAll(key)) + .flatMap(nil -> Flux + .fromIterable(key) + .flatMap(k -> operations.convertAndSend(topicName, key)) + .then()) + .onErrorResume(err -> this.handleError(err)); + } + + @Override + public Flux getAll(Object... keys) { + if (keys == null || keys.length == 0) { + return operations + .opsForHash() + .values(redisKey) + .map(r -> (E) r); + } + return operations + .opsForHash() + .multiGet(redisKey, Arrays.asList(keys)) + .flatMapIterable(Function.identity()) + .map(r -> (E) r) + .onErrorResume(err -> this.handleError(err)); + } + + + @Override + public Mono evict(Object key) { + return operations + .opsForHash() + .remove(redisKey, key) + .then(localCache.evict(key)) + .then(operations.convertAndSend(topicName, key)) + .onErrorResume(err -> this.handleError(err)) + .then(); + } + + @Override + public Mono clear() { + return operations + .opsForHash() + .delete(redisKey) + .then(localCache.clear()) + .then(operations.convertAndSend(topicName, "___all")) + .onErrorResume(err -> this.handleError(err)) + .then(); + } +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/UnSupportedReactiveCache.java b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/UnSupportedReactiveCache.java new file mode 100644 index 000000000..f1d9f230c --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/java/org/hswebframework/web/cache/supports/UnSupportedReactiveCache.java @@ -0,0 +1,79 @@ +package org.hswebframework.web.cache.supports; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hswebframework.web.cache.ReactiveCache; +import org.reactivestreams.Publisher; +import reactor.cache.CacheFlux; +import reactor.cache.CacheMono; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collection; +import java.util.function.Supplier; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UnSupportedReactiveCache implements ReactiveCache { + + private static final UnSupportedReactiveCache INSTANCE = new UnSupportedReactiveCache<>(); + + @SuppressWarnings("all") + public static ReactiveCache getInstance() { + return (UnSupportedReactiveCache) INSTANCE; + } + + @Override + public Flux getFlux(Object key, Supplier> loader) { + return loader.get(); + } + + @Override + public Mono getMono(Object key, Supplier> loader) { + return loader.get(); + } + + @Override + public Flux getFlux(Object key) { + return Flux.empty(); + } + + @Override + public Mono getMono(Object key) { + return Mono.empty(); + } + + @Override + public Mono put(Object key, Publisher data) { + return Mono.empty(); + } + + @Override + public Mono evict(Object key) { + return Mono.empty(); + } + + @Override + public Mono evictAll(Iterable key) { + return Mono.empty(); + } + + @Override + public Flux getAll(Object... keys) { + return Flux.empty(); + } + + @Override + public Mono clear() { + return Mono.empty(); + } + + @Override + public CacheMono.MonoCacheBuilderMapMiss mono(Object key) { + return Supplier::get; + } + + @Override + public CacheFlux.FluxCacheBuilderMapMiss flux(Object key) { + return Supplier::get; + } +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/hsweb-concurrent/hsweb-concurrent-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..459a3e025 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.hswebframework.web.cache.configuration.ReactiveCacheManagerConfiguration \ No newline at end of file diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/CaffeineReactiveCacheManagerTest.java b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/CaffeineReactiveCacheManagerTest.java new file mode 100644 index 000000000..68464b161 --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/CaffeineReactiveCacheManagerTest.java @@ -0,0 +1,70 @@ +package org.hswebframework.web.cache; + +import org.hswebframework.web.cache.supports.CaffeineReactiveCacheManager; +import org.hswebframework.web.cache.supports.GuavaReactiveCacheManager; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + + +@SpringBootTest(classes = TestApplication.class,args = { + "--hsweb.cache.type=caffeine" +}) +@RunWith(SpringRunner.class) +@DirtiesContext +public class CaffeineReactiveCacheManagerTest { + + @Autowired + ReactiveCacheManager cacheManager; + + @Test + public void test(){ + Assert.assertNotNull(cacheManager); + Assert.assertTrue(cacheManager instanceof CaffeineReactiveCacheManager); + + ReactiveCache cache= cacheManager.getCache("test"); + cache.clear() + .as(StepVerifier::create) + .verifyComplete(); + + cache.flux("test-flux") + .onCacheMissResume(Flux.just("1","2","3")) + .as(StepVerifier::create) + .expectNext("1","2","3") + .verifyComplete(); + + cache.put("test-flux",Flux.just("3","2","1")) + .as(StepVerifier::create) + .verifyComplete(); + + cache.getFlux("test-flux") + .as(StepVerifier::create) + .expectNext("3","2","1") + .verifyComplete(); + + + cache.mono("test-mono") + .onCacheMissResume(Mono.just("1")) + .as(StepVerifier::create) + .expectNext("1") + .verifyComplete(); + + cache.put("test-mono",Mono.just("2")) + .as(StepVerifier::create) + .verifyComplete(); + + cache.getMono("test-mono") + .as(StepVerifier::create) + .expectNext("2") + .verifyComplete(); + + + } +} \ No newline at end of file diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/GuavaReactiveCacheManagerTest.java b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/GuavaReactiveCacheManagerTest.java new file mode 100644 index 000000000..7f6ceb36c --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/GuavaReactiveCacheManagerTest.java @@ -0,0 +1,70 @@ +package org.hswebframework.web.cache; + +import org.hswebframework.web.cache.supports.GuavaReactiveCacheManager; +import org.hswebframework.web.cache.supports.RedisLocalReactiveCacheManager; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + + +@SpringBootTest(classes = TestApplication.class,args = { + "--hsweb.cache.type=guava" +}) +@RunWith(SpringRunner.class) +@DirtiesContext +public class GuavaReactiveCacheManagerTest { + + @Autowired + ReactiveCacheManager cacheManager; + + @Test + public void test(){ + Assert.assertNotNull(cacheManager); + Assert.assertTrue(cacheManager instanceof GuavaReactiveCacheManager); + + ReactiveCache cache= cacheManager.getCache("test"); + cache.clear() + .as(StepVerifier::create) + .verifyComplete(); + + cache.flux("test-flux") + .onCacheMissResume(Flux.just("1","2","3")) + .as(StepVerifier::create) + .expectNext("1","2","3") + .verifyComplete(); + + cache.put("test-flux",Flux.just("3","2","1")) + .as(StepVerifier::create) + .verifyComplete(); + + cache.getFlux("test-flux") + .as(StepVerifier::create) + .expectNext("3","2","1") + .verifyComplete(); + + + cache.mono("test-mono") + .onCacheMissResume(Mono.just("1")) + .as(StepVerifier::create) + .expectNext("1") + .verifyComplete(); + + cache.put("test-mono",Mono.just("2")) + .as(StepVerifier::create) + .verifyComplete(); + + cache.getMono("test-mono") + .as(StepVerifier::create) + .expectNext("2") + .verifyComplete(); + + + } +} \ No newline at end of file diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/RedisReactiveCacheManagerTest.java b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/RedisReactiveCacheManagerTest.java new file mode 100644 index 000000000..34ee8177c --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/RedisReactiveCacheManagerTest.java @@ -0,0 +1,72 @@ +package org.hswebframework.web.cache; + +import org.hswebframework.web.cache.supports.RedisLocalReactiveCacheManager; +import org.hswebframework.web.cache.supports.RedisReactiveCache; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.junit4.rules.SpringClassRule; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.Assert.*; + + +@SpringBootTest(classes = TestApplication.class, args = { + "--hsweb.cache.type=redis" +}) +@RunWith(SpringRunner.class) +@DirtiesContext +public class RedisReactiveCacheManagerTest { + + @Autowired + ReactiveCacheManager cacheManager; + + @Test + public void test() { + Assert.assertNotNull(cacheManager); + Assert.assertTrue(cacheManager instanceof RedisLocalReactiveCacheManager); + + ReactiveCache cache = cacheManager.getCache("test"); + cache.clear() + .as(StepVerifier::create) + .verifyComplete(); + + cache.getFlux("test-flux", () -> Flux.just("1", "2", "3")) + .as(StepVerifier::create) + .expectNext("1", "2", "3") + .verifyComplete(); + + cache.put("test-flux", Flux.just("3", "2", "1")) + .as(StepVerifier::create) + .verifyComplete(); + + cache.getFlux("test-flux") + .as(StepVerifier::create) + .expectNext("3", "2", "1") + .verifyComplete(); + + + cache.getMono("test-mono", () -> Mono.just("1")) + .as(StepVerifier::create) + .expectNext("1") + .verifyComplete(); + + cache.put("test-mono", Mono.just("2")) + .as(StepVerifier::create) + .verifyComplete(); + + cache.getMono("test-mono") + .as(StepVerifier::create) + .expectNext("2") + .verifyComplete(); + + + } +} \ No newline at end of file diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/TestApplication.java b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/TestApplication.java new file mode 100644 index 000000000..f58192cad --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/test/java/org/hswebframework/web/cache/TestApplication.java @@ -0,0 +1,7 @@ +package org.hswebframework.web.cache; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestApplication { +} diff --git a/hsweb-concurrent/hsweb-concurrent-cache/src/test/resources/application-redis.yml b/hsweb-concurrent/hsweb-concurrent-cache/src/test/resources/application-redis.yml new file mode 100644 index 000000000..0aae69f2b --- /dev/null +++ b/hsweb-concurrent/hsweb-concurrent-cache/src/test/resources/application-redis.yml @@ -0,0 +1,5 @@ +hsweb: + cache: + redis: + local-cache-type: caffeine + type: redis \ No newline at end of file diff --git a/hsweb-concurrent/pom.xml b/hsweb-concurrent/pom.xml new file mode 100644 index 000000000..522b84b97 --- /dev/null +++ b/hsweb-concurrent/pom.xml @@ -0,0 +1,19 @@ + + + + hsweb-framework + org.hswebframework.web + 4.0.19-SNAPSHOT + + 4.0.0 + + hsweb-concurrent + pom + + hsweb-concurrent-cache + + + + \ No newline at end of file diff --git a/hsweb-core/README.md b/hsweb-core/README.md new file mode 100644 index 000000000..c5723914b --- /dev/null +++ b/hsweb-core/README.md @@ -0,0 +1,83 @@ +# 系统核心,通用工具等 + + +### bean 复制工具 +`FastBeanCopier`类. 提供高效的bean复制.支持复杂结构,类型转换,集合泛型,支持bean到map,map到bean的复制. + +原理: 使用工具类`Proxy`,通过`javassist`去动态构造一个类,通过原生的方式调用get set方法.而不是通过低效的反射. + +```java + //将source对象中的属性复制到target中. + FastBeanCopier.copy(source,target); + + //将source对象中的属性复制到target中.不复制id字段 + FastBeanCopier.copy(source,target,"id"); + +``` +约定: 如果属性类实现了`Cloneable`接口,在复制的时候将调用`clone`方法.所以如果你实现了`Cloneable`接口,就必须重写`clone`方法并且为`public`修饰的. + +### 数据字典 + +可通过枚举来定义数据字典,定义一个枚举,并实现`EnumDict`接口: +```java +@AllArgsConstructor +@Getter +@Dict(id="data-status") //定义一个id,默认为 DataStatusEnum.class.getSimpleName(); +public enum DataStatusEnum implements EnumDict { + ENABLED((byte) 1, "正常"), + DISABLED((byte) 0, "禁用"), + LOCK((byte) -1, "锁定"), + DELETED((byte) -10, "删除"); + + private Byte value; + + private String text; +} +``` + +在实体类中使用: +```java +@Data +public class User { + private String id; + + //单选 + private DataStatusEnum status; + + //多选 + private DataStatusEnum[] statusArr; +} +``` + +作用: +1. 当值为单选,在持久化到数据库时,将自动存储字典的value值. 因此数据库字段的类型应该与value字段的类型一致. +2. 当值为多选,并且枚举选项数量小于`64`个,则会将值进行位运算(`EnumDict.toBit`)后存储.在查询的时候也使用位运算进行查询. +因此数据库字段的类型应该为数字类型。 +如: `where().in("statusArr",0,-1);` 则将生成sql : `where status_arr & {bit} != {bit}` 。 +在java中可以通过`EnumDict`中的静态方法进行判断,如 `in` 和 `anyIn`. +3. 当枚举选项数量大于等于`64`个的时候,需要自行实现存储和查询逻辑,可以使用中间表的方式,也可以使用hsweb自带的实现,模块:`hsweb-system/hsweb-system-dictionary`。 + +注意: 1,2的功能由`hsweb-commons-dao`模块去实现,如果你不没有使用hsweb自带的dao实现,可能无法使用此功能. + +所有的字典都会注册到:`DictDefineRepository`,可通过此类去获取字典,以提供给前端或者其他地方使用. + +## ToString +``org.hswebframework.web.bean.ToString``提供了对Bean转为String的功能.包括字段脱敏(打码). + +```java + +@lombok.Getter +@lombok.Setter +public class MyEntity{ + + //敏感字段,在ToString的时候会给字段打码.比如: 185*****234 + @org.hswebframework.web.bean.ToString.Ignore + private String userPhone; + + public String toString(){ + return org.hswebframework.web.bean.ToString.toString(this); + } +} + +``` + diff --git a/hsweb-core/pom.xml b/hsweb-core/pom.xml new file mode 100644 index 000000000..7e9fb7dc3 --- /dev/null +++ b/hsweb-core/pom.xml @@ -0,0 +1,146 @@ + + + + hsweb-framework + org.hswebframework.web + 4.0.19-SNAPSHOT + ../pom.xml + + 4.0.0 + + hsweb-core + + 核心包 + + + + org.javassist + javassist + ${javassist.version} + + + + com.fasterxml.jackson.core + jackson-databind + + + + org.hswebframework + hsweb-utils + + + + org.springframework + spring-context + + + + org.springframework + spring-web + + + + org.springframework + spring-webflux + true + + + + org.slf4j + slf4j-api + + + + commons-beanutils + commons-beanutils + + + + javax.validation + validation-api + + + + com.alibaba + fastjson + + + + org.springframework + spring-aspects + + + + io.projectreactor + reactor-core + + + + io.swagger.core.v3 + swagger-annotations + + + + javax.servlet + javax.servlet-api + provided + + + + org.hswebframework + hsweb-expands-script + ${hsweb.expands.version} + true + + + + org.glassfish + jakarta.el + + + + org.hibernate.validator + hibernate-validator + + + + io.projectreactor.addons + reactor-extra + + + + com.google.guava + guava + + + + jctools-core + org.jctools + 4.0.1 + + + + io.netty + netty-common + + + + io.seruco.encoding + base62 + 0.1.3 + + + + org.apache.commons + commons-collections4 + + + + org.hswebframework + hsweb-easy-orm-core + + + + \ No newline at end of file diff --git a/hsweb-core/src/main/java/org/hswebframework/web/CodeConstants.java b/hsweb-core/src/main/java/org/hswebframework/web/CodeConstants.java new file mode 100644 index 000000000..388b5df05 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/CodeConstants.java @@ -0,0 +1,17 @@ +package org.hswebframework.web; + +public interface CodeConstants { + + interface Error { + String illegal_argument = "illegal_argument"; + + String timeout = "timeout"; + + String unsupported = "unsupported"; + + String unauthorized = "unauthorized"; + + String not_found="not_found"; + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/aop/MethodInterceptorContext.java b/hsweb-core/src/main/java/org/hswebframework/web/aop/MethodInterceptorContext.java new file mode 100644 index 000000000..2f4496aa2 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/aop/MethodInterceptorContext.java @@ -0,0 +1,88 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.aop; + +import org.reactivestreams.Publisher; + +import java.io.Serializable; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * AOP拦截到方法的参数上下文,用于获取当前进行操作的方法的各种参数信息,如:当前所在类实例,参数集合,注解 + * + * @author zhouhao + * @since 3.0 + */ +public interface MethodInterceptorContext extends Serializable { + + /** + * 获取当前类实例 + * + * @return 类实例对象 + */ + Object getTarget(); + + /** + * 当前操作的方法 + * + * @return 方法实例 + */ + Method getMethod(); + + /** + * 根据参数名获取参数值,此参数为方法的参数,而非http参数
+ * 如:当前被操作的方法为 query(QueryParam param); 调用getParameter("param"); 则返回QueryParam实例
+ * 注意:返回值为Optional对象,使用方法见{@link Optional}
+ * + * @param name 参数名称 + * @param 参数泛型 + * @return Optional + */ + Optional getArgument(String name); + + /** + * 获取当前操作方法或实例上指定类型的泛型,如果方法上未获取到,则获取实例类上的注解。实例类上未获取到,则返回null + * + * @param type 注解的类型 + * @param 注解泛型 + * @return 注解 + */ + T getAnnotation(Class type); + + /** + * 获取全部参数 + * + * @return 参数集合 + * @see MethodInterceptorContext#getArgument(String) + */ + Map getNamedArguments(); + + Object[] getArguments(); + + boolean handleReactiveArguments(Function, Publisher> handler); + + Object getInvokeResult(); + + void setInvokeResult(Object result); + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/aop/MethodInterceptorHolder.java b/hsweb-core/src/main/java/org/hswebframework/web/aop/MethodInterceptorHolder.java new file mode 100644 index 000000000..184775977 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/aop/MethodInterceptorHolder.java @@ -0,0 +1,185 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.aop; + +import com.google.common.collect.Maps; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.aopalliance.intercept.MethodInvocation; +import org.hswebframework.web.utils.AnnotationUtils; +import org.hswebframework.web.utils.DigestUtils; +import org.reactivestreams.Publisher; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * @author zhouhao + */ +@AllArgsConstructor +@Getter +public class MethodInterceptorHolder { + /** + * 参数名称获取器,用于获取方法参数的名称 + */ + public static final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer(); + + public static MethodInterceptorHolder create(MethodInvocation invocation) { + String[] argNames = nameDiscoverer.getParameterNames(invocation.getMethod()); + Object[] args = invocation.getArguments(); + + String[] names; + //参数名与参数长度不一致,则填充argx来作为参数名 + if (argNames == null || argNames.length != args.length) { + names = new String[args.length]; + for (int i = 0, len = args.length; i < len; i++) { + names[i] = (argNames == null || argNames.length <= i || argNames[i] == null) ? "arg" + i : argNames[i]; + } + } else { + names = argNames; + } + return new MethodInterceptorHolder(null, + invocation.getMethod(), + invocation.getThis(), + args, + names, + null); + } + + private String id; + + private final Method method; + + private final Object target; + + private final Object[] arguments; + + private final String[] argumentsNames; + + private Map namedArguments; + + public String getId() { + if (id == null) { + id = DigestUtils.md5Hex(method.toString()); + } + return id; + } + + protected Map createNamedArguments() { + Map namedArguments = Maps.newLinkedHashMapWithExpectedSize(arguments.length); + for (int i = 0, len = arguments.length; i < len; i++) { + namedArguments.put(argumentsNames[i], arguments[i]); + } + return namedArguments; + + } + + public Map getNamedArguments() { + return namedArguments == null ? namedArguments = createNamedArguments() : namedArguments; + } + + public T findMethodAnnotation(Class annClass) { + return AnnotationUtils.findMethodAnnotation(annClass, method, annClass); + } + + public T findClassAnnotation(Class annClass) { + return AnnotationUtils.findAnnotation(target.getClass(), annClass); + } + + public T findAnnotation(Class annClass) { + return AnnotationUtils.findAnnotation(target.getClass(), method, annClass); + } + + public MethodInterceptorContext createParamContext() { + return createParamContext(null); + } + + public MethodInterceptorContext createParamContext(Object invokeResult) { + return new MethodInterceptorContext() { + private static final long serialVersionUID = -4102787561601219273L; + private Object result = invokeResult; + + @Override + public Object[] getArguments() { + return arguments; + } + + public boolean handleReactiveArguments(Function, Publisher> handler) { + boolean handled = false; + Object[] args = getArguments(); + if (args == null || args.length == 0) { + return false; + } + for (int i = 0; i < args.length; i++) { + Object arg = args[i]; + if (arg instanceof Publisher) { + args[i] = handler.apply(((Publisher) arg)); + handled = true; + } + } + + return handled; + } + + + @Override + public Object getTarget() { + return target; + } + + @Override + public Method getMethod() { + return method; + } + + @Override + public Optional getArgument(String name) { + if (namedArguments == null) { + return Optional.empty(); + } + return Optional.ofNullable((T) namedArguments.get(name)); + } + + @Override + public T getAnnotation(Class annClass) { + return findAnnotation(annClass); + } + + @Override + public Map getNamedArguments() { + return MethodInterceptorHolder.this.getNamedArguments(); + } + + @Override + public Object getInvokeResult() { + return result; + } + + @Override + public void setInvokeResult(Object result) { + this.result = result; + } + }; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/BeanFactory.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/BeanFactory.java new file mode 100644 index 000000000..db22e66b5 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/BeanFactory.java @@ -0,0 +1,6 @@ +package org.hswebframework.web.bean; + +public interface BeanFactory { + + T newInstance(Class beanType); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ClassDescription.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ClassDescription.java new file mode 100644 index 000000000..29ce001f3 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ClassDescription.java @@ -0,0 +1,45 @@ +package org.hswebframework.web.bean; + +import lombok.Getter; +import org.hswebframework.web.dict.EnumDict; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +public class ClassDescription { + private final Class type; + + private final boolean collectionType; + private final boolean arrayType; + private final boolean enumType; + private final boolean enumDict; + private final int fieldSize; + private final boolean number; + + private final Object[] enums; + private final Map fields; + + public ClassDescription(Class type) { + this.type = type; + collectionType = Collection.class.isAssignableFrom(type); + enumDict = EnumDict.class.isAssignableFrom(type); + arrayType = type.isArray(); + enumType = type.isEnum(); + fieldSize = type.getDeclaredFields().length; + number = Number.class.isAssignableFrom(type); + if (enumType) { + enums = type.getEnumConstants(); + } else { + enums = null; + } + fields = Arrays + .stream(type.getDeclaredFields()) + .collect(Collectors.toMap(Field::getName, f -> f, (a, b) -> b)); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ClassDescriptions.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ClassDescriptions.java new file mode 100644 index 000000000..731a50b41 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ClassDescriptions.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.bean; + + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class ClassDescriptions { + + private static final Map, ClassDescription> CACHE = new ConcurrentHashMap<>(); + + public static ClassDescription getDescription(Class type) { + return CACHE.computeIfAbsent(type, ClassDescription::new); + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/CompareUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/CompareUtils.java new file mode 100644 index 000000000..85e2d807e --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/CompareUtils.java @@ -0,0 +1,278 @@ +package org.hswebframework.web.bean; + +import org.hswebframework.utils.time.DateFormatter; +import org.hswebframework.web.dict.EnumDict; + +import java.math.BigDecimal; +import java.util.*; + +@SuppressWarnings("all") +public abstract class CompareUtils { + + public static boolean compare(Object source, Object target) { + if (source == target) { + return true; + } + + if (source == null || target == null) { + return false; + } + + if (source.equals(target)) { + return true; + } + + if (source instanceof Boolean) { + return compare(((Boolean) source), target); + } + if (source instanceof Number) { + return compare(((Number) source), target); + } + if (target instanceof Number) { + return compare(((Number) target), source); + } + + if (source instanceof Date) { + return compare(((Date) source), target); + } + + if (target instanceof Date) { + return compare(((Date) target), source); + } + + if (source instanceof String) { + return compare(((String) source), target); + } + + if (target instanceof String) { + return compare(((String) target), source); + } + if (source instanceof Collection) { + return compare(((Collection) source), target); + } + + if (target instanceof Collection) { + return compare(((Collection) target), source); + } + + if (source instanceof Map) { + return compare(((Map) source), target); + } + + if (target instanceof Map) { + return compare(((Map) target), source); + } + + if (source.getClass().isEnum() || source instanceof Enum) { + return compare(((Enum) source), target); + } + + if (target.getClass().isEnum() || source instanceof Enum) { + return compare(((Enum) target), source); + } + + if (source.getClass().isArray()) { + return compare(((Object[]) source), target); + } + + if (target.getClass().isArray()) { + return compare(((Object[]) target), source); + } + + + return compare(FastBeanCopier.copy(source, HashMap.class), FastBeanCopier.copy(target, HashMap.class)); + + } + + public static boolean compare(Map map, Object target) { + if (map == target) { + return true; + } + + if (map == null || target == null) { + return false; + } + Map targetMap = null; + if (target instanceof Map) { + targetMap = ((Map) target); + } else { + targetMap = FastBeanCopier.copy(target, HashMap::new); + } + + if (map.size() != targetMap.size()) { + return false; + } + for (Map.Entry entry : map.entrySet()) { + if (!compare(entry.getValue(), targetMap.get(entry.getKey()))) { + return false; + } + } + + return true; + } + + + public static boolean compare(Collection collection, Object target) { + if (collection == target) { + return true; + } + + if (collection == null || target == null) { + return false; + } + Collection targetCollection = null; + if (target instanceof String) { + target = ((String) target).split("[, ;]"); + } + if (target instanceof Collection) { + targetCollection = ((Collection) target); + } else if (target.getClass().isArray()) { + targetCollection = Arrays.asList(((Object[]) target)); + } + if (targetCollection == null) { + return false; + } + + Set left = new HashSet(collection); + Set right = new HashSet(targetCollection); + + if (left.size() < right.size()) { + Set tmp = right; + right = left; + left = tmp; + } + l: + for (Object source : left) { + if (!right.stream().anyMatch(targetObj -> compare(source, targetObj))) { + return false; + } + } + return true; + } + + public static boolean compare(Object[] number, Object target) { + + + return compare(Arrays.asList(number), target); + } + + + public static boolean compare(Number number, Object target) { + if (number == target) { + return true; + } + + if (number == null || target == null) { + return false; + } + + if (target.equals(number)) { + return true; + } + if (target instanceof Number) { + return number.doubleValue() == ((Number) target).doubleValue(); + } + if (target instanceof Date) { + return number.longValue() == ((Date) target).getTime(); + } + if (target instanceof String) { + //日期格式的字符串? + String stringValue = String.valueOf(target); + DateFormatter dateFormatter = DateFormatter.getFormatter(stringValue); + if (dateFormatter != null) { + //格式化为相同格式的字符串进行对比 + return (dateFormatter.toString(new Date(number.longValue())).equals(stringValue)); + } + try { + return new BigDecimal(stringValue).doubleValue() == number.doubleValue(); + } catch (NumberFormatException e) { + return false; + } + } + + return false; + } + + public static boolean compare(Enum e, Object target) { + if (e == target) { + return true; + } + + if (e == null || target == null) { + return false; + } + String stringValue = String.valueOf(target); + if (e instanceof EnumDict) { + EnumDict dict = ((EnumDict) e); + return e.name().equalsIgnoreCase(stringValue) || dict.eq(target); + } + + return e.name().equalsIgnoreCase(stringValue); + } + + public static boolean compare(String string, Object target) { + if (string == target) { + return true; + } + + if (string == null || target == null) { + return false; + } + if (string.equals(String.valueOf(target))) { + return true; + } + + if (target instanceof Enum) { + return compare(((Enum) target), string); + } + + if (target instanceof Date) { + return compare(((Date) target), string); + } + + if (target instanceof Number) { + return compare(((Number) target), string); + } + if (target instanceof Collection) { + return compare(((Collection) target), string); + } + + return false; + } + + public static boolean compare(Boolean bool, Object target) { + return bool.equals(target) || String.valueOf(bool).equals(target); + } + + + public static boolean compare(Date date, Object target) { + if (date == target) { + return true; + } + + if (date == null || target == null) { + return false; + } + if (target instanceof Date) { + return date.getTime() == ((Date) target).getTime(); + } + + if (target instanceof String) { + //日期格式的字符串? + String stringValue = String.valueOf(target); + DateFormatter dateFormatter = DateFormatter.getFormatter(stringValue); + if (dateFormatter != null) { + //格式化为相同格式的字符串进行对比 + return (dateFormatter.toString(date).equals(stringValue)); + } + } + + if (target instanceof Number) { + long longValue = ((Number) target).longValue(); + return date.getTime() == longValue; + } + + return false; + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/Converter.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/Converter.java new file mode 100644 index 000000000..31df30e88 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/Converter.java @@ -0,0 +1,6 @@ +package org.hswebframework.web.bean; + +@FunctionalInterface +public interface Converter { + T convert(Object source, Class targetClass,Class[] genericType); +} \ No newline at end of file diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/Copier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/Copier.java new file mode 100644 index 000000000..34ae21536 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/Copier.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.bean; + +import com.google.common.collect.Sets; +import reactor.core.Disposable; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public interface Copier extends Disposable { + void copy(Object source, Object target, Set ignore, Converter converter); + + default void copy(Object source, Object target, String... ignore) { + copy(source, target, Sets.newHashSet(ignore), FastBeanCopier.DEFAULT_CONVERT); + } + + @Override + default void dispose() { + + } + +} + diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/DefaultToStringOperator.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/DefaultToStringOperator.java new file mode 100644 index 000000000..9ee75955a --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/DefaultToStringOperator.java @@ -0,0 +1,343 @@ +package org.hswebframework.web.bean; + +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.utils.time.DateFormatter; +import org.springframework.beans.BeanUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ReflectionUtils; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Field; +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static org.hswebframework.web.bean.ToString.Feature.coverIgnoreProperty; +import static org.hswebframework.web.bean.ToString.Feature.disableNestProperty; +import static org.hswebframework.web.bean.ToString.Feature.nullPropertyToEmpty; + +/** + * @author zhouhao + * @since 3.0.0-RC + */ +@Slf4j +public class DefaultToStringOperator implements ToStringOperator { + + private final PropertyDescriptor[] descriptors; + + private Set defaultIgnoreProperties; + + private long defaultFeatures = ToString.DEFAULT_FEATURE; + + private Map descriptorMap; + + private Map> converts; + + private final Function coverStringConvert = (o) -> coverString(String.valueOf(o), 80); + + private final Function, BiFunction> simpleConvertBuilder = type -> { + if (Date.class.isAssignableFrom(type)) { + return (value, f) -> DateFormatter.toString(((Date) value), "yyyy-MM-dd HH:mm:ss"); + } else { + return (value, f) -> value; + } + }; + + private final Predicate> simpleTypePredicate = ((Predicate>) String.class::isAssignableFrom) + .or(Class::isEnum) + .or(Class::isPrimitive) + .or(Date.class::isAssignableFrom) + .or(Number.class::isAssignableFrom) + .or(Boolean.class::isAssignableFrom); + + private final Class targetType; + + public DefaultToStringOperator(Class targetType) { + this.targetType = targetType; + descriptors = BeanUtils.getPropertyDescriptors(targetType); + init(); + } + + public static String coverString(String str, double percent) { + if (str.length() == 1) { + return "*"; + } + + if (percent > 1) { + percent = percent / 100d; + } + percent = 1 - percent; + long size = Math.round(str.length() * percent); + + long end = (str.length() - size / 2); + + long start = str.length() - end; + start = start == 0 && percent > 0 ? 1 : start; + char[] chars = str.toCharArray(); + for (int i = 0; i < chars.length; i++) { + if (i >= start && i <= end - 1) { + chars[i] = '*'; + } + } + return new String(chars); + } + + @SuppressWarnings("all") + protected void init() { + converts = new HashMap<>(); + descriptorMap = Arrays.stream(descriptors).collect(Collectors.toMap(PropertyDescriptor::getName, Function.identity())); + //获取类上的注解 + ToString.Ignore classIgnore = AnnotationUtils.getAnnotation(targetType, ToString.Ignore.class); + ToString.Features features = AnnotationUtils.getAnnotation(targetType, ToString.Features.class); + if (null != features && features.value().length > 0) { + defaultFeatures = ToString.Feature.createFeatures(features.value()); + } else { + defaultFeatures = ToString.DEFAULT_FEATURE; + } + defaultIgnoreProperties = classIgnore == null ? + new HashSet<>(new java.util.HashSet<>()) + : new HashSet<>(Arrays.asList(classIgnore.value())); + + //是否打码 + boolean defaultCover = classIgnore != null && classIgnore.cover(); + + for (PropertyDescriptor descriptor : descriptors) { + if ("class".equals(descriptor.getName())) { + continue; + } + Class propertyType = descriptor.getPropertyType(); + String propertyName = descriptor.getName(); + BiFunction convert; + ToString.Ignore propertyIgnore = null; + long propertyFeature = 0; + try { + Field field = ReflectionUtils.findField(targetType, descriptor.getName()); + propertyIgnore = field.getAnnotation(ToString.Ignore.class); + features = AnnotationUtils.getAnnotation(field, ToString.Features.class); + if (propertyIgnore != null) { + for (String val : propertyIgnore.value()) { + defaultIgnoreProperties.add(field.getName().concat(".").concat(val)); + } + } + if (null != features && features.value().length > 0) { + propertyFeature = ToString.Feature.createFeatures(features.value()); + } + } catch (Exception ignore) { + } + //是否设置了打码 + boolean cover = (propertyIgnore == null && defaultCover) || (propertyIgnore != null && propertyIgnore.cover()); + //是否注解了ignore + boolean hide = propertyIgnore != null; + + long finalPropertyFeature = propertyFeature; + + if (simpleTypePredicate.test(propertyType)) { + BiFunction simpleConvert = simpleConvertBuilder.apply(propertyType); + convert = (value, f) -> { + long feature = finalPropertyFeature == 0 ? f.features : finalPropertyFeature; + + value = simpleConvert.apply(value, f); + if (hide || f.ignoreProperty.contains(propertyName)) { + if (ToString.Feature.hasFeature(feature, ToString.Feature.coverIgnoreProperty)) { + return coverStringConvert.apply(value); + } else { + return null; + } + } + return value; + }; + + } else { + boolean toStringOverride = false; + try { + toStringOverride = propertyType.getMethod("toString").getDeclaringClass() != Object.class; + } catch (NoSuchMethodException ignore) { + } + boolean finalToStringOverride = toStringOverride; + boolean justReturn = propertyType.isArray() + || Collection.class.isAssignableFrom(propertyType) + || Map.class.isAssignableFrom(propertyType); + + convert = (value, f) -> { + if (f.ignoreProperty.contains(propertyName)) { + return null; + } + long feature = finalPropertyFeature == 0 ? f.features : finalPropertyFeature; + + boolean jsonFormat = ToString.Feature.hasFeature(feature, ToString.Feature.jsonFormat); + boolean propertyJsonFormat = ToString.Feature.hasFeature(finalPropertyFeature, ToString.Feature.jsonFormat); + + if (ToString.Feature.hasFeature(f.features, disableNestProperty)) { + return null; + } + if (!jsonFormat && finalToStringOverride) { + return String.valueOf(value); + } + + Set newIgnoreProperty = f.ignoreProperty + .stream() + .filter(property -> property.startsWith(propertyName.concat("."))) + .map(property -> property.substring(propertyName.length() + 1)) + .collect(Collectors.toSet()); + + if (justReturn) { + if (value instanceof Object[]) { + value = Arrays.asList(((Object[]) value)); + } + if (value instanceof Map) { + value = convertMap(((Map) value), feature, newIgnoreProperty); + } + if (value instanceof Collection) { + value = ((Collection) value).stream() + .map((val) -> { + if (val instanceof Map) { + return convertMap(((Map) val), feature, newIgnoreProperty); + } + if (simpleTypePredicate.test(val.getClass())) { + return val; + } + ToStringOperator operator = ToString.getOperator(val.getClass()); + if (operator instanceof DefaultToStringOperator) { + return ((DefaultToStringOperator) operator).toMap(val, feature, newIgnoreProperty); + } + return operator.toString(val, feature, newIgnoreProperty); + }).collect(Collectors.toList()); + + } + if (value instanceof Map) { + value = convertMap(((Map) value), feature, newIgnoreProperty); + } + if (propertyJsonFormat) { + return JSON.toJSONString(value); + } + return value; + } + + ToStringOperator operator = ToString.getOperator(value.getClass()); + if (!propertyJsonFormat && operator instanceof DefaultToStringOperator) { + return ((DefaultToStringOperator) operator).toMap(value, feature, newIgnoreProperty); + } else { + return operator.toString(value, feature, newIgnoreProperty); + } + }; + } + converts.put(descriptor.getName(), convert); + } + } + + static class ConvertConfig { + long features; + Set ignoreProperty; + } + + protected Map convertMap(Map obj, long features, Set ignoreProperty) { + if (ignoreProperty.isEmpty()) { + return obj; + } + boolean cover = ToString.Feature.hasFeature(features, coverIgnoreProperty); + boolean isNullPropertyToEmpty = ToString.Feature.hasFeature(features, nullPropertyToEmpty); + boolean isDisableNestProperty = ToString.Feature.hasFeature(features, disableNestProperty); + + Map newMap = new HashMap<>(obj); + Set ignore = new HashSet<>(ignoreProperty.size()); + ignore.addAll(defaultIgnoreProperties); + + for (Map.Entry entry : newMap.entrySet()) { + Object value = entry.getValue(); + + if (value == null) { + if (isNullPropertyToEmpty) { + entry.setValue(""); + } + continue; + } + Class type = value.getClass(); + if (simpleTypePredicate.test(type)) { + value = simpleConvertBuilder.apply(type).apply(value, null); + if (ignoreProperty.contains(entry.getKey())) { + if (cover) { + value = coverStringConvert.apply(value); + } else { + ignore.add(entry.getKey()); + } + entry.setValue(value); + } + + } else { + if (isDisableNestProperty) { + ignore.add(entry.getKey()); + } + } + } + ignore.forEach(newMap::remove); + return newMap; + } + + protected Map toMap(T target, long features, Set ignoreProperty) { + Map map = target instanceof Map ? ((Map) target) : FastBeanCopier.copy(target, new LinkedHashMap<>()); + + Set ignore = ignoreProperty == null || ignoreProperty.isEmpty() ? defaultIgnoreProperties : ignoreProperty; + ConvertConfig convertConfig = new ConvertConfig(); + convertConfig.ignoreProperty = ignore; + convertConfig.features = features == -1 ? defaultFeatures : features; + Set realIgnore = new HashSet<>(); + + for (Map.Entry entry : map.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + if (ToString.Feature.hasFeature(features, ToString.Feature.nullPropertyToEmpty)) { + boolean isSimpleType = false; + PropertyDescriptor propertyDescriptor = descriptorMap.get(entry.getKey()); + Class propertyType = null; + if (propertyDescriptor != null) { + propertyType = propertyDescriptor.getPropertyType(); + isSimpleType = simpleTypePredicate.test(propertyType); + } + if (isSimpleType || propertyType == null) { + entry.setValue(""); + } else if (propertyType.isArray() || Collection.class.isAssignableFrom(propertyType)) { + entry.setValue(new java.util.ArrayList<>()); + } else { + entry.setValue(new java.util.HashMap<>()); + } + } + continue; + } + BiFunction converter = converts.get(entry.getKey()); + if (null != converter) { + entry.setValue(converter.apply(value, convertConfig)); + } + if (entry.getValue() == null) { + realIgnore.add(entry.getKey()); + } + } + realIgnore.forEach(map::remove); + + return map; + } + + @Override + public String toString(T target, long features, Set ignoreProperty) { + if (target == null) { + return ""; + } + if (features == -1) { + features = defaultFeatures; + } + + Map mapValue = toMap(target, features, ignoreProperty); + if (ToString.Feature.hasFeature(features, ToString.Feature.jsonFormat)) { + return JSON.toJSONString(mapValue); + } + boolean writeClassName = ToString.Feature.hasFeature(features, ToString.Feature.writeClassname); + + StringJoiner joiner = new StringJoiner(", ", (writeClassName ? target.getClass().getSimpleName() : "") + "{", "}"); + + mapValue.forEach((key, value) -> joiner.add(key.concat("=").concat(String.valueOf(value)))); + + return joiner.toString(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/Diff.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/Diff.java new file mode 100644 index 000000000..6a40bb1f7 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/Diff.java @@ -0,0 +1,51 @@ +package org.hswebframework.web.bean; + +import com.alibaba.fastjson.JSON; +import com.google.common.collect.Sets; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Diff { + + private String property; + + private Object before; + + private Object after; + + + public static List of(Object before, Object after, String... ignoreProperty) { + List diffs = new ArrayList<>(); + Set ignores = Sets.newHashSet(ignoreProperty); + + Map beforeMap = FastBeanCopier.copy(before, HashMap::new); + Map afterMap = FastBeanCopier.copy(after, HashMap::new); + + for (Map.Entry entry : afterMap.entrySet()) { + if (ignores.contains(entry.getKey())) { + continue; + } + Object afterValue = entry.getValue(); + String key = entry.getKey(); + Object beforeValue = beforeMap.get(key); + if (!CompareUtils.compare(beforeValue, afterValue)) { + diffs.add(new Diff(key, beforeValue, afterValue)); + } + } + return diffs; + + } + + @Override + public String toString() { + return JSON.toJSONString(this); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableToBeanCopier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableToBeanCopier.java new file mode 100644 index 000000000..d154f328d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableToBeanCopier.java @@ -0,0 +1,20 @@ +package org.hswebframework.web.bean; + +import lombok.AllArgsConstructor; +import org.hswebframework.ezorm.core.Extendable; + +import java.util.Set; + +@AllArgsConstructor +class ExtendableToBeanCopier implements Copier { + + private final Copier copier; + + @Override + public void copy(Object source, Object target, Set ignore, Converter converter) { + copier.copy(source, target, ignore, converter); + FastBeanCopier.copy(((Extendable) source).extensions(), target); + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableToMapCopier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableToMapCopier.java new file mode 100644 index 000000000..f2dd5ddbc --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableToMapCopier.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.bean; + +import lombok.AllArgsConstructor; +import org.hswebframework.ezorm.core.Extendable; + +import java.util.Map; +import java.util.Set; + +@AllArgsConstructor +class ExtendableToMapCopier implements Copier { + + private final Copier copier; + + @Override + public void copy(Object source, Object target, Set ignore, Converter converter) { + copier.copy(source, target, ignore, converter); + ExtendableUtils.copyToMap((Extendable) source, ignore, (Map) target); + //移除map中的extensions + ((Map) target).remove("extensions"); + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableUtils.java new file mode 100644 index 000000000..e61659cfa --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ExtendableUtils.java @@ -0,0 +1,41 @@ +package org.hswebframework.web.bean; + +import com.google.common.collect.Maps; +import org.apache.commons.collections4.CollectionUtils; +import org.hswebframework.ezorm.core.Extendable; + +import java.util.Map; +import java.util.Set; + +public class ExtendableUtils { + + public static void copyFromMap(Map source, + Set ignore, + Extendable target) { + ClassDescription def = ClassDescriptions.getDescription(target.getClass()); + + for (Map.Entry entry : source.entrySet()) { + //只copy没有定义的数据 + if (!ignore.contains(entry.getKey()) && !def.getFields().containsKey(entry.getKey())) { + target.setExtension(entry.getKey(), entry.getValue()); + } + } + + } + + public static void copyToMap(Extendable target, + Set ignore, + Map source) { + if (CollectionUtils.isNotEmpty(ignore)) { + source.putAll( + Maps.filterKeys(target.extensions(), key -> !ignore.contains(key)) + ); + } else { + source.putAll( + target.extensions() + ); + } + + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java new file mode 100644 index 000000000..45d7ab842 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java @@ -0,0 +1,779 @@ +package org.hswebframework.web.bean; + +import com.google.common.collect.Maps; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.beanutils.BeanUtilsBean; +import org.apache.commons.beanutils.ConvertUtilsBean; +import org.apache.commons.beanutils.PropertyUtilsBean; +import org.hswebframework.ezorm.core.Extendable; +import org.hswebframework.utils.time.DateFormatter; +import org.hswebframework.web.dict.EnumDict; +import org.hswebframework.web.proxy.Proxy; +import org.hswebframework.web.utils.DynamicArrayList; +import org.springframework.core.ResolvableType; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.NumberUtils; +import org.springframework.util.ReflectionUtils; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * @author zhouhao + * @since 3.0 + */ +@Slf4j +public final class FastBeanCopier { + private static final Map CACHE = new ConcurrentHashMap<>(); + + private static final PropertyUtilsBean propertyUtils = BeanUtilsBean.getInstance().getPropertyUtils(); + + private static final ConvertUtilsBean convertUtils = BeanUtilsBean.getInstance().getConvertUtils(); + + private static final Map, Class> wrapperClassMapping = new HashMap<>(); + + @SuppressWarnings("all") + public static final Class[] EMPTY_CLASS_ARRAY = new Class[0]; + + private static BeanFactory BEAN_FACTORY; + + public static final DefaultConverter DEFAULT_CONVERT; + + public static void setBeanFactory(BeanFactory beanFactory) { + BEAN_FACTORY = beanFactory; + DEFAULT_CONVERT.setBeanFactory(beanFactory); + } + + public static BeanFactory getBeanFactory() { + return BEAN_FACTORY; + } + + static { + wrapperClassMapping.put(byte.class, Byte.class); + wrapperClassMapping.put(short.class, Short.class); + wrapperClassMapping.put(int.class, Integer.class); + wrapperClassMapping.put(float.class, Float.class); + wrapperClassMapping.put(double.class, Double.class); + wrapperClassMapping.put(char.class, Character.class); + wrapperClassMapping.put(boolean.class, Boolean.class); + wrapperClassMapping.put(long.class, Long.class); + BEAN_FACTORY = new BeanFactory() { + @Override + @SneakyThrows + @SuppressWarnings("all") + public T newInstance(Class beanType) { + return beanType == Map.class ? (T) new HashMap<>() : beanType.newInstance(); + } + }; + DEFAULT_CONVERT = new DefaultConverter(); + DEFAULT_CONVERT.setBeanFactory(BEAN_FACTORY); + } + + @SuppressWarnings("all") + public static Set include(String... inculdeProperties) { + return new HashSet(Arrays.asList(inculdeProperties)) { + @Override + public boolean contains(Object o) { + return !super.contains(o); + } + }; + } + + public static Object getProperty(Object source, String key) { + if (source instanceof Map) { + return ((Map) source).get(key); + } + SingleValueMap map = new SingleValueMap<>(); + copy(source, map, include(key)); + return map.getValue(); + } + + public static T copy(S source, T target, String... ignore) { + return copy(source, target, DEFAULT_CONVERT, ignore); + } + + public static T copy(S source, Supplier target, String... ignore) { + return copy(source, target.get(), DEFAULT_CONVERT, ignore); + } + + @SneakyThrows + public static T copy(S source, Class target, String... ignore) { + return copy(source, target.newInstance(), DEFAULT_CONVERT, ignore); + } + + public static T copy(S source, T target, Converter converter, String... ignore) { + return copy(source, target, converter, (ignore == null || ignore.length == 0) ? Collections.emptySet() : new HashSet<>(Arrays.asList(ignore))); + } + + public static T copy(S source, T target, Set ignore) { + return copy(source, target, DEFAULT_CONVERT, ignore); + } + + @SuppressWarnings("all") + public static T copy(S source, T target, Converter converter, Set ignore) { + if (source instanceof Map && target instanceof Map) { + if (CollectionUtils.isEmpty(ignore)) { + ((Map) target).putAll(((Map) source)); + } else { + ((Map) source) + .forEach((k, v) -> { + if (!ignore.contains(k)) { + ((Map) target).put(k, v); + } + }); + } + return target; + } + + getCopier(source, target, true) + .copy(source, target, ignore, converter); + return target; + } + + static Class getUserClass(Object object) { + if (object instanceof Map) { + return Map.class; + } + Class type = ClassUtils.getUserClass(object); + + if (java.lang.reflect.Proxy.isProxyClass(type)) { + Class[] interfaces = type.getInterfaces(); + return interfaces[0]; + } + + return type; + } + + public static Copier getCopier(Object source, Object target, boolean autoCreate) { + Class sourceType = getUserClass(source); + Class targetType = getUserClass(target); + CacheKey key = createCacheKey(sourceType, targetType); + if (autoCreate) { + return CACHE.computeIfAbsent(key, k -> createCopier(k.sourceType, k.targetType)); + } else { + return CACHE.get(key); + } + + } + + private static CacheKey createCacheKey(Class source, Class target) { + return new CacheKey(source, target); + } + + public static Copier createCopier(Class source, Class target) { + String sourceName = source.getName(); + String tartName = target.getName(); + if (sourceName.startsWith("package ")) { + sourceName = sourceName.substring("package ".length()); + } + if (tartName.startsWith("package ")) { + tartName = tartName.substring("package ".length()); + } + boolean targetIsExtendable = Extendable.class.isAssignableFrom(target); + boolean sourceIsExtendable = Extendable.class.isAssignableFrom(source); + boolean targetIsMap = Map.class.isAssignableFrom(target); + boolean sourceIsMap = Map.class.isAssignableFrom(source); + + String method = "public void copy(Object s, Object t, java.util.Set ignore, " + + "org.hswebframework.web.bean.Converter converter){\n" + + "try{\n\t" + + sourceName + " $$__source=(" + sourceName + ")s;\n\t" + + tartName + " $$__target=(" + tartName + ")t;\n\t" + + createCopierCode(source, target) + + "}catch(Throwable e){\n" + + "\tthrow e;" + + "\n}\n" + + "\n}"; + try { + @SuppressWarnings("all") + Proxy proxy = Proxy + .create(Copier.class, new Class[]{source, target}) + .addMethod(method); + Copier copier = proxy.newInstance(); + if (sourceIsExtendable && targetIsMap) { + copier = new ExtendableToMapCopier(copier); + } else if (sourceIsMap && targetIsExtendable) { + copier = new MapToExtendableCopier(copier); + } else if (sourceIsExtendable) { + copier = new ExtendableToBeanCopier(copier); + } + return copier; + } catch (Exception e) { + log.error("创建bean copy 代理对象失败:\n{}", method, e); + throw new UnsupportedOperationException(e.getMessage(), e); + } + } + + private static Map createProperty(Class type) { + + List fieldNames = Arrays + .stream(type.getDeclaredFields()) + .map(Field::getName) + .collect(Collectors.toList()); + + return Stream.of(propertyUtils.getPropertyDescriptors(type)) + .filter(property -> !property + .getName() + .equals("class") && property.getReadMethod() != null && property.getWriteMethod() != null) + .map(BeanClassProperty::new) + //让字段有序 + .sorted(Comparator.comparing(property -> fieldNames.indexOf(property.name))) + .collect(Collectors.toMap(ClassProperty::getName, Function.identity(), (k, k2) -> k, LinkedHashMap::new)); + + } + + private static Map createMapProperty(Map template) { + return template + .values() + .stream() + .map(classProperty -> new MapClassProperty(classProperty.name)) + .collect(Collectors.toMap(ClassProperty::getName, Function.identity(), (k, k2) -> k, LinkedHashMap::new)); + } + + private static String createCopierCode(Class source, Class target) { + Map sourceProperties = null; + + Map targetProperties = null; + + boolean targetIsExtendable = Extendable.class.isAssignableFrom(target); + boolean sourceIsExtendable = Extendable.class.isAssignableFrom(source); + boolean targetIsMap = Map.class.isAssignableFrom(target); + boolean sourceIsMap = Map.class.isAssignableFrom(source); + //源类型为Map + if (sourceIsMap) { + if (!targetIsMap) { + targetProperties = createProperty(target); + sourceProperties = createMapProperty(targetProperties); + + } + } else if (targetIsMap) { + sourceProperties = createProperty(source); + targetProperties = createMapProperty(sourceProperties); + } else { + targetProperties = createProperty(target); + sourceProperties = createProperty(source); + } + if (sourceProperties == null || targetProperties == null) { + throw new UnsupportedOperationException("不支持的类型,source:" + source + " target:" + target); + } + StringBuilder code = new StringBuilder(); + + for (ClassProperty sourceProperty : sourceProperties.values()) { + ClassProperty targetProperty = targetProperties.get(sourceProperty.getName()); + if (targetProperty == null) { + //复制到拓展对象 + if (targetIsExtendable && !sourceIsExtendable && !sourceIsMap) { + code.append("if(!ignore.contains(\"").append(sourceProperty.getName()).append("\")){\n\t"); + if (!sourceProperty.isPrimitive()) { + code.append("if($$__source.").append(sourceProperty.getReadMethod()).append("!=null){\n"); + } + code.append("\t\t((org.hswebframework.ezorm.core.Extendable)$$__target).setExtension(") + .append("\"").append(sourceProperty.name).append("\",") + .append("$$__source.").append(sourceProperty.getReadMethod()) + .append(");"); + if (!sourceProperty.isPrimitive()) { + code.append("\n\t}"); + } + code.append("\n}\n"); + } + continue; + } + code.append("if(!ignore.contains(\"").append(sourceProperty.getName()).append("\")){\n\t"); + if (!sourceProperty.isPrimitive()) { + code.append("if($$__source.").append(sourceProperty.getReadMethod()).append("!=null){\n"); + } + code.append(targetProperty.generateVar(targetProperty.getName())).append("=") + .append(sourceProperty.generateGetter(target, targetProperty.getType())) + .append(";\n"); + + if (!targetProperty.isPrimitive()) { + code.append("\tif(").append(sourceProperty.getName()).append("!=null){\n"); + } + code + .append("\t$$__target.") + .append(targetProperty.generateSetter(targetProperty.getType(), sourceProperty.getName())) + .append(";\n"); + if (!targetProperty.isPrimitive()) { + code.append("\t}\n"); + } + if (!sourceProperty.isPrimitive()) { + code.append("\t}\n"); + } + code.append("}\n"); + } + return code.toString(); + } + + static abstract class ClassProperty { + + @Getter + protected String name; + + @Getter + protected String readMethodName; + + @Getter + protected String writeMethodName; + + @Getter + protected BiFunction, Class, String> getter; + + @Getter + protected BiFunction, String, String> setter; + + @Getter + protected Class type; + + @Getter + protected Class beanType; + + public String getReadMethod() { + return readMethodName + "()"; + } + + public String generateVar(String name) { + return getTypeName().concat(" ").concat(name); + } + + public String getTypeName() { + return getTypeName(type); + } + + public String getTypeName(Class type) { + String targetTypeName = type.getName(); + if (type.isArray()) { + targetTypeName = type.getComponentType().getName() + "[]"; + } + return targetTypeName; + } + + public boolean isPrimitive() { + return isPrimitive(getType()); + } + + public boolean isPrimitive(Class type) { + return type.isPrimitive(); + } + + public boolean isWrapper() { + return isWrapper(getType()); + } + + public boolean isWrapper(Class type) { + return wrapperClassMapping.containsValue(type); + } + + protected Class getPrimitiveType(Class type) { + return wrapperClassMapping.entrySet().stream() + .filter(entry -> entry.getValue() == type) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } + + protected Class getWrapperType() { + return wrapperClassMapping.get(type); + } + + protected String castWrapper(String getter) { + return getWrapperType().getSimpleName().concat(".valueOf(").concat(getter).concat(")"); + } + + public BiFunction, Class, String> createGetterFunction() { + + return (targetBeanType, targetType) -> { + String getterCode = "$$__source." + getReadMethod(); + + String generic = "org.hswebframework.web.bean.FastBeanCopier.EMPTY_CLASS_ARRAY"; + Field field = ReflectionUtils.findField(targetBeanType, name); + boolean hasGeneric = false; + if (field != null) { + String[] arr = Arrays.stream(ResolvableType.forField(field) + .getGenerics()) + .map(ResolvableType::getRawClass) + .filter(Objects::nonNull) + .map(t -> t.getName().concat(".class")) + .toArray(String[]::new); + if (arr.length > 0) { + generic = "new Class[]{" + String.join(",", arr) + "}"; + hasGeneric = true; + } + } + String convert = "converter.convert((Object)(" + (isPrimitive() ? castWrapper(getterCode) : getterCode) + ")," + + getTypeName(targetType) + ".class," + generic + ")"; + StringBuilder convertCode = new StringBuilder(); + + if (targetType != getType()) { + if (isPrimitive(targetType)) { + boolean sourceIsWrapper = isWrapper(); + Class targetWrapperClass = wrapperClassMapping.get(targetType); + + Class sourcePrimitive = getPrimitiveType(getType()); + //目标字段是基本数据类型,源字段是包装器类型 + // source.getField().intValue(); + if (sourceIsWrapper) { + convertCode + .append(getterCode) + .append(".") + .append(sourcePrimitive.getName()) + .append("Value()"); + } else { + //类型不一致,调用convert转换 + convertCode.append("((").append(targetWrapperClass.getName()) + .append(")") + .append(convert) + .append(").") + .append(targetType.getName()) + .append("Value()"); + } + + } else if (isPrimitive()) { + boolean targetIsWrapper = isWrapper(targetType); + //源字段类型为基本数据类型,目标字段为包装器类型 + if (targetIsWrapper) { + convertCode.append(targetType.getName()) + .append(".valueOf(") + .append(getterCode) + .append(")"); + } else { + convertCode.append("(").append(targetType.getName()) + .append(")(") + .append(convert) + .append(")"); + } + } else { + convertCode.append("(").append(getTypeName(targetType)) + .append(")(") + .append(convert) + .append(")"); + } + } else { + if (Cloneable.class.isAssignableFrom(targetType)) { + try { + convertCode + .append("(") + .append(getTypeName()) + .append(")") + .append(getterCode) + .append(".clone()"); + } catch (Exception e) { + convertCode.append(getterCode); + } + } else { + if ((Map.class.isAssignableFrom(targetType) + || Collection.class.isAssignableFrom(type)) && hasGeneric) { + convertCode.append("(").append(getTypeName()).append(")").append(convert); + } else { + convertCode.append("(").append(getTypeName()).append(")").append(getterCode); +// convertCode.append(getterCode); + } + + } + + } +// if (!isPrimitive()) { +// return getterCode + "!=null?" + convertCode.toString() + ":null"; +// } + return convertCode.toString(); + }; + } + + public BiFunction, String, String> createSetterFunction(Function settingNameSupplier) { + return (sourceType, paramGetter) -> settingNameSupplier.apply(paramGetter); + } + + public String generateGetter(Class targetBeanType, Class targetType) { + return getGetter().apply(targetBeanType, targetType); + } + + public String generateSetter(Class targetType, String getter) { + return getSetter().apply(targetType, getter); + } + } + + static class BeanClassProperty extends ClassProperty { + public BeanClassProperty(PropertyDescriptor descriptor) { + type = descriptor.getPropertyType(); + readMethodName = descriptor.getReadMethod().getName(); + writeMethodName = descriptor.getWriteMethod().getName(); + + getter = createGetterFunction(); + setter = createSetterFunction(paramGetter -> writeMethodName + "(" + paramGetter + ")"); + name = descriptor.getName(); + beanType = descriptor.getReadMethod().getDeclaringClass(); + + } + } + + static class MapClassProperty extends ClassProperty { + public MapClassProperty(String name) { + type = Object.class; + this.name = name; + this.readMethodName = "get"; + this.writeMethodName = "put"; + + this.getter = createGetterFunction(); + this.setter = createSetterFunction(paramGetter -> "put(\"" + name + "\"," + paramGetter + ")"); + beanType = Map.class; + } + + @Override + public String getReadMethod() { + return "get(\"" + name + "\")"; + } + + @Override + public String getReadMethodName() { + return "get(\"" + name + "\")"; + } + } + + + public static final class DefaultConverter implements Converter { + private BeanFactory beanFactory = BEAN_FACTORY; + + public void setBeanFactory(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + public Collection newCollection(Class targetClass) { + + if (targetClass == List.class) { + return new ArrayList<>(); + } else if (targetClass == ConcurrentHashMap.KeySetView.class) { + return ConcurrentHashMap.newKeySet(); + } else if (targetClass == Set.class) { + return new HashSet<>(); + } else if (targetClass == Queue.class) { + return new LinkedList<>(); + } else { + try { + return (Collection) targetClass.newInstance(); + } catch (Exception e) { + throw new UnsupportedOperationException("不支持的类型:" + targetClass, e); + } + } + } + + @Override + @SuppressWarnings("all") + @SneakyThrows + public T convert(Object source, Class targetClass, Class[] genericType) { + if (source == null) { + return null; + } + ClassDescription target = ClassDescriptions.getDescription(targetClass); + + if (target.isEnumType()) { + if (source instanceof EnumDict) { + Object val = (T) ((EnumDict) source).getValue(); + if (targetClass.isInstance(val)) { + return ((T) val); + } + return convert(val, targetClass, genericType); + } + } + if (targetClass == String.class) { + if (source instanceof Date) { + // TODO: 18-4-16 自定义格式 + return (T) DateFormatter.toString(((Date) source), "yyyy-MM-dd HH:mm:ss"); + } + return (T) String.valueOf(source); + } + if (targetClass == Object.class) { + return (T) source; + } + if (targetClass == Date.class) { + if (source instanceof String) { + T parsed = (T) DateFormatter.fromString((String) source); + if (parsed == null) { + return (T) converterByApache(Date.class, source); + } + return parsed; + } + if (source instanceof Number) { + return (T) new Date(((Number) source).longValue()); + } + if (source instanceof Date) { + return (T) new Date(((Date) source).getTime()); + } + } + if (target.isCollectionType()) { + Collection collection = newCollection(targetClass); + Collection sourceCollection; + if (source instanceof Collection) { + sourceCollection = (Collection) source; + } else if (source.getClass().isArray()) { + sourceCollection = new DynamicArrayList(source); + } else if (source instanceof Map) { + sourceCollection = ((Map) source).values(); + } else { + if (source instanceof String) { + String stringValue = ((String) source); + sourceCollection = Arrays.asList(stringValue.split("[,]")); + } else { + sourceCollection = Arrays.asList(source); + } + } + //转换泛型 + if (genericType != null && genericType.length > 0 && genericType[0] != Object.class) { + for (Object sourceObj : sourceCollection) { + collection.add(convert(sourceObj, genericType[0], null)); + } + } else { + collection.addAll(sourceCollection); + } + return (T) collection; + } + if (target.isEnumType()) { + if (target.isEnumDict()) { + String strVal = String.valueOf(source); + Object val = null; + for (Object anEnum : target.getEnums()) { + EnumDict dic = ((EnumDict) anEnum); + Enum e = ((Enum) anEnum); + if (dic.eq(source) || e.name().equalsIgnoreCase(strVal)) { + val = (T) anEnum; + break; + } + } + if (val == null) { + return null; + } + if (targetClass.isInstance(val)) { + return ((T) val); + } + return convert(val, targetClass, genericType); + } + String strSource = String.valueOf(source); + for (Object e : target.getEnums()) { + Enum t = ((Enum) e); + if ((t.name().equalsIgnoreCase(strSource) + || Objects.equals(String.valueOf(t.ordinal()), strSource))) { + return (T) e; + } + } + + log.warn("无法将:{}转为枚举:{}", + source, + targetClass, + new ClassCastException(source + "=>" + targetClass)); + return null; + } + //转换为数组 + if (target.isArrayType()) { + Class componentType = targetClass.getComponentType(); + + List val = convert(source, List.class, new Class[]{componentType}); + int size = val.size(); + + Object array = Array.newInstance(componentType, size); + for (int i = 0; i < size; i++) { + Array.set(array, i, val.get(i)); + } + return (T) array; + } + if (target.isNumber()) { + if (source instanceof String) { + return (T) NumberUtils.parseNumber(String.valueOf(source), (Class) targetClass); + } + if (source instanceof Date) { + source = ((Date) source).getTime(); + } + } + try { + org.apache.commons.beanutils.Converter converter = convertUtils.lookup(targetClass); + if (null != converter) { + return converter.convert(targetClass, source); + } + + //快速复制map + if (targetClass == Map.class) { + if (source instanceof Map) { + return (T) copyMap(((Map) source)); + } + if (source instanceof Collection) { + Map map = new LinkedHashMap<>(); + int i = 0; + for (Object o : ((Collection) source)) { + if (genericType.length >= 2) { + map.put(convert(i++, genericType[0], EMPTY_CLASS_ARRAY), convert(o, genericType[1], EMPTY_CLASS_ARRAY)); + } else { + map.put(i++, o); + } + } + return (T) map; + + } + ClassDescription sourType = ClassDescriptions.getDescription(source.getClass()); + return (T) copy(source, Maps.newHashMapWithExpectedSize(sourType.getFieldSize())); + } + + return copy(source, beanFactory.newInstance(targetClass), this); + } catch (Exception e) { + log.warn("复制类型{}->{}失败", targetClass, e); + throw e; + } +// return null; + } + + private Map copyMap(Map map) { + if (map instanceof TreeMap) { + return new TreeMap<>(map); + } + + if (map instanceof LinkedHashMap) { + return new LinkedHashMap<>(map); + } + + if (map instanceof ConcurrentHashMap) { + return new ConcurrentHashMap<>(map); + } + + return new HashMap<>(map); + } + + private Object converterByApache(Class targetClass, Object source) { + org.apache.commons.beanutils.Converter converter = convertUtils.lookup(targetClass); + if (null != converter) { + return converter.convert(targetClass, source); + } + return null; + } + } + + @AllArgsConstructor + public static class CacheKey { + + private final Class sourceType; + + private final Class targetType; + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof CacheKey)) { + return false; + } + CacheKey target = ((CacheKey) obj); + return target.targetType == targetType && target.sourceType == sourceType; + } + + public int hashCode() { + int result = this.targetType != null ? this.targetType.hashCode() : 0; + result = 31 * result + (this.sourceType != null ? this.sourceType.hashCode() : 0); + return result; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/MapToExtendableCopier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/MapToExtendableCopier.java new file mode 100644 index 000000000..9bbab361b --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/MapToExtendableCopier.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.bean; + +import lombok.AllArgsConstructor; +import org.hswebframework.ezorm.core.Extendable; + +import java.util.Map; +import java.util.Set; + +@AllArgsConstructor +class MapToExtendableCopier implements Copier { + + private final Copier copier; + + @Override + public void copy(Object source, Object target, Set ignore, Converter converter) { + copier.copy(source, target, ignore, converter); + + ExtendableUtils.copyFromMap((Map) source, ignore, (Extendable) target); + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/SingleValueMap.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/SingleValueMap.java new file mode 100644 index 000000000..b37221a38 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/SingleValueMap.java @@ -0,0 +1,108 @@ +package org.hswebframework.web.bean; + +import java.util.*; + +public class SingleValueMap implements Map { + private K key; + private V value; + + @Override + public int size() { + return value == null ? 0 : 1; + } + + @Override + public boolean isEmpty() { + return size() == 0; + } + + @Override + public boolean containsKey(Object key) { + return Objects.equals(this.key, key); + } + + @Override + public boolean containsValue(Object value) { + return Objects.equals(this.value, value); + } + + @Override + public V get(Object key) { + return Objects.equals(key, this.key) ? value : null; + } + + @Override + public V put(K key, V value) { + this.key = key; + V old = this.value; + this.value = value; + return old; + } + + @Override + public V remove(Object key) { + if (Objects.equals(key, this.key)) { + V old = this.value; + this.value = null; + return old; + } + return null; + } + + @Override + public void putAll(Map m) { + if (m.size() > 0) { + Map.Entry entry = m.entrySet().iterator().next(); + this.key = entry.getKey(); + this.value = entry.getValue(); + } + } + + @Override + public void clear() { + this.key = null; + this.value = null; + } + + @Override + public Set keySet() { + return key == null ? Collections.emptySet() : Collections.singleton(key); + } + + @Override + public Collection values() { + return value == null ? Collections.emptySet() : Collections.singleton(value); + } + + @Override + public Set> entrySet() { + return key == null ? Collections.emptySet() : Collections.singleton( + new Entry() { + @Override + public K getKey() { + return key; + } + + @Override + public V getValue() { + return value; + } + + @Override + public V setValue(V value) { + V old = SingleValueMap.this.value; + SingleValueMap.this.value = value; + return old; + } + } + ); + } + + public V getValue() { + return value; + } + + public K getKey() { + return key; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ToString.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ToString.java new file mode 100644 index 000000000..928309d1e --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ToString.java @@ -0,0 +1,141 @@ +package org.hswebframework.web.bean; + +import org.springframework.util.ClassUtils; + +import java.lang.annotation.*; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author zhouhao + * @since 3.0.0-RC + */ +public class ToString { + + public static long DEFAULT_FEATURE = Feature.createFeatures( + Feature.coverIgnoreProperty + , Feature.nullPropertyToEmpty +// , Feature.jsonFormat + ); + + public static final Map cache = new ConcurrentHashMap<>(); + + @SuppressWarnings("all") + public static ToStringOperator getOperator(Class type) { + return cache.computeIfAbsent(type, DefaultToStringOperator::new); + } + + @SuppressWarnings("all") + public static String toString(T target) { + return getOperator((Class) ClassUtils.getUserClass(target)).toString(target); + } + + @SuppressWarnings("all") + public static String toString(T target, String... ignoreProperty) { + return getOperator((Class) ClassUtils.getUserClass(target)).toString(target, ignoreProperty); + } + + @Target({ElementType.TYPE, ElementType.FIELD}) + @Retention(RetentionPolicy.RUNTIME) + @Documented + public @interface Ignore { + + String[] value() default {}; + + boolean cover() default true; + + } + + @Target({ElementType.TYPE, ElementType.FIELD}) + @Retention(RetentionPolicy.RUNTIME) + @Documented + public @interface Features { + Feature[] value() default {}; + } + + public enum Feature { + + /** + * 什么也不配置 + * + * @since 3.0.0-RC + */ + empty, + + /** + * 忽略为null的字段 + * + * @since 3.0.0-RC + */ + ignoreNullProperty, + + /** + * null的字段转为空,如null字符串转为"", null的list转为[] + * + * @since 3.0.0-RC + */ + nullPropertyToEmpty, + + /** + * 排除的字段使用*进行遮盖,如: 张三 =? 张* , 18502314087 => 185****087 + * + * @since 3.0.0-RC + */ + coverIgnoreProperty, + + /** + * 是否关闭嵌套属性toString + * + * @since 3.0.0-RC + */ + disableNestProperty, + + /** + * 以json方式进行格式化 + * + * @since 3.0.0-RC + */ + jsonFormat, + + /** + * 是否写出类名 + * + * @since 3.0.0-RC + */ + writeClassname; + + + public long getMask() { + return 1L << ordinal(); + } + + public static boolean hasFeature(long features, Feature feature) { + long mast = feature.getMask(); + return (features & mast) == mast; + } + + public static long removeFeatures(long oldFeature, Feature... features) { + if (features == null) { + return 0L; + } + long value = oldFeature; + for (Feature feature : features) { + value &= ~feature.getMask(); + } + return value; + } + + public static long createFeatures(Feature... features) { + if (features == null) { + return 0L; + } + long value = 0L; + for (Feature feature : features) { + value |= feature.getMask(); + } + + return value; + } + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/ToStringOperator.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/ToStringOperator.java new file mode 100644 index 000000000..878d3796b --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/ToStringOperator.java @@ -0,0 +1,17 @@ +package org.hswebframework.web.bean; + + +import java.util.*; + +/** + * @author zhouhao + * @since 3.0.0-RC + */ +public interface ToStringOperator { + + default String toString(T target, String... ignoreProperty) { + return toString(target, -1, ignoreProperty == null ? new java.util.HashSet<>() : new HashSet<>(Arrays.asList(ignoreProperty))); + } + + String toString(T target, long features, Set ignoreProperty); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/context/Context.java b/hsweb-core/src/main/java/org/hswebframework/web/context/Context.java new file mode 100644 index 000000000..bd6b5856d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/context/Context.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.context; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +public interface Context { + + default Optional get(Class key) { + return get(ContextKey.of(key)); + } + + default void put(Class key, T value) { + put(ContextKey.of(key), value); + } + + default void put(String key, T value) { + put(ContextKey.of(key), value); + } + + Optional get(ContextKey key); + + T getOrDefault(ContextKey key, Supplier defaultValue); + + void put(ContextKey key, T value); + + T remove(ContextKey key); + + Map getAll(); + + void clean(); + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/context/ContextKey.java b/hsweb-core/src/main/java/org/hswebframework/web/context/ContextKey.java new file mode 100644 index 000000000..00ee46095 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/context/ContextKey.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.context; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public final class ContextKey { + + private final String key; + + public static ContextKey of(String key) { + return new ContextKey<>(key); + } + + public static ContextKey of(Class key) { + return new ContextKey<>(key.getName()); + } + + public static ContextKey string(String key) { + return of(key); + } + + public static ContextKey integer(String key) { + return of(key); + } + + public static ContextKey bool(String key) { + return of(key); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/context/ContextUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/context/ContextUtils.java new file mode 100644 index 000000000..38eb71a9e --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/context/ContextUtils.java @@ -0,0 +1,40 @@ +package org.hswebframework.web.context; + + +import reactor.core.publisher.Mono; + +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * @since 4.0.0 + */ +public class ContextUtils { + + private static final ThreadLocal contextThreadLocal = ThreadLocal.withInitial(MapContext::new); + + public static Context currentContext() { + return contextThreadLocal.get(); + } + + @Deprecated + public static Mono reactiveContext() { + return Mono + .deferContextual(context->Mono.justOrEmpty(context.getOrEmpty(Context.class))) + .contextWrite(acceptContext(ctx -> { + + })); + } + + @Deprecated + public static Function acceptContext(Consumer contextConsumer) { + return context -> { + if (!context.hasKey(Context.class)) { + context = context.put(Context.class, new MapContext()); + } + contextConsumer.accept(context.get(Context.class)); + return context; + }; + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/context/MapContext.java b/hsweb-core/src/main/java/org/hswebframework/web/context/MapContext.java new file mode 100644 index 000000000..869071b9b --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/context/MapContext.java @@ -0,0 +1,45 @@ +package org.hswebframework.web.context; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +@SuppressWarnings("all") +class MapContext implements Context { + + private Map map = new ConcurrentHashMap<>(); + + @Override + public Optional get(ContextKey key) { + return Optional.ofNullable(map.get(key.getKey())) + .map(v -> ((T) v)); + } + + @Override + public T getOrDefault(ContextKey key, Supplier defaultValue) { + return (T) map.computeIfAbsent(key.getKey(), __ -> defaultValue.get()); + } + + @Override + public void put(ContextKey key, T value) { + map.put(key.getKey(), value); + } + + @Override + public T remove(ContextKey key) { + return (T)map.remove(key); + } + + @Override + public Map getAll() { + return new HashMap<>(map); + } + + @Override + public void clean() { + map.clear(); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/convert/CustomMessageConverter.java b/hsweb-core/src/main/java/org/hswebframework/web/convert/CustomMessageConverter.java new file mode 100644 index 000000000..291c72fca --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/convert/CustomMessageConverter.java @@ -0,0 +1,11 @@ +package org.hswebframework.web.convert; + +/** + * @author zhouhao + * @since 3.0 + */ +public interface CustomMessageConverter { + boolean support(Class clazz); + + Object convert(Class clazz, byte[] message); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/ClassDictDefine.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/ClassDictDefine.java new file mode 100644 index 000000000..e15b0af72 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/ClassDictDefine.java @@ -0,0 +1,9 @@ +package org.hswebframework.web.dict; + +/** + * @author zhouhao + * @since 3.0 + */ +public interface ClassDictDefine extends DictDefine { + String getField(); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/Dict.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/Dict.java new file mode 100644 index 000000000..8f60f4b43 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/Dict.java @@ -0,0 +1,33 @@ +package org.hswebframework.web.dict; + + +import java.lang.annotation.*; + +/** + * @author zhouhao + * @since 3.0 + */ +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Dict { + /** + * @return 字典ID + * @see DictDefine#getId() + * @see DictDefineRepository + */ + String value() default ""; + + /** + * 字典别名 + * @return 别名 + */ + String alias() default ""; + + /** + * @return 字典说明, 备注 + */ + String comments() default ""; + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/DictDefine.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/DictDefine.java new file mode 100644 index 000000000..513c9411b --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/DictDefine.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.dict; + +import java.io.Serializable; +import java.util.List; + +/** + * @author zhouhao + * @since 3.0 + */ +public interface DictDefine extends Serializable { + String getId(); + + String getAlias(); + + String getComments(); + + List> getItems(); + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/DictDefineRepository.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/DictDefineRepository.java new file mode 100644 index 000000000..37b16d7f7 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/DictDefineRepository.java @@ -0,0 +1,16 @@ +package org.hswebframework.web.dict; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * @author zhouhao + * @since 1.0 + */ +public interface DictDefineRepository { + Mono getDefine(String id); + + Flux getAllDefine(); + + void addDefine(DictDefine dictDefine); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java new file mode 100644 index 000000000..5cb43158f --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java @@ -0,0 +1,495 @@ +package org.hswebframework.web.dict; + +import com.alibaba.fastjson.JSONException; +import com.alibaba.fastjson.annotation.JSONType; +import com.alibaba.fastjson.parser.DefaultJSONParser; +import com.alibaba.fastjson.parser.JSONLexer; +import com.alibaba.fastjson.parser.JSONToken; +import com.alibaba.fastjson.parser.deserializer.ObjectDeserializer; +import com.alibaba.fastjson.serializer.JSONSerializable; +import com.alibaba.fastjson.serializer.JSONSerializer; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.bean.ClassDescription; +import org.hswebframework.web.bean.ClassDescriptions; +import org.hswebframework.web.dict.defaults.DefaultItemDefine; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.i18n.LocaleUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.Serializable; +import java.lang.reflect.Type; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 枚举字典,使用枚举来实现数据字典,可通过集成此接口来实现一些有趣的功能. + * ⚠️:如果使用了位运算来判断枚举,枚举数量不要超过64个,且顺序不要随意变动! + * ⚠️:如果要开启在反序列化json的时候,支持将对象反序列化枚举,由于fastJson目前的版本还不支持从父类获取注解, + * 所以需要在实现类上注解:@JSONType(deserializer = EnumDict.EnumDictJSONDeserializer.class). + * + * @author zhouhao + * @see 3.0 + * @see EnumDictJSONDeserializer + * @see JSONSerializable + */ +@JSONType(deserializer = EnumDict.EnumDictJSONDeserializer.class) +@JsonDeserialize(contentUsing = EnumDict.EnumDictJSONDeserializer.class) +public interface EnumDict extends JSONSerializable, Serializable { + + /** + * 枚举选项的值,通常由字母或者数字组成,并且在同一个枚举中值唯一;对应数据库中的值通常也为此值 + * + * @return 枚举的值 + * @see ItemDefine#getValue() + */ + V getValue(); + + /** + * 枚举字典选项的文本,通常为中文 + * + * @return 枚举的文本 + * @see ItemDefine#getText() + */ + String getText(); + + /** + * {@link Enum#ordinal()} + * + * @return 枚举序号, 如果枚举顺序改变, 此值将被变动 + */ + int ordinal(); + + default long index() { + return ordinal(); + } + + default long getMask() { + return 1L << index(); + } + + /** + * 对比是否和value相等,对比地址,值,value转为string忽略大小写对比,text忽略大小写对比 + * + * @param v value + * @return 是否相等 + */ + @SuppressWarnings("all") + default boolean eq(Object v) { + if (v == null) { + return false; + } + if (v instanceof Object[]) { + v = Arrays.asList(v); + } + if (v instanceof Collection) { + return ((Collection) v).stream().anyMatch(this::eq); + } + if (v instanceof Map) { + v = ((Map) v).getOrDefault("value", ((Map) v).get("text")); + } + if (v instanceof Number) { + v = ((Number) v).intValue(); + } + if (v instanceof EnumDict) { + EnumDict dict = ((EnumDict) v); + v = dict.getValue(); + if (v == null) { + v = dict.getText(); + } + } + return this == v + || getValue() == v + || Objects.equals(getValue(), v) + || Objects.equals(ordinal(), v) + || String.valueOf(getValue()).equalsIgnoreCase(String.valueOf(v)) + || getText().equalsIgnoreCase(String.valueOf(v) + ); + } + + default boolean in(long mask) { + return (mask & getMask()) != 0; + } + + default boolean in(EnumDict... dict) { + return in(toMask(dict)); + } + + /** + * 枚举选项的描述,对一个选项进行详细的描述有时候是必要的.默认值为{@link EnumDict#getText()} + * + * @return 描述 + */ + default String getComments() { + return getText(); + } + + + /** + * 从指定的枚举类中查找想要的枚举,并返回一个{@link Optional},如果未找到,则返回一个{@link Optional#empty()} + * + * @param type 实现了{@link EnumDict}的枚举类 + * @param predicate 判断逻辑 + * @param 枚举类型 + * @return 查找到的结果 + */ + @SuppressWarnings("all") + static & EnumDict> Optional find(Class type, Predicate predicate) { + ClassDescription description = ClassDescriptions.getDescription(type); + if (description.isEnumType()) { + for (Object enumDict : description.getEnums()) { + if (predicate.test((T) enumDict)) { + return Optional.of((T) enumDict); + } + } + } + return Optional.empty(); + } + + @SuppressWarnings("all") + static & EnumDict> List findList(Class type, Predicate predicate) { + ClassDescription description = ClassDescriptions.getDescription(type); + if (description.isEnumType()) { + return Arrays.stream(description.getEnums()) + .map(v -> (T) v) + .filter(predicate) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + /** + * 根据枚举的{@link EnumDict#getValue()}来查找. + * + * @see EnumDict#find(Class, Predicate) + */ + static & EnumDict> Optional findByValue(Class type, Object value) { + if (value == null) { + return Optional.empty(); + } + return find(type, e -> e.getValue() == value || e.getValue().equals(value) || String + .valueOf(e.getValue()) + .equalsIgnoreCase(String.valueOf(value))); + } + + /** + * 根据枚举的{@link EnumDict#getText()} 来查找. + * + * @see EnumDict#find(Class, Predicate) + */ + static & EnumDict> Optional findByText(Class type, String text) { + return find(type, e -> e.getText().equalsIgnoreCase(text)); + } + + /** + * 根据枚举的{@link EnumDict#getValue()},{@link EnumDict#getText()}来查找. + * + * @see EnumDict#find(Class, Predicate) + */ + static & EnumDict> Optional find(Class type, Object target) { + return find(type, v -> v.eq(target)); + } + + @SafeVarargs + static > long toMask(T... t) { + if (t == null) { + return 0L; + } + long value = 0L; + for (T t1 : t) { + value |= t1.getMask(); + } + return value; + } + + + @SafeVarargs + static & EnumDict> boolean in(T target, T... t) { + ClassDescription description = ClassDescriptions.getDescription(target.getClass()); + Object[] all = description.getEnums(); + + if (all.length >= 64) { + Set allSet = new HashSet<>(Arrays.asList(all)); + for (T t1 : t) { + if (allSet.contains(t1)) { + return true; + } + } + return false; + } + return maskIn(toMask(t), target); + } + + @SafeVarargs + static > boolean maskIn(long mask, T... t) { + long value = toMask(t); + return (mask & value) == value; + } + + @SafeVarargs + static > boolean maskInAny(long mask, T... t) { + long value = toMask(t); + return (mask & value) != 0; + } + + static > List getByMask(List allOptions, long mask) { + if (allOptions.size() >= 64) { + throw new UnsupportedOperationException("不支持选项超过64个数据字典!"); + } + List arr = new ArrayList<>(); + for (T t : allOptions) { + if (t.in(mask)) { + arr.add(t); + } + } + return arr; + } + + static > List getByMask(Supplier> allOptionsSupplier, long mask) { + return getByMask(allOptionsSupplier.get(), mask); + } + + + static & EnumDict> List getByMask(Class tClass, long mask) { + + return getByMask(Arrays.asList(tClass.getEnumConstants()), mask); + } + + /** + * 默认在序列化为json时,默认会以对象方式写出枚举,可通过系统环境变量 hsweb.enum.dict.disableWriteJSONObject关闭默认设置。 + * 比如: java -jar -Dhsweb.enum.dict.disableWriteJSONObject=true + */ + boolean DEFAULT_WRITE_JSON_OBJECT = !Boolean.getBoolean("hsweb.enum.dict.disableWriteJSONObject"); + + /** + * @return 是否在序列化为json的时候, 将枚举以对象方式序列化 + * @see EnumDict#DEFAULT_WRITE_JSON_OBJECT + */ + default boolean isWriteJSONObjectEnabled() { + return DEFAULT_WRITE_JSON_OBJECT; + } + + default String getI18nCode() { + return getText(); + } + + default String getI18nMessage(Locale locale) { + return LocaleUtils.resolveMessage(getI18nCode(), locale, getText()); + } + + /** + * 当{@link EnumDict#isWriteJSONObjectEnabled()}返回true时,在序列化为json的时候,会写出此方法返回的对象 + * + * @return 最终序列化的值 + * @see EnumDict#isWriteJSONObjectEnabled() + */ + @JsonValue + default Object getWriteJSONObject() { + if (isWriteJSONObjectEnabled()) { + Map jsonObject = new HashMap<>(); + jsonObject.put("value", getValue()); + jsonObject.put("text", getI18nMessage(LocaleUtils.current())); + // jsonObject.put("index", index()); + // jsonObject.put("mask", getMask()); + return jsonObject; + } + + return this.getValue(); + } + + @Override + default void write(JSONSerializer jsonSerializer, Object o, Type type, int i) { + if (isWriteJSONObjectEnabled()) { + jsonSerializer.write(getWriteJSONObject()); + } else { + jsonSerializer.write(getValue()); + } + } + + /** + * 自定义fastJson枚举序列化 + */ + @Slf4j + @AllArgsConstructor + @NoArgsConstructor + class EnumDictJSONDeserializer extends JsonDeserializer implements ObjectDeserializer { + private Function mapper; + + @Override + @SuppressWarnings("all") + public T deserialze(DefaultJSONParser parser, Type type, Object fieldName) { + try { + Object value; + final JSONLexer lexer = parser.lexer; + final int token = lexer.token(); + if (token == JSONToken.LITERAL_INT) { + int intValue = lexer.intValue(); + lexer.nextToken(JSONToken.COMMA); + + return (T) EnumDict.find((Class) type, intValue).orElse(null); + } else if (token == JSONToken.LITERAL_STRING) { + String name = lexer.stringVal(); + lexer.nextToken(JSONToken.COMMA); + + if (name.length() == 0) { + return (T) null; + } + return (T) EnumDict.find((Class) type, name).orElse(null); + } else if (token == JSONToken.NULL) { + lexer.nextToken(JSONToken.COMMA); + return null; + } else { + value = parser.parse(); + if (value instanceof Map) { + return (T) EnumDict.find(((Class) type), ((Map) value).get("value")) + .orElseGet(() -> + EnumDict + .find(((Class) type), ((Map) value).get("text")) + .orElse(null)); + } + } + + throw new JSONException("parse enum " + type + " error, value : " + value); + } catch (JSONException e) { + throw e; + } catch (Exception e) { + throw new JSONException(e.getMessage(), e); + } + } + + @Override + public int getFastMatchToken() { + return JSONToken.LITERAL_STRING; + } + + @Override + @SuppressWarnings("all") + @SneakyThrows + public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException { + JsonNode node = jp.getCodec().readTree(jp); + if (mapper != null) { + if (node.isTextual()) { + return mapper.apply(node.asText()); + } + if (node.isNumber()) { + return mapper.apply(node.asLong()); + } + if (node.isObject()) { + JsonNode value = node.get("value"); + if (value == null) { + value = node.get("text"); + } + if (value != null) { + return mapper.apply(value.asText()); + } + } + } + String currentName = jp.currentName(); + Object currentValue = jp.getCurrentValue(); + Class findPropertyType; + if (StringUtils.isEmpty(currentName) || StringUtils.isEmpty(currentValue)) { + return null; + } else { + findPropertyType = BeanUtils.findPropertyType(currentName, currentValue.getClass()); + } + Supplier exceptionSupplier = () -> { + List values = Stream + .of(findPropertyType.getEnumConstants()) + .map(Enum.class::cast) + .map(e -> { + if (e instanceof EnumDict) { + return ((EnumDict) e).getValue(); + } + return e.name(); + }).collect(Collectors.toList()); + + return new ValidationException(currentName, "validation.parameter_does_not_exist_in_enums", currentName); + }; + if (EnumDict.class.isAssignableFrom(findPropertyType) && findPropertyType.isEnum()) { + if (node.isObject()) { + JsonNode valueNode = node.get("value"); + Object value = null; + if (valueNode != null) { + if (valueNode.isTextual()) { + value = valueNode.textValue(); + } else if (valueNode.isNumber()) { + value = valueNode.numberValue(); + } + } + return (EnumDict) EnumDict + .findByValue(findPropertyType, value) + .orElseThrow(exceptionSupplier); + } + if (node.isNumber()) { + return (EnumDict) EnumDict + .find(findPropertyType, node.numberValue()) + .orElseThrow(exceptionSupplier); + } + if (node.isTextual()) { + return (EnumDict) EnumDict + .find(findPropertyType, node.textValue()) + .orElseThrow(exceptionSupplier); + } + return exceptionSupplier.get(); + } + if (findPropertyType.isEnum()) { + return Stream + .of(findPropertyType.getEnumConstants()) + .filter(o -> { + if (node.isTextual()) { + return node.textValue().equalsIgnoreCase(((Enum) o).name()); + } + if (node.isNumber()) { + return node.intValue() == ((Enum) o).ordinal(); + } + return false; + }) + .findAny() + .orElseThrow(exceptionSupplier); + } + + log.warn("unsupported deserialize enum json : {} for: {}@{}", node, currentName, currentValue); + return null; + } + } + + /** + * 创建动态的字典选项 + * + * @param value 值 + * @return 字典选项 + */ + static EnumDict create(String value) { + return create(value, null); + } + + /** + * 创建动态的字典选项 + * + * @param value 值 + * @param text 说明 + * @return 字典选项 + */ + static EnumDict create(String value, String text) { + return DefaultItemDefine + .builder() + .value(value) + .text(text) + .build(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/I18nEnumDict.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/I18nEnumDict.java new file mode 100644 index 000000000..aa3835247 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/I18nEnumDict.java @@ -0,0 +1,60 @@ +package org.hswebframework.web.dict; + +/** + * 国际化支持的枚举数据字典,自动根据 : 类名.name()来获取text.如果没有定义则获取{@link EnumDict#getText()}的值. + * 例: + * 定义枚举并实现{@link I18nEnumDict}接口 + *
+ * package com.domain.dict;
+ *
+ * @AllArgsConstructor
+ * @Getter
+ * @Dict("device-state")
+ * public enum DeviceState implements I18nEnumDict<String> {
+ *     notActive("未启用"),
+ *     offline("离线"),
+ *     online("在线");
+ *
+ *     private final String text;
+ *
+ *     @Override
+ *     public String getValue() {
+ *         return name();
+ *     }
+ *   }
+ * 
+ *

+ * 在resources下添加文件: i18n/{path}/{name}_zh_CN.properties + *

+ * 注意: {path}修改为自己的名称。{name}不能包含下划线(_)。不能存在完全重名的文件。 + *

+ * 正确的格式: i18n/my-module/messages_zh_CN.properties + *

+ * 错误的格式: i18n/my-module/messages_msg_zh_CN.properties + *

+ * 文件内容: + *

+ * com.domain.dict.DeviceState.notActive=未启用
+ * com.domain.dict.DeviceState.offline=离线
+ * com.domain.dict.DeviceState.online=在线
+ * 
+ * + * @param 值类型 + * @author zhouhao + * @since 4.0.11 + */ +public interface I18nEnumDict extends EnumDict { + + /** + * 枚举name + * + * @return name + * @see Enum#name() + */ + String name(); + + @Override + default String getI18nCode() { + return this.getClass().getName() + "." + name(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/ItemDefine.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/ItemDefine.java new file mode 100644 index 000000000..e3dee490d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/ItemDefine.java @@ -0,0 +1,23 @@ +package org.hswebframework.web.dict; + + +/** + * @author zhouhao + * @since 3.0 + */ +public interface ItemDefine extends EnumDict { + String getText(); + + String getValue(); + + String getComments(); + + int getOrdinal(); + + @Override + default int ordinal() { + return getOrdinal(); + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultClassDictDefine.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultClassDictDefine.java new file mode 100644 index 000000000..bb9560e5e --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultClassDictDefine.java @@ -0,0 +1,28 @@ +package org.hswebframework.web.dict.defaults; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hswebframework.web.dict.ClassDictDefine; +import org.hswebframework.web.dict.EnumDict; +import org.hswebframework.web.dict.ItemDefine; + +import java.util.List; + +/** + * @author zhouhao + * @since 3.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DefaultClassDictDefine implements ClassDictDefine { + private static final long serialVersionUID = -4113467848927281082L; + private String field; + private String id; + private String alias; + private String comments; + private List> items; +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultDictDefine.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultDictDefine.java new file mode 100644 index 000000000..dc1bbb850 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultDictDefine.java @@ -0,0 +1,26 @@ +package org.hswebframework.web.dict.defaults; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hswebframework.web.dict.DictDefine; +import org.hswebframework.web.dict.EnumDict; + +import java.util.List; + +/** + * @author zhouhao + * @since 3.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DefaultDictDefine implements DictDefine { + private static final long serialVersionUID = 20094004707177152L; + private String id; + private String alias; + private String comments; + private List> items; +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultDictDefineRepository.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultDictDefineRepository.java new file mode 100644 index 000000000..0a29e2506 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultDictDefineRepository.java @@ -0,0 +1,96 @@ +package org.hswebframework.web.dict.defaults; + +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.utils.StringUtils; +import org.hswebframework.web.dict.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author zhouhao + * @since 3.0 + */ +@Slf4j +public class DefaultDictDefineRepository implements DictDefineRepository { + protected final Map parsedDict = new ConcurrentHashMap<>(); + + public DefaultDictDefineRepository() { + } + + public void registerDefine(DictDefine define) { + if (define == null || define.getId() == null) { + return; + } + parsedDict.put(define.getId(), define); + } + + @SuppressWarnings("all") + public static DictDefine parseEnumDict(Class type) { + + try { + Dict dict = type.getAnnotation(Dict.class); + if (!type.isEnum()) { + return null; + } + + Object[] constants = type.getEnumConstants(); + List> items = new ArrayList<>(constants.length); + + for (Object enumConstant : constants) { + if (enumConstant instanceof EnumDict) { + items.add((EnumDict) enumConstant); + } else { + Enum e = ((Enum) enumConstant); + items.add( + DefaultItemDefine + .builder() + .value(e.name()) + .text(e.name()) + .ordinal(e.ordinal()) + .build()); + } + } + + DefaultDictDefine define = new DefaultDictDefine(); + if (dict != null) { + define.setId(dict.value()); + define.setComments(dict.comments()); + define.setAlias(dict.alias()); + } else { + + String id = StringUtils.camelCase2UnderScoreCase(type.getSimpleName()).replace("_", "-"); + if (id.startsWith("-")) { + id = id.substring(1); + } + define.setId(id); + define.setAlias(type.getSimpleName()); +// define.setComments(); + } + define.setItems(items); + log.trace("parse enum dict : {} as : {}", type, define.getId()); + return define; + } catch (Throwable e) { + log.warn("parse enum class [{}] error", type, e); + return null; + } + + } + + @Override + public Mono getDefine(String id) { + return Mono.justOrEmpty(parsedDict.get(id)); + } + + @Override + public Flux getAllDefine() { + return Flux.fromIterable(parsedDict.values()); + } + + @Override + public void addDefine(DictDefine dictDefine) { + registerDefine(dictDefine); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultItemDefine.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultItemDefine.java new file mode 100644 index 000000000..ff73093c9 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/defaults/DefaultItemDefine.java @@ -0,0 +1,41 @@ +package org.hswebframework.web.dict.defaults; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hswebframework.web.dict.ItemDefine; +import org.hswebframework.web.i18n.MultipleI18nSupportEntity; + +import java.util.Locale; +import java.util.Map; + +/** + * @author zhouhao + * @since 3.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DefaultItemDefine implements ItemDefine, MultipleI18nSupportEntity { + private static final long serialVersionUID = 1L; + + private String text; + private String value; + private String comments; + private int ordinal; + private Map> i18nMessages; + + public DefaultItemDefine(String text, String value, String comments, int ordinal) { + this.text = text; + this.value = value; + this.comments = comments; + this.ordinal = ordinal; + } + + @Override + public String getI18nMessage(Locale locale) { + return getI18nMessage("text", locale, text); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/enums/TrueOrFalse.java b/hsweb-core/src/main/java/org/hswebframework/web/enums/TrueOrFalse.java new file mode 100644 index 000000000..2d2156263 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/enums/TrueOrFalse.java @@ -0,0 +1,21 @@ +package org.hswebframework.web.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.dict.Dict; +import org.hswebframework.web.dict.EnumDict; + +@Getter +@AllArgsConstructor +@Dict("true-or-false") +public enum TrueOrFalse implements EnumDict { + + TRUE((byte) 1, "是"), + + FALSE((byte) 0, "否"); + + private Byte value; + + private String text; + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/event/AsyncEvent.java b/hsweb-core/src/main/java/org/hswebframework/web/event/AsyncEvent.java new file mode 100644 index 000000000..19f78d79c --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/event/AsyncEvent.java @@ -0,0 +1,43 @@ +package org.hswebframework.web.event; + +import org.reactivestreams.Publisher; +import org.springframework.context.ApplicationEventPublisher; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +/** + * 异步事件,使用响应式编程进行事件监听时,请使用此事件接口 + * + * @author zhouhao + * @since 4.0.5 + */ +public interface AsyncEvent { + + Mono getAsync(); + + /** + * 注册一个异步任务 + * + * @param publisher 异步任务 + */ + void async(Publisher publisher); + + /** + * 注册一个优先级高的任务 + * @param publisher 任务 + */ + void first(Publisher publisher); + + void transformFirst(Function,Publisher> mapper); + + void transform(Function,Publisher> mapper); + + /** + * 推送事件到 ApplicationEventPublisher + * + * @param eventPublisher ApplicationEventPublisher + * @return async void + */ + Mono publish(ApplicationEventPublisher eventPublisher); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/event/AsyncEventHooks.java b/hsweb-core/src/main/java/org/hswebframework/web/event/AsyncEventHooks.java new file mode 100644 index 000000000..71727f120 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/event/AsyncEventHooks.java @@ -0,0 +1,62 @@ +package org.hswebframework.web.event; + +import io.netty.util.concurrent.FastThreadLocal; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.LinkedList; + +public class AsyncEventHooks { + + private static final FastThreadLocal> hooks = new FastThreadLocal>() { + @Override + protected LinkedList initialValue() { + return new LinkedList<>(); + } + }; + + public static AutoUnbindable bind(AsyncEventHook hook) { + LinkedList list = hooks.get(); + list.add(hook); + return () -> list.removeLastOccurrence(hook); + } + + static Mono hookFirst(AsyncEvent event, Mono publisher) { + LinkedList hooksList = hooks.getIfExists(); + if (hooksList == null) { + return publisher; + } + for (AsyncEventHook asyncEventHook : hooksList) { + publisher = asyncEventHook.hookFirst(event, publisher); + } + return publisher; + } + + static Mono hookAsync(AsyncEvent event, Mono publisher) { + LinkedList hooksList = hooks.getIfExists(); + if (hooksList == null) { + return publisher; + } + for (AsyncEventHook asyncEventHook : hooksList) { + publisher = asyncEventHook.hookAsync(event, publisher); + } + return publisher; + } + + + public interface AutoUnbindable extends AutoCloseable { + @Override + void close(); + } + + public interface AsyncEventHook { + default Mono hookAsync(AsyncEvent event, Mono publisher) { + return publisher; + } + + default Mono hookFirst(AsyncEvent event, Mono publisher) { + return publisher; + } + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/event/DefaultAsyncEvent.java b/hsweb-core/src/main/java/org/hswebframework/web/event/DefaultAsyncEvent.java new file mode 100644 index 000000000..8307a4922 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/event/DefaultAsyncEvent.java @@ -0,0 +1,55 @@ +package org.hswebframework.web.event; + +import org.reactivestreams.Publisher; +import org.springframework.context.ApplicationEventPublisher; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +public class DefaultAsyncEvent implements AsyncEvent { + + private transient Mono async = Mono.empty(); + private transient Mono first = Mono.empty(); + + private transient boolean hasListener; + + public synchronized void async(Publisher publisher) { + hasListener = true; + this.async = async.then(AsyncEventHooks.hookAsync(this, Mono.fromDirect(publisher))); + } + + @Override + public synchronized void first(Publisher publisher) { + hasListener = true; + this.first = AsyncEventHooks.hookFirst(this, Mono.fromDirect(publisher)).then(first); + } + + @Override + public synchronized void transformFirst(Function, Publisher> mapper) { + hasListener = true; + this.first = Mono.fromDirect(mapper.apply(this.first)); + } + + @Override + public synchronized void transform(Function, Publisher> mapper) { + hasListener = true; + this.async = Mono.fromDirect(mapper.apply(this.async)); + } + + @Override + public Mono getAsync() { + return this.first.then(this.async).then(); + } + + @Override + public Mono publish(ApplicationEventPublisher eventPublisher) { + + eventPublisher.publishEvent(this); + + return getAsync(); + } + + public boolean hasListener() { + return hasListener; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/event/GenericsPayloadApplicationEvent.java b/hsweb-core/src/main/java/org/hswebframework/web/event/GenericsPayloadApplicationEvent.java new file mode 100644 index 000000000..8f8ab4de0 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/event/GenericsPayloadApplicationEvent.java @@ -0,0 +1,49 @@ +package org.hswebframework.web.event; + +import org.springframework.context.PayloadApplicationEvent; +import org.springframework.core.ResolvableType; + +/** + * 动态泛型事件,用于动态发布支持泛型的事件 + *
+ *     //相当于发布事件: EntityModifyEvent<UserEntity>
+ *     eventPublisher
+ *          .publishEvent(new GenericsPayloadApplicationEvent<>(this, new EntityModifyEvent<>(oldEntity, newEntity), UserEntity.class));
+ *
+ *      //只监听相同泛型事件
+ *      @EventListener
+ *      public handleEvent(EntityModifyEvent<UserEntity> event){
+ *
+ *      }
+ * 
+ * + * @author zhouhao + * @since 3.0.7 + */ +public class GenericsPayloadApplicationEvent extends PayloadApplicationEvent { + + private static final long serialVersionUID = 3745888943307798710L; + + //泛型列表 + private transient Class[] generics; + + //事件类型 + private transient Class eventType; + + /** + * @param source 事件源 + * @param payload 事件,不能使用匿名内部类 + * @param generics 泛型列表 + */ + public GenericsPayloadApplicationEvent(Object source, E payload, Class... generics) { + super(source, payload); + this.generics = generics; + this.eventType = payload.getClass(); + } + + @Override + public ResolvableType getResolvableType() { + return ResolvableType.forClassWithGenerics(PayloadApplicationEvent.class + , ResolvableType.forClassWithGenerics(eventType, generics)); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java new file mode 100644 index 000000000..a74b541de --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java @@ -0,0 +1,100 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.exception; + +import lombok.Getter; + +/** + * 业务异常 + * + * @author zhouhao + * @since 2.0 + */ +@Getter +public class BusinessException extends I18nSupportException { + private static final long serialVersionUID = 5441923856899380112L; + + private int status = 500; + private String code; + + public BusinessException(String message) { + this(message, 500); + } + + public BusinessException(String message, int status, Object... args) { + this(message, null, status, args); + } + + public BusinessException(String message, String code) { + this(message, code, 500); + } + + + public BusinessException(String message, String code, int status, Object... args) { + super(message, args); + this.code = code; + this.status = status; + } + + + public BusinessException(String message, Throwable cause) { + super(message, cause); + } + + public BusinessException(String message, Throwable cause, int status) { + super(message, cause); + this.status = status; + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends BusinessException { + public NoStackTrace(String message) { + this(message, 500); + } + + public NoStackTrace(String message, int status, Object... args) { + this(message, null, status, args); + } + + public NoStackTrace(String message, String code) { + this(message, code, 500); + } + + public NoStackTrace(String message, String code, int status, Object... args) { + super(message, code, status, args); + + } + + public NoStackTrace(String message, Throwable cause) { + super(message, cause); + } + + public NoStackTrace(String message, Throwable cause, int status) { + super(message, cause, status); + } + + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/I18nSupportException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/I18nSupportException.java new file mode 100644 index 000000000..9a5e8a787 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/I18nSupportException.java @@ -0,0 +1,116 @@ +package org.hswebframework.web.exception; + + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.i18n.LocaleUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import java.util.Locale; + +/** + * 支持国际化消息的异常,code为 + * + * @author zhouhao + * @see LocaleUtils#resolveMessage(String, Object...) + * @since 4.0.11 + */ +@Getter +@Setter(AccessLevel.PROTECTED) +public class I18nSupportException extends TraceSourceException { + + /** + * 消息code,在message.properties文件中定义的key + */ + private String i18nCode; + + /** + * 消息参数 + */ + private Object[] args; + + protected I18nSupportException() { + + } + + public I18nSupportException(String code, Object... args) { + super(code); + this.i18nCode = code; + this.args = args; + } + + public I18nSupportException(String code, Throwable cause, Object... args) { + super(code, cause); + this.args = args; + this.i18nCode = code; + } + + public String getOriginalMessage() { + return super.getMessage() != null ? super.getMessage() : getI18nCode(); + } + + @Override + public String getMessage() { + return getLocalizedMessage(); + } + + @Override + public final String getLocalizedMessage() { + return getLocalizedMessage(LocaleUtils.current()); + } + + public String getLocalizedMessage(Locale locale) { + return LocaleUtils.resolveMessage(i18nCode, locale, getOriginalMessage(), args); + } + + public final Mono getLocalizedMessageReactive() { + return LocaleUtils + .currentReactive() + .map(this::getLocalizedMessage); + } + + public static String tryGetLocalizedMessage(Throwable error, Locale locale) { + if (error instanceof I18nSupportException) { + return ((I18nSupportException) error).getLocalizedMessage(locale); + } + String msg = error.getMessage(); + + if (!StringUtils.hasText(msg)) { + msg = "error." + error.getClass().getSimpleName(); + } + if (msg.contains(".")) { + return LocaleUtils.resolveMessage(msg, locale, msg); + } + return msg; + } + + public static String tryGetLocalizedMessage(Throwable error) { + return tryGetLocalizedMessage(error, LocaleUtils.current()); + } + + public static Mono tryGetLocalizedMessageReactive(Throwable error) { + return LocaleUtils + .currentReactive() + .map(locale -> tryGetLocalizedMessage(error, locale)); + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends I18nSupportException { + public NoStackTrace(String code, Object... args) { + super(code, args); + } + + public NoStackTrace(String code, Throwable cause, Object... args) { + super(code, cause, args); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java new file mode 100644 index 000000000..bcfabe713 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java @@ -0,0 +1,52 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class NotFoundException extends BusinessException { + public NotFoundException(String message, Object... args) { + super(message, 404, args); + } + + public NotFoundException() { + this("error.not_found"); + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends NotFoundException { + public NoStackTrace(String code, Object... args) { + super(code, args); + } + + public NoStackTrace() { + super(); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/TraceSourceException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/TraceSourceException.java new file mode 100644 index 000000000..edd0cd4e4 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/TraceSourceException.java @@ -0,0 +1,249 @@ +package org.hswebframework.web.exception; + +import org.hswebframework.web.i18n.LocaleUtils; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import javax.annotation.Nullable; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; + +/** + * 支持溯源的异常,通过{@link TraceSourceException#withSource(Object) }来标识异常的源头. + * 在捕获异常的地方通过获取异常源来处理一些逻辑,比如判断是由哪条数据发生的错误等操作. + * + * @author zhouhao + * @since 4.0.15 + */ +public class TraceSourceException extends RuntimeException { + + private static final String deepTraceKey = TraceSourceException.class.getName() + "_deep"; + private static final Context deepTraceContext = Context.of(deepTraceKey, true); + + private String operation; + + private Object source; + + public TraceSourceException() { + + } + + public TraceSourceException(String message) { + super(message); + } + + public TraceSourceException(Throwable e) { + super(e.getMessage(), e); + } + + public TraceSourceException(String message, Throwable e) { + super(message, e); + } + + @Nullable + public Object getSource() { + return source; + } + + @Nullable + public String getOperation() { + return operation; + } + + public TraceSourceException withSource(Object source) { + this.source = source; + return self(); + } + + public TraceSourceException withSource(String operation, Object source) { + this.operation = operation; + this.source = source; + return self(); + } + + protected TraceSourceException self() { + return this; + } + + /** + * 深度溯源上下文,用来标识是否是深度溯源的异常.开启深度追踪后,会创建新的{@link TraceSourceException}对象. + * + * @return 上下文 + * @see Flux#contextWrite(ContextView) + * @see Mono#contextWrite(ContextView) + */ + @Deprecated + public static Context deepTraceContext() { + return deepTraceContext; + } + + public static Function> transfer(Object source) { + return transfer(null, source); + } + + + /** + * 溯源异常转换器.通常配合{@link Mono#onErrorResume(Function)}使用. + *

+ * 转换逻辑: + *

+ * 1. 如果捕获的异常不是TraceSourceException,则直接创建新的TraceSourceException并返回. + *

+ * 2. 如果捕获的异常是TraceSourceException,并且上下文没有指定{@link TraceSourceException#deepTraceContext()}, + * 则修改捕获的TraceSourceException异常中的source.如果上下文中指定了{@link TraceSourceException#deepTraceContext()} + * 则创建新的TraceSourceException + * + *

{@code
+     *
+     *  doSomething()
+     *  .onErrorResume(TraceSourceException.transfer(data))
+     *
+     * }
+ * + * @param operation 操作名称 + * @param source 源 + * @param 泛型 + * @return 转换器 + * @see Flux#onErrorResume(Function) + * @see Mono#onErrorResume(Function) + */ + public static Function> transfer(String operation, Object source) { + if (source == null && operation == null) { + return Mono::error; + } + return err -> Mono.error(transform(err, operation, source)); + } + + /** + * 填充溯源信息到异常中 + * + * @param error 异常 + * @param operation 操作名称 + * @param source 源数据 + * @return 填充后的异常 + */ + public static Throwable transform(Throwable error, String operation, Object source) { + error.addSuppressed( + new StacklessTraceSourceException().withSource(operation, source) + ); + return error; + } + + public static Object tryGetSource(Throwable err) { + + if (err instanceof TraceSourceException) { + return ((TraceSourceException) err).getSource(); + } + + for (Throwable throwable : err.getSuppressed()) { + Object source = tryGetSource(throwable); + if (source != null) { + return source; + } + } + + Throwable cause = err.getCause(); + + if (cause != null) { + return tryGetSource(cause); + } + + return null; + } + + public static String tryGetOperation(Throwable err) { + if (err instanceof TraceSourceException) { + return ((TraceSourceException) err).getOperation(); + } + + for (Throwable throwable : err.getSuppressed()) { + String operation = tryGetOperation(throwable); + if (operation != null) { + return operation; + } + } + + Throwable cause = err.getCause(); + if (cause != null) { + return tryGetOperation(cause); + } + return null; + } + + protected String getExceptionName() { + return this.getClass().getCanonicalName(); + } + + @Override + public String toString() { + String className = getExceptionName(); + String message = this.getLocalizedMessage(); + String operation = this.operation; + String source = Optional + .ofNullable(this.source) + .map(Object::toString) + .orElse(null); + + StringBuilder builder = new StringBuilder( + className.length() + + (message == null ? 0 : message.length()) + + (operation == null ? 0 : operation.length()) + + (source == null ? 0 : source.length())); + + builder.append(className); + if (message != null) { + builder.append(':').append(message); + } + if (operation != null) { + builder.append("\n\t[Operation] ⇢ ").append(operation); + } + if (source != null) { + builder.append("\n\t [Source] ⇢ ").append(source); + } + + return builder.toString(); + } + + public static String tryGetOperationLocalized(Throwable err, Locale locale) { + String opt = tryGetOperation(err); + return StringUtils.hasText(opt) ? LocaleUtils.resolveMessage(opt, locale, opt) : opt; + } + + public static Mono tryGetOperationLocalizedReactive(Throwable err) { + return LocaleUtils + .currentReactive() + .handle((locale, sink) -> { + String opt = tryGetOperationLocalized(err, locale); + if (opt != null) { + sink.next(opt); + } + }); + } + + public static class StacklessTraceSourceException extends TraceSourceException { + public StacklessTraceSourceException() { + super(); + } + + public StacklessTraceSourceException(String message) { + super(message); + } + + public StacklessTraceSourceException(Throwable e) { + super(e.getMessage(), e); + } + + public StacklessTraceSourceException(String message, Throwable e) { + super(message, e); + } + + @Override + public synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java new file mode 100644 index 000000000..370b974bc --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java @@ -0,0 +1,132 @@ +package org.hswebframework.web.exception; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.i18n.LocaleUtils; +import org.springframework.http.HttpStatus; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ResponseStatus; + +import javax.validation.ConstraintViolation; +import java.util.*; +import java.util.stream.Collectors; + +@Getter +@Setter +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class ValidationException extends I18nSupportException { + + private static final boolean propertyI18nEnabled = Boolean.getBoolean("i18n.validation.property.enabled"); + + private List details; + + public ValidationException(String message) { + super(message); + } + + public ValidationException(String property, String message, Object... args) { + this(message, Collections.singletonList(new Detail(property, message, null)), args); + } + + public ValidationException(String message, List details, Object... args) { + super(message, args); + this.details = details; + } + + public ValidationException(Set> violations) { + ConstraintViolation first = violations.iterator().next(); + if (Objects.equals(first.getMessageTemplate(), first.getMessage())) { + //模版和消息相同,说明是自定义的message,而不是已经通过i18n获取的. + setI18nCode(first.getMessage()); + } else { + setI18nCode("validation.property_validate_failed"); + } + String property = first.getPropertyPath().toString(); + + //{0} 属性 ,{1} 验证消息 + //property也支持国际化? + String propertyI18n = propertyI18nEnabled ? + first.getRootBeanClass().getName() + "." + property + : property; + + setArgs(new Object[]{propertyI18n, first.getMessage()}); + + details = new ArrayList<>(violations.size()); + for (ConstraintViolation violation : violations) { + details.add(new Detail(violation.getPropertyPath().toString(), + violation.getMessage(), + null)); + } + } + + public List getDetails(Locale locale) { + return CollectionUtils.isEmpty(details) + ? Collections.emptyList() + : details + .stream() + .map(detail -> detail.translateI18n(locale)) + .collect(Collectors.toList()); + } + + @Override + public String getLocalizedMessage(Locale locale) { + if (propertyI18nEnabled && "validation.property_validate_failed".equals(getI18nCode()) && getArgs().length > 0) { + Object[] args = getArgs().clone(); + args[0] = LocaleUtils.resolveMessage(String.valueOf(args[0]), locale, String.valueOf(args[0])); + return LocaleUtils.resolveMessage(getI18nCode(), locale, getOriginalMessage(), args); + } + return super.getLocalizedMessage(locale); + } + + @Getter + @Setter + @AllArgsConstructor + public static class Detail { + + @Schema(description = "字段") + String property; + + @Schema(description = "说明") + String message; + + @Schema(description = "详情") + Object detail; + + public Detail translateI18n(Locale locale) { + if (StringUtils.hasText(message) && message.contains(".")) { + return new Detail(property, LocaleUtils.resolveMessage(message, locale, message), detail); + } + return this; + } + } + + /** + * 不填充线程栈的异常,在一些对线程栈不敏感,且对异常不可控(如: 来自未认证请求产生的异常)的情况下不填充线程栈对性能有利。 + */ + public static class NoStackTrace extends ValidationException { + public NoStackTrace(String message) { + super(message); + } + + public NoStackTrace(String property, String message, Object... args) { + super(property, message, args); + } + + public NoStackTrace(String message, List details, Object... args) { + super(message, details, args); + + } + + public NoStackTrace(Set> violations) { + super(violations); + } + + @Override + public final synchronized Throwable fillInStackTrace() { + return this; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzer.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzer.java new file mode 100644 index 000000000..f7dcfd60c --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzer.java @@ -0,0 +1,27 @@ +package org.hswebframework.web.exception.analyzer; + +/** + * 异常分析器,用于分析异常信息. 实现此接口,并使用SPI进行拓展. + * + *
{@code
+ *
+ *  META-INF/services/org.hswebframework.web.exception.analyzer.ExceptionAnalyzer
+ *
+ * }
+ * + * @author zhouhao + * @since 4.0.18 + * @see ExceptionAnalyzerReporter + */ +public interface ExceptionAnalyzer { + + /** + * 执行分析 + * + * @param error 异常信息 + * @return 是否被处理 + */ + boolean analyze(Throwable error); + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzerReporter.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzerReporter.java new file mode 100644 index 000000000..d2bd60afc --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzerReporter.java @@ -0,0 +1,84 @@ +package org.hswebframework.web.exception.analyzer; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * 提供基础的异常分析器实现 + * + * @author zhouhao + * @since 4.0.18 + */ +@Slf4j +public class ExceptionAnalyzerReporter implements ExceptionAnalyzer { + + private final List reporter = new CopyOnWriteArrayList<>(); + + + public static String wrapLog(String message) { + char[] arr = new char[message.length() + 2]; + Arrays.fill(arr, '='); + arr[0] = '\n'; + arr[arr.length - 1] = '\n'; + String line = new String(arr); + return line + message + line; + } + + protected void addReporter(Predicate predicate, + Consumer reporter) { + this.reporter.add(new Reporter() { + @Override + public boolean predicate(Throwable error) { + return predicate.test(error); + } + + @Override + public void report(Throwable error) { + reporter.accept(error); + } + }); + } + + protected void addSimpleReporter(Pattern pattern, Consumer reporter) { + + addReporter((error) -> { + if (error.getMessage() == null) { + return pattern.matcher(error.toString()).matches(); + } + return pattern.matcher(error.getMessage()).matches() || pattern.matcher(error.toString()).matches(); + }, reporter); + } + + public boolean doReportException(Throwable failure) { + Throwable cause = failure; + while (cause != null) { + for (Reporter _reporter : this.reporter) { + if (_reporter.predicate(cause)) { + _reporter.report(cause); + return true; + } + } + cause = cause.getCause(); + } + return false; + } + + @Override + public boolean analyze(Throwable error) { + return doReportException(error); + } + + interface Reporter { + + boolean predicate(Throwable error); + + void report(Throwable error); + + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzers.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzers.java new file mode 100644 index 000000000..626b3b753 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/analyzer/ExceptionAnalyzers.java @@ -0,0 +1,47 @@ +package org.hswebframework.web.exception.analyzer; + +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.ServiceLoader; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 异常分析器,用于分析异常信息.使用{@link ExceptionAnalyzer}进行分析拓展. + * + * @author zhouhao + * @see ExceptionAnalyzer + * @since 4.0.18 + */ +@Slf4j +public class ExceptionAnalyzers { + + private static final List ANALYZER = new CopyOnWriteArrayList<>(); + + private ExceptionAnalyzers() { + + } + + static { + ServiceLoader.load(ExceptionAnalyzer.class).forEach(ANALYZER::add); + } + + public static void addAnalyzer(ExceptionAnalyzer analyzer) { + log.debug("add ExceptionAnalyzer:{}", analyzer); + ANALYZER.add(analyzer); + } + + public static boolean analyze(Throwable failure) { + Throwable cause = failure; + while (cause != null) { + for (ExceptionAnalyzer _analyzer : ANALYZER) { + if (_analyzer.analyze(cause)) { + return true; + } + } + cause = cause.getCause(); + } + return false; + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/ContextLocaleResolver.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/ContextLocaleResolver.java new file mode 100644 index 000000000..cfc7b986f --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/ContextLocaleResolver.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.i18n; + +import org.hibernate.validator.spi.messageinterpolation.LocaleResolver; +import org.hibernate.validator.spi.messageinterpolation.LocaleResolverContext; + +import java.util.Locale; + +public class ContextLocaleResolver implements LocaleResolver { + @Override + public Locale resolve(LocaleResolverContext context) { + return LocaleUtils.current(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/I18nSupportEntity.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/I18nSupportEntity.java new file mode 100644 index 000000000..d86781d2f --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/I18nSupportEntity.java @@ -0,0 +1,73 @@ +package org.hswebframework.web.i18n; + +import org.apache.commons.collections4.MapUtils; + +import java.util.Locale; +import java.util.Map; + +/** + * 国际化支持实体,实现此接口,提供基础的国际化支持.如:针对实体类某些字段的国际化支持. + * + * @author zhouhao + * @since 4.0.18 + * @see SingleI18nSupportEntity + * @see MultipleI18nSupportEntity + */ +public interface I18nSupportEntity { + + /** + * 根据key获取全部国际化信息,key为地区标识,value为国际化消息. + *
{@code
+     *
+     *    {"zh":"你好","en":"hello"}
+     *
+     *  }
+ * + * @param key key + * @return 国际化信息 + */ + Map getI18nMessages(String key); + + /** + * 根据当前地区获取,指定key的国际化信息. + *
{@code
+     *
+     *    public String getI18nName(){
+     *        return getI18nMessages("name",this.name);
+     *    }
+     *
+     * }
+ * + * @param key key + * @return 国际化信息 + * @see LocaleUtils#transform + */ + default String getI18nMessage(String key, String defaultMessage) { + return getI18nMessage(key, LocaleUtils.current(), defaultMessage); + } + + /** + * 根据指定的语言地区,获取指定key的国际化信息. + *
{@code
+     *
+     *    public String getI18nName(){
+     *        return getI18nMessages("name",Locale.US,this.name);
+     *    }
+     *
+     * }
+ * + * @param key key + * @return 国际化信息 + */ + default String getI18nMessage(String key, Locale locale, String defaultMessage) { + + Map entries = getI18nMessages(key); + + if (MapUtils.isEmpty(entries)) { + return defaultMessage; + } + + return LocaleUtils.getMessage(entries::get, locale, () -> defaultMessage); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/I18nSupportUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/I18nSupportUtils.java new file mode 100644 index 000000000..e5b48f1ae --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/I18nSupportUtils.java @@ -0,0 +1,52 @@ +package org.hswebframework.web.i18n; + +import org.apache.commons.collections4.MapUtils; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public class I18nSupportUtils { + + public static Map> putI18nMessages(String i18nKey, + String property, + Collection locales, + String defaultMsg, + Map> container) { + if (container == null) { + container = new HashMap<>(); + } + + container.compute( + property, + (p, c) -> { + Map msg = putI18nMessages(i18nKey, locales, defaultMsg, c); + //为空不存储 + return MapUtils.isEmpty(msg) ? null : msg; + }); + + return container; + } + + public static Map putI18nMessages(String i18nKey, + Collection locales, + String defaultMsg, + Map container) { + if (container == null) { + container = new HashMap<>(); + } + + for (Locale locale : locales) { + String msg = LocaleUtils.resolveMessage(i18nKey, locale, defaultMsg); + if (StringUtils.hasText(msg)) { + container.put(locale.toString(), msg); + } + } + + return container; + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/LocaleUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/LocaleUtils.java new file mode 100644 index 000000000..b7f555eeb --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/LocaleUtils.java @@ -0,0 +1,607 @@ +package org.hswebframework.web.i18n; + +import io.netty.util.concurrent.FastThreadLocal; +import lombok.AllArgsConstructor; +import org.hswebframework.web.exception.I18nSupportException; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscription; +import org.springframework.context.MessageSource; +import reactor.core.CoreSubscriber; +import reactor.core.publisher.*; +import reactor.util.context.Context; + +import javax.annotation.Nonnull; +import java.util.*; +import java.util.concurrent.Callable; +import java.util.function.*; + +/** + * 用于进行国际化消息转换 + * 常用方法: + * + *
    + *
  • {@link LocaleUtils#current()}
  • + *
  • {@link LocaleUtils#currentReactive()}
  • + *
  • {@link LocaleUtils#resolveMessageReactive(String, Object...)}
  • + *
+ * + * @author zhouhao + * @since 4.0.11 + */ +public final class LocaleUtils { + + public static final Locale DEFAULT_LOCALE = Locale.getDefault(); + + private static final FastThreadLocal CONTEXT_THREAD_LOCAL = new FastThreadLocal<>(); + + static MessageSource messageSource = UnsupportedMessageSource.instance(); + + static Set supportsLocales; + + static { + supportsLocales = new HashSet<>(); + supportsLocales.add(Locale.CHINESE); + supportsLocales.add(Locale.ENGLISH); + String prop = System.getProperty("hsweb.locale.supports"); + if (prop != null) { + try { + for (String locale : prop.split(",")) { + if (locale.isEmpty()) { + continue; + } + supportsLocales.add(Locale.forLanguageTag(locale)); + } + } catch (Throwable e) { + System.err.println("error parse hsweb.locale.supports :" + prop); + } + } + } + + /** + * 获取支持的语言地区,默认支持中文和英文,可通过jvm参数: -Dhsweb.locale.supports=zh,en 来指定支持的语言地区 + * + * @return 支持的语言地区 + */ + public static Set getSupportLocales() { + return Collections.unmodifiableSet(supportsLocales); + } + + /** + * 从指定数据源中获取国际化消息 + * + * @param messageSource 消息源 + * @param locale 语言地区 + * @param defaultMessage 默认消息 + */ + public static String getMessage(Function messageSource, + Locale locale, + Supplier defaultMessage) { + String str = locale.toString(); + String msg = messageSource.apply(str); + if (msg == null) { + msg = messageSource.apply(locale.getLanguage()); + } + return msg == null ? defaultMessage.get() : msg; + } + + /** + * 获取当前的语言地区,如果没有设置则返回系统默认语言 + * + * @return Locale + */ + public static Locale current() { + Locale locale = CONTEXT_THREAD_LOCAL.get(); + if (locale == null) { + locale = DEFAULT_LOCALE; + } + return locale; + } + + /** + * 在指定的区域中执行函数,只能在非响应式同步操作时使用,如:转换实体类中某些属性的国际化消息。 + *

+ * 在函数的逻辑中可以通过{@link LocaleUtils#current()}来获取当前语言. + * + * @param data 参数 + * @param locale 区域 + * @param mapper 函数 + * @param 参数类型 + * @param 函数返回类型 + * @return 返回值 + */ + public static R doWith(T data, Locale locale, BiFunction mapper) { + Locale old = CONTEXT_THREAD_LOCAL.get(); + try { + CONTEXT_THREAD_LOCAL.set(locale); + return mapper.apply(data, locale); + } finally { + CONTEXT_THREAD_LOCAL.set(old); + } + } + + /** + * 使用指定的区域来执行某些操作 + * + * @param locale 区域 + * @param consumer 任务 + */ + public static void doWith(Locale locale, Consumer consumer) { + Locale old = CONTEXT_THREAD_LOCAL.get(); + try { + CONTEXT_THREAD_LOCAL.set(locale); + consumer.accept(locale); + } finally { + CONTEXT_THREAD_LOCAL.set(old); + } + } + + /** + * 在响应式作用,使用指定的区域作为语言环境,在下游则可以使用{@link LocaleUtils#currentReactive()}来获取 + *

+ *

+     * monoOrFlux
+     * .contextWrite(LocaleUtils.useLocale(locale))
+     * 
+ * + * @param locale 区域 + * @return 上下为构造函数 + */ + public static Function useLocale(Locale locale) { + return ctx -> ctx.put(Locale.class, locale); + } + + /** + * 响应式方式获取当前区域 + * + * @return 区域 + */ + @SuppressWarnings("all") + public static Mono currentReactive() { + return Mono + .deferContextual(ctx -> Mono.just(ctx.getOrDefault(Locale.class, DEFAULT_LOCALE))); + } + + public static Mono doInReactive(Callable call) { + return currentReactive() + .handle((locale, sink) -> { + Locale old = CONTEXT_THREAD_LOCAL.get(); + try { + CONTEXT_THREAD_LOCAL.set(locale); + T data = call.call(); + if (data != null) { + sink.next(data); + } + } catch (Throwable e) { + sink.error(e); + } finally { + CONTEXT_THREAD_LOCAL.set(old); + } + }); + } + + /** + * 响应式方式解析出异常的区域消息,并进行结果转换. + *

+ * + *

+     * LocaleUtils
+     *  .resolveThrowable(error,(err,msg)-> createResponse(err,msg) );
+     * 
+ * + * @param source 异常 + * @param mapper 结果转换器 + * @param 异常类型 + * @param 转换结果类型 + * @return 转换后的结果 + * @see LocaleUtils#doWithReactive(Object, Function, BiFunction, Object...) + */ + public static Mono resolveThrowable(S source, + BiFunction mapper) { + return resolveThrowable(messageSource, source, mapper); + } + + /** + * 指定消息源,响应式方式解析出异常的区域消息,并进行结果转换. + *

+ * + *

+     * LocaleUtils
+     *  .resolveThrowable(source,error,(err,msg)-> createResponse(err,msg) );
+     * 
+ * + * @param messageSource 消息源 + * @param source 异常 + * @param mapper 结果转换器 + * @param 异常类型 + * @param 转换结果类型 + * @return 转换后的结果 + * @see LocaleUtils#doWithReactive(Object, Function, BiFunction, Object...) + */ + public static Mono resolveThrowable(MessageSource messageSource, + S source, + BiFunction mapper) { + return doWithReactive(messageSource, source, I18nSupportException::getI18nCode, mapper, source.getArgs()); + } + + /** + * 使用参数,响应式方式解析出异常的区域消息,并进行结果转换. + *

+ * 参数对应消息模版中的{n} + *

+ * + *

+     * LocaleUtils
+     *  .resolveThrowable(source,error,(err,msg)-> createResponse(err,msg) );
+     * 
+ * + * @param source 异常 + * @param mapper 结果转换器 + * @param args 参数 + * @param 异常类型 + * @param 转换结果类型 + * @return 转换后的结果 + * @see LocaleUtils#doWithReactive(Object, Function, BiFunction, Object...) + * @see java.text.MessageFormat + */ + public static Mono resolveThrowable(S source, + BiFunction mapper, + Object... args) { + return resolveThrowable(messageSource, source, mapper, args); + } + + /** + * 使用参数,指定消息源,响应式方式解析出异常的区域消息,并进行结果转换. + *

+ * 参数对应消息模版中的{n} + *

+ * + *

+     * LocaleUtils
+     *  .resolveThrowable(source,error,(err,msg)-> createResponse(err,msg) );
+     * 
+ * + * @param source 异常 + * @param mapper 结果转换器 + * @param args 参数 + * @param 异常类型 + * @param 转换结果类型 + * @return 转换后的结果 + * @see LocaleUtils#doWithReactive(Object, Function, BiFunction, Object...) + * @see java.text.MessageFormat + */ + public static Mono resolveThrowable(MessageSource messageSource, + S source, + BiFunction mapper, + Object... args) { + if (source instanceof I18nSupportException && args.length == 0) { + I18nSupportException ex = ((I18nSupportException) source); + return resolveThrowable(ex, (err, msg) -> mapper.apply(source, msg)); + } + return doWithReactive(messageSource, source, Throwable::getMessage, mapper, args); + } + + /** + * 在响应式环境中处理区域消息并转换为新的结果 + * + * @param source 数据 + * @param message 消息转换 + * @param mapper 数据转换 + * @param args 参数 + * @param 数据类型 + * @param 结果类型 + * @return 转换结果 + * @see java.text.MessageFormat + */ + public static Mono doWithReactive(S source, + Function message, + BiFunction mapper, + Object... args) { + return doWithReactive(messageSource, source, message, mapper, args); + } + + /** + * 指定消息源,在响应式环境中处理区域消息并转换为新的结果 + * + * @param source 数据 + * @param message 消息转换 + * @param mapper 数据转换 + * @param args 参数 + * @param 数据类型 + * @param 结果类型 + * @return 转换结果 + * @see java.text.MessageFormat + */ + public static Mono doWithReactive(MessageSource messageSource, + S source, + Function message, + BiFunction mapper, + Object... args) { + return currentReactive() + .map(locale -> { + String msg = message.apply(source); + String newMsg = resolveMessage(messageSource, locale, msg, msg, args); + return mapper.apply(source, newMsg); + }); + } + + /** + * 使用默认的消息源,响应式方式解析消息 + * + * @param code 消息编码 + * @param args 参数 + * @return 解析后的消息 + */ + public static Mono resolveMessageReactive(String code, + Object... args) { + return currentReactive() + .map(locale -> resolveMessage(messageSource, locale, code, code, args)); + } + + /** + * 使用指定的消息源,响应式方式解析消息 + * + * @param messageSource 消息源 + * @param code 消息编码 + * @param args 参数 + * @return 解析后的消息 + */ + public static Mono resolveMessageReactive(MessageSource messageSource, + String code, + Object... args) { + return currentReactive() + .map(locale -> resolveMessage(messageSource, locale, code, code, args)); + } + + /** + * 解析消息 + * + * @param code 消息编码 + * @param locale 地区 + * @param defaultMessage 默认消息 + * @param args 参数 + * @return 解析后的消息 + */ + public static String resolveMessage(String code, + Locale locale, + String defaultMessage, + Object... args) { + return resolveMessage(messageSource, locale, code, defaultMessage, args); + } + + /** + * 使用指定的消息源解析消息 + * + * @param messageSource + * @param code 消息编码 + * @param locale 地区 + * @param defaultMessage 默认消息 + * @param args 参数 + * @return 解析后的消息 + */ + public static String resolveMessage(MessageSource messageSource, + Locale locale, + String code, + String defaultMessage, + Object... args) { + return messageSource.getMessage(code, args, defaultMessage, locale); + } + + /** + * 使用默认消息源和当前地区解析消息 + * + * @param code 消息编码 + * @param args 参数 + * @return 解析后的消息 + */ + public static String resolveMessage(String code, Object... args) { + return resolveMessage(messageSource, current(), code, code, args); + } + + /** + * 使用默认消息源和当前地区解析消息 + * + * @param code 消息编码 + * @param args 参数 + * @param defaultMessage 默认消息 + * @return 解析后的消息 + */ + public static String resolveMessage(String code, + String defaultMessage, + Object... args) { + return resolveMessage(messageSource, current(), code, defaultMessage, args); + } + + /** + * 使用指定消息源和当前地区解析消息 + * + * @param code 消息编码 + * @param args 参数 + * @return 解析后的消息 + */ + public static String resolveMessage(MessageSource messageSource, + String code, + String defaultMessage, + Object... args) { + return resolveMessage(messageSource, current(), code, defaultMessage, args); + } + + + /** + * 在响应式中获取区域并执行指定的操作 + * + * @param operation 操作 + * @param 元素类型 + */ + public static Consumer> on(SignalType type, BiConsumer, Locale> operation) { + return signal -> { + if (signal.getType() != type) { + return; + } + Locale locale = signal.getContextView().getOrDefault(Locale.class, DEFAULT_LOCALE); + + doWith(locale, l -> operation.accept(signal, l)); + }; + } + + /** + * 在响应式的各个周期获取地区并执行指定的操作 + * + *
+     *     monoOrFlux
+     *     .as(LocaleUtils.doOn(ON_NEXT,(signal,locale)-> ... ))
+     *     ...
+     * 
+ * + * @param type 周期类型 + * @param operation 操作 + * @param 响应式流中元素类型 + * @param 响应式流类型 + * @return 原始流 + */ + @SuppressWarnings("all") + public static > Function doOn(SignalType type, BiConsumer, Locale> operation) { + return publisher -> { + if (publisher instanceof Mono) { + return (T) Mono + .from(publisher) + .doOnEach(on(type, operation)); + } + return (T) Flux + .from(publisher) + .doOnEach(on(type, operation)); + }; + } + + /** + *
+     * monoOrFlux
+     * .as(LocaleUtils.doOnNext(element-> .... ))
+     * ...
+     * 
+ */ + public static > Function doOnNext(Consumer operation) { + return doOn(SignalType.ON_NEXT, (s, l) -> operation.accept(s.get())); + } + + /** + *
+     * monoOrFlux
+     * .as(LocaleUtils.doOnNext((element,locale)-> .... ))
+     * ...
+     * 
+ */ + public static > Function doOnNext(BiConsumer operation) { + return doOn(SignalType.ON_NEXT, (s, l) -> operation.accept(s.get(), l)); + } + + /** + *
+     * monoOrFlux
+     * .as(LocaleUtils.doOnError(error-> .... ))
+     * ...
+     * 
+ */ + public static > Function doOnError(Consumer operation) { + return doOn(SignalType.ON_ERROR, (s, l) -> operation.accept(s.getThrowable())); + } + + /** + *
+     * monoOrFlux
+     * .as(LocaleUtils.doOnError((error,locale)-> .... ))
+     * ...
+     * 
+ */ + public static > Function doOnError(BiConsumer operation) { + return doOn(SignalType.ON_ERROR, (s, l) -> operation.accept(s.getThrowable(), l)); + } + + public static Flux transform(Flux flux) { + return new LocaleFlux<>(flux); + } + + public static Mono transform(Mono mono) { + return new LocaleMono<>(mono); + } + + @AllArgsConstructor + static class LocaleMono extends Mono { + private final Mono source; + + @Override + public void subscribe(@Nonnull CoreSubscriber actual) { + doWith(actual, + actual.currentContext().getOrDefault(Locale.class, DEFAULT_LOCALE), + (a, l) -> { + source.subscribe( + new LocaleSwitchSubscriber<>(a) + ); + return null; + } + ); + } + } + + @AllArgsConstructor + static class LocaleFlux extends Flux { + private final Flux source; + + @Override + public void subscribe(@Nonnull CoreSubscriber actual) { + doWith(actual, + actual.currentContext().getOrDefault(Locale.class, DEFAULT_LOCALE), + (a, l) -> { + source.subscribe( + new LocaleSwitchSubscriber<>(a) + ); + return null; + } + ); + } + } + + @AllArgsConstructor + static class LocaleSwitchSubscriber extends BaseSubscriber { + private final CoreSubscriber actual; + + @Override + @Nonnull + public Context currentContext() { + return actual + .currentContext(); + } + + @Override + protected void hookOnSubscribe(@Nonnull Subscription subscription) { + actual.onSubscribe(this); + } + + private Locale current() { + return currentContext() + .getOrDefault(Locale.class, DEFAULT_LOCALE); + } + + @Override + protected void hookOnComplete() { + doWith(current(), (l) -> actual.onComplete()); + } + + @Override + protected void hookOnError(@Nonnull Throwable error) { + + doWith(error, current(), (v, l) -> { + actual.onError(v); + return null; + }); + } + + @Override + protected void hookOnNext(@Nonnull T value) { + + doWith(value, current(), (v, l) -> { + actual.onNext(v); + return null; + }); + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/MessageSourceInitializer.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/MessageSourceInitializer.java new file mode 100644 index 000000000..59072c03d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/MessageSourceInitializer.java @@ -0,0 +1,12 @@ +package org.hswebframework.web.i18n; + +import org.springframework.context.MessageSource; + +public class MessageSourceInitializer { + + public static void init(MessageSource messageSource) { + if (LocaleUtils.messageSource == null || LocaleUtils.messageSource instanceof UnsupportedMessageSource) { + LocaleUtils.messageSource = messageSource; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/MultipleI18nSupportEntity.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/MultipleI18nSupportEntity.java new file mode 100644 index 000000000..7e4c6806b --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/MultipleI18nSupportEntity.java @@ -0,0 +1,51 @@ +package org.hswebframework.web.i18n; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.apache.commons.collections4.MapUtils; + +import java.util.Collections; +import java.util.Map; + +/** + * 支持多个国际化信息的实体类,用于多个字段的国际化支持. + * + * @author zhouhao + * @since 4.0.18 + * @see I18nSupportUtils + */ +public interface MultipleI18nSupportEntity extends I18nSupportEntity { + + /** + * 全部国际化信息,key为字段名,value为国际化信息. + *
{@code
+     *  {
+     *      "name":{"zh":"中文","en":"english"},
+     *      "desc":{"zh":"描述","en":"description"}
+     *  }
+     * }
+ * + * @return 国际化信息 + */ + @Schema(description = "国际化配置", example = "{\"name\":{\"zh\":\"名称\",\"en\":\"Name\"}}") + Map> getI18nMessages(); + + /** + * 根据key获取全部国际化信息,key为地区标识,value为国际化消息. + *
{@code
+     *
+     *    {"zh":"你好","en":"hello"}
+     *
+     *  }
+ * + * @param key key + * @return 国际化信息 + */ + @Override + default Map getI18nMessages(String key) { + Map> source = getI18nMessages(); + if (MapUtils.isEmpty(source)) { + return Collections.emptyMap(); + } + return source.get(key); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/SingleI18nSupportEntity.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/SingleI18nSupportEntity.java new file mode 100644 index 000000000..54b19ebff --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/SingleI18nSupportEntity.java @@ -0,0 +1,13 @@ +package org.hswebframework.web.i18n; + +import java.util.Map; + +public interface SingleI18nSupportEntity extends I18nSupportEntity { + + Map getI18nMessages(); + + default Map getI18nMessages(String key) { + return getI18nMessages(); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/UnsupportedMessageSource.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/UnsupportedMessageSource.java new file mode 100644 index 000000000..8c040606d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/UnsupportedMessageSource.java @@ -0,0 +1,31 @@ +package org.hswebframework.web.i18n; + +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.context.NoSuchMessageException; + +import java.util.Locale; + +public class UnsupportedMessageSource implements MessageSource { + + private static final UnsupportedMessageSource INSTANCE = new UnsupportedMessageSource(); + + public static MessageSource instance() { + return INSTANCE; + } + + @Override + public String getMessage(String code, Object[] args, String defaultMessage, Locale locale) { + return defaultMessage; + } + + @Override + public String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException { + return code; + } + + @Override + public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + return resolvable.getDefaultMessage(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/WebFluxLocaleFilter.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/WebFluxLocaleFilter.java new file mode 100644 index 000000000..fc6a0d0fe --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/WebFluxLocaleFilter.java @@ -0,0 +1,35 @@ +package org.hswebframework.web.i18n; + +import org.springframework.lang.NonNull; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.Locale; + +public class WebFluxLocaleFilter implements WebFilter { + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, WebFilterChain chain) { + return chain + .filter(exchange) + .as(LocaleUtils::transform) + .contextWrite(LocaleUtils.useLocale(getLocaleContext(exchange))); + } + + public Locale getLocaleContext(ServerWebExchange exchange) { + String lang = exchange.getRequest() + .getQueryParams() + .getFirst(":lang"); + if (StringUtils.hasText(lang)) { + return Locale.forLanguageTag(lang); + } + Locale locale = exchange.getLocaleContext().getLocale(); + if (locale == null) { + return Locale.getDefault(); + } + return locale; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/id/IDGenerator.java b/hsweb-core/src/main/java/org/hswebframework/web/id/IDGenerator.java new file mode 100644 index 000000000..844039d8d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/id/IDGenerator.java @@ -0,0 +1,72 @@ +/* + * + * * Copyright 2020 http://www.hswebframework.org + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package org.hswebframework.web.id; + +import org.hswebframework.web.utils.DigestUtils; + +/** + * ID生成器,用于生成ID + * + * @author zhouhao + * @since 3.0 + */ +@FunctionalInterface +public interface IDGenerator { + T generate(); + + /** + * 空ID生成器 + */ + IDGenerator NULL = () -> null; + + @SuppressWarnings("unchecked") + static IDGenerator getNullGenerator() { + return (IDGenerator) NULL; + } + + /** + * 使用UUID生成id + */ + IDGenerator UUID = () -> java.util.UUID.randomUUID().toString(); + + /** + * 随机字符 + */ + IDGenerator RANDOM = RandomIdGenerator.GLOBAL; + + /** + * md5(uuid()) + */ + IDGenerator MD5 = () -> DigestUtils.md5Hex(UUID.generate()); + + /** + * 雪花算法 + */ + IDGenerator SNOW_FLAKE = SnowflakeIdGenerator.getInstance()::nextId; + + /** + * 雪花算法转String + */ + IDGenerator SNOW_FLAKE_STRING = () -> String.valueOf(SNOW_FLAKE.generate()); + + /** + * 雪花算法的16进制 + */ + IDGenerator SNOW_FLAKE_HEX = () -> Long.toHexString(SNOW_FLAKE.generate()); +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/id/RandomIdGenerator.java b/hsweb-core/src/main/java/org/hswebframework/web/id/RandomIdGenerator.java new file mode 100644 index 000000000..01c66f302 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/id/RandomIdGenerator.java @@ -0,0 +1,105 @@ +package org.hswebframework.web.id; + +import io.netty.util.concurrent.FastThreadLocal; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; + +import java.time.Duration; +import java.util.Base64; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class RandomIdGenerator implements IDGenerator { + + // java -Dgenerator.random.instance-id=8 + static final RandomIdGenerator GLOBAL = new RandomIdGenerator( + Integer.getInteger("generator.random.instance-id", ThreadLocalRandom.current().nextInt(1, 127)).byteValue() + ); + + static final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + + private final static FastThreadLocal HOLDER = new FastThreadLocal() { + @Override + protected byte[] initialValue() { + return new byte[24]; + } + }; + + private final byte instanceId; + + public static RandomIdGenerator create(byte instanceId) { + return new RandomIdGenerator(instanceId); + } + + public String generate() { + long now = System.currentTimeMillis(); + byte[] value = HOLDER.get(); + value[0] = instanceId; + + value[1] = (byte) (now >>> 32); + value[2] = (byte) (now >>> 24); + value[3] = (byte) (now >>> 16); + value[4] = (byte) (now >>> 8); + value[5] = (byte) (now); + + nextBytes(value, 6, 8); + nextBytes(value, 8, 16); + nextBytes(value, 16, 24); + return encoder.encodeToString(value); + } + + public static boolean isRandomId(String id) { + if (id.length() < 16 || id.length() > 48) { + return false; + } + return org.apache.commons.codec.binary.Base64.isBase64(id); + } + + public static boolean timestampRangeOf(String id, Duration duration) { + try { + if (!isRandomId(id)) { + return false; + } + long now = System.currentTimeMillis(); + long ts = getTimestampInId(id); + return Math.abs(now - ts) <= duration.toMillis(); + } catch (IllegalArgumentException e) { + return false; + } + } + + public static long getTimestampInId(String id) { + byte[] bytes = Base64.getUrlDecoder().decode(id); + if (bytes.length < 6) { + return -1; + } + long now = System.currentTimeMillis(); + return ((now >>> 56) & 0xff) << 56 | + ((now >>> 48) & 0xff) << 48 | + ((now >>> 40) & 0xff) << 40 | + ((long) bytes[1] & 0xff) << 32 | + ((long) bytes[2] & 0xff) << 24 | + ((long) bytes[3] & 0xff) << 16 | + ((long) bytes[4] & 0xff) << 8 | + (long) bytes[5] & 0xff; + } + + protected Random random() { + return io.netty.util.internal.ThreadLocalRandom.current(); + } + + private void nextBytes(byte[] bytes, int offset, int len) { + Random random = random(); + + for (int i = offset; i < len; ) { + for (int rnd = random.nextInt(), + n = Math.min(len - i, Integer.SIZE / Byte.SIZE); + n-- > 0; rnd >>= Byte.SIZE) { + bytes[i++] = (byte) rnd; + } + } + + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/id/SnowflakeIdGenerator.java b/hsweb-core/src/main/java/org/hswebframework/web/id/SnowflakeIdGenerator.java new file mode 100644 index 000000000..b0fcd6655 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/id/SnowflakeIdGenerator.java @@ -0,0 +1,102 @@ +package org.hswebframework.web.id; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.SecureRandom; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +@Slf4j +public class SnowflakeIdGenerator { + + private final long workerId; + private final long dataCenterId; + private long sequence = 0L; + + private final long twepoch = 1288834974657L; + + private final long workerIdBits = 5L; + private final long datacenterIdBits = 5L; + private final long maxWorkerId = ~(-1L << workerIdBits); + private final long maxDataCenterId = ~(-1L << datacenterIdBits); + private final long sequenceBits = 12L; + + private final long workerIdShift = sequenceBits; + private final long datacenterIdShift = sequenceBits + workerIdBits; + private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; + private final long sequenceMask = ~(-1L << sequenceBits); + + private long lastTimestamp = -1L; + + private static final SnowflakeIdGenerator generator; + + static { + Random random = new SecureRandom(); + long workerId = Long.getLong("id-worker", random.nextInt(31)); + long dataCenterId = Long.getLong("id-datacenter", random.nextInt(31)); + generator = new SnowflakeIdGenerator(workerId, dataCenterId); + } + + public static SnowflakeIdGenerator getInstance() { + return generator; + } + + public static SnowflakeIdGenerator create(int workerId, int dataCenterId) { + return new SnowflakeIdGenerator(workerId, dataCenterId); + } + + public static SnowflakeIdGenerator create() { + return create(ThreadLocalRandom.current().nextInt(31), ThreadLocalRandom.current().nextInt(31)); + } + + public SnowflakeIdGenerator(long workerId, long dataCenterId) { + // sanity check for workerId + if (workerId > maxWorkerId || workerId < 0) { + throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId)); + } + if (dataCenterId > maxDataCenterId || dataCenterId < 0) { + throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDataCenterId)); + } + this.workerId = workerId; + this.dataCenterId = dataCenterId; + log.info("worker starting. timestamp left shift {}, datacenter id bits {}, worker id bits {}, sequence bits {}, workerid {}", timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId); + } + + public synchronized long nextId() { + long timestamp = timeGen(); + //时间回退 + if (timestamp < lastTimestamp) { + //发生回退时不拒绝,有可能出现重复数据? + log.warn("clock is moving backwards {}.", lastTimestamp); +// throw new UnsupportedOperationException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); + } + + if (lastTimestamp == timestamp) { + sequence = (sequence + 1) & sequenceMask; + if (sequence == 0) { + timestamp = tilNextMillis(lastTimestamp); + } + } else { + sequence = 0L; + } + + lastTimestamp = timestamp; + + return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << datacenterIdShift) | (workerId << workerIdShift) | sequence; + } + + protected long tilNextMillis(long lastTimestamp) { + long timestamp = timeGen(); + while (timestamp <= lastTimestamp) { + timestamp = timeGen(); + } + return timestamp; + } + + protected long timeGen() { + return System.currentTimeMillis(); + } + +} \ No newline at end of file diff --git a/hsweb-core/src/main/java/org/hswebframework/web/logger/ReactiveLogger.java b/hsweb-core/src/main/java/org/hswebframework/web/logger/ReactiveLogger.java new file mode 100644 index 000000000..5c1231c86 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/logger/ReactiveLogger.java @@ -0,0 +1,133 @@ +package org.hswebframework.web.logger; + +import com.google.common.collect.Maps; +import lombok.extern.slf4j.Slf4j; +import org.hswebframework.web.utils.CollectionUtils; +import org.slf4j.MDC; +import reactor.core.publisher.*; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +@Slf4j +public class ReactiveLogger { + + private static final String CONTEXT_KEY = ReactiveLogger.class.getName(); + + public static Function start(String key, String value) { + return start(Collections.singletonMap(key, value)); + } + + public static Function start(String... keyAndValue) { + return start(CollectionUtils.pairingArrayMap(keyAndValue)); + } + + public static Mono mdc(String key, String value) { + return Mono + .empty() + .contextWrite(start(key, value)); + } + + public static Mono mdc(String... keyAndValue) { + return Mono + .empty() + .contextWrite(start(keyAndValue)); + } + + public static Function start(Map context) { + return ctx -> { + Optional> maybeContextMap = ctx.getOrEmpty(CONTEXT_KEY); + if (maybeContextMap.isPresent()) { + maybeContextMap.get().putAll(Maps.filterValues(context,Objects::nonNull)); + return ctx; + } else { + return ctx.put(CONTEXT_KEY, new ConcurrentHashMap<>(context)); + } + }; + } + + + public static void log(ContextView context, Consumer> logger) { + Optional> maybeContextMap = context.getOrEmpty(CONTEXT_KEY); + if (!maybeContextMap.isPresent()) { + logger.accept(new HashMap<>()); + } else { + Map ctx = maybeContextMap.get(); + MDC.setContextMap(ctx); + try { + logger.accept(ctx); + } finally { + MDC.clear(); + } + } + } + + public static Consumer> on(SignalType type, BiConsumer, Signal> logger) { + return signal -> { + if (signal.getType() != type) { + return; + } + Optional> maybeContextMap + = signal.getContextView().getOrEmpty(CONTEXT_KEY); + if (!maybeContextMap.isPresent()) { + logger.accept(new HashMap<>(), signal); + } else { + Map ctx = maybeContextMap.get(); + MDC.setContextMap(ctx); + try { + logger.accept(ctx, signal); + } finally { + MDC.clear(); + } + } + }; + } + + public static Mono mdc(Consumer> consumer) { + return Mono + .deferContextual(ctx -> { + Optional> maybeContextMap = ctx.getOrEmpty(CONTEXT_KEY); + if (maybeContextMap.isPresent()) { + consumer.accept(maybeContextMap.get()); + } else { + consumer.accept(Collections.emptyMap()); + log.warn("logger context is empty,please call publisher.contextWrite(ReactiveLogger.mdc()) first!"); + } + return Mono.empty(); + }); + } + + public static BiConsumer> handle(BiConsumer> logger) { + return (t, rFluxSink) -> { + log(rFluxSink.contextView(), context -> { + logger.accept(t, rFluxSink); + }); + }; + } + + public static Consumer> onNext(Consumer logger) { + return on(SignalType.ON_NEXT, (ctx, signal) -> { + logger.accept(signal.get()); + }); + + } + + public static Consumer> onComplete(Runnable logger) { + return on(SignalType.ON_COMPLETE, (ctx, signal) -> { + logger.run(); + }); + } + + public static Consumer> onError(Consumer logger) { + return on(SignalType.ON_ERROR, (ctx, signal) -> { + logger.accept(signal.getThrowable()); + }); + } + + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/proxy/Proxy.java b/hsweb-core/src/main/java/org/hswebframework/web/proxy/Proxy.java new file mode 100644 index 000000000..a8938a68d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/proxy/Proxy.java @@ -0,0 +1,251 @@ +package org.hswebframework.web.proxy; + +import javassist.*; +import javassist.bytecode.AnnotationsAttribute; +import javassist.bytecode.ConstPool; +import javassist.bytecode.annotation.*; +import lombok.Getter; +import lombok.SneakyThrows; +import org.springframework.util.ClassUtils; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * @author zhouhao + * @since 3.0 + */ +public class Proxy extends URLClassLoader { + private static final AtomicLong counter = new AtomicLong(1); + + private final CtClass ctClass; + @Getter + private final Class superClass; + @Getter + private final String className; + @Getter + private final String classFullName; + + private final List loaders = new ArrayList<>(); + private Class targetClass; + + @SneakyThrows + public static Proxy create(Class superClass, Class[] classPaths, String... classPathString) { + return new Proxy<>(superClass, classPaths, classPathString); + } + + @SneakyThrows + public static Proxy create(Class superClass, String... classPathString) { + return new Proxy<>(superClass, null, classPathString); + } + + public Proxy(Class superClass, String... classPathString) { + this(superClass, null, classPathString); + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + for (ClassLoader loader : loaders) { + try { + return loader.loadClass(name); + } catch (ClassNotFoundException ignore) { + } + } + return super.loadClass(name); + } + + @Override + public URL getResource(String name) { + for (ClassLoader loader : loaders) { + URL resource = loader.getResource(name); + if (resource != null) { + return resource; + } + } + return super.getResource(name); + } + + @Override + public Enumeration getResources(String name) throws IOException { + @SuppressWarnings("all") + Enumeration[] tmp = (Enumeration[]) new Enumeration[loaders.size()]; + + return new Enumeration() { + + @Override + public boolean hasMoreElements() { + for (Enumeration urlEnumeration : tmp) { + if (urlEnumeration.hasMoreElements()) { + return true; + } + } + return false; + } + + @Override + public URL nextElement() { + for (Enumeration urlEnumeration : tmp) { + if (urlEnumeration.hasMoreElements()) { + return urlEnumeration.nextElement(); + } + } + return null; + } + }; + } + + @SneakyThrows + private static URL[] toUrl(String[] str) { + if (str == null || str.length == 0) { + return new URL[0]; + } + URL[] arr = new URL[str.length]; + for (int i = 0; i < str.length; i++) { + arr[i] = URI.create(str[i]).toURL(); + } + return arr; + } + + @SneakyThrows + public Proxy(Class superClass, Class[] classPaths, String... classPathString) { + super(toUrl(classPathString)); + if (superClass == null) { + throw new NullPointerException("superClass can not be null"); + } + this.superClass = superClass; + ClassPool classPool = ClassPool.getDefault(); + + if (classPaths != null) { + for (Class classPath : classPaths) { + if (classPath.getClassLoader() != null && + classPath.getClassLoader() != this.getClass().getClassLoader()) { + loaders.add(classPath.getClassLoader()); + } + } + } + + loaders.add(ClassUtils.getDefaultClassLoader()); + + classPool.insertClassPath(new LoaderClassPath(this)); + + className = superClass.getSimpleName() + "$Proxy" + counter.getAndIncrement(); + String packageName = superClass.getPackage().getName(); + if (packageName.startsWith("java")) { + packageName = "proxy." + packageName; + } + classFullName = packageName + "." + className; + + ctClass = classPool.makeClass(classFullName); + if (superClass != Object.class) { + if (superClass.isInterface()) { + ctClass.setInterfaces(new CtClass[]{classPool.get(superClass.getName())}); + } else { + ctClass.setSuperclass(classPool.get(superClass.getName())); + } + } + addConstructor("public " + className + "(){}"); + } + + public Proxy addMethod(String code) { + return handleException(() -> ctClass.addMethod(CtNewMethod.make(code, ctClass))); + } + + public Proxy addConstructor(String code) { + return handleException(() -> ctClass.addConstructor(CtNewConstructor.make(code, ctClass))); + } + + public Proxy addField(String code) { + return addField(code, null); + } + + public Proxy addField(String code, Class annotation) { + return addField(code, annotation, null); + } + + @SuppressWarnings("all") + public static MemberValue createMemberValue(Object value, ConstPool constPool) { + MemberValue memberValue = null; + if (value instanceof Integer) { + memberValue = new IntegerMemberValue(constPool, ((Integer) value)); + } else if (value instanceof Boolean) { + memberValue = new BooleanMemberValue((Boolean) value, constPool); + } else if (value instanceof Long) { + memberValue = new LongMemberValue((Long) value, constPool); + } else if (value instanceof String) { + memberValue = new StringMemberValue((String) value, constPool); + } else if (value instanceof Class) { + memberValue = new ClassMemberValue(((Class) value).getName(), constPool); + } else if (value instanceof Object[]) { + Object[] arr = ((Object[]) value); + ArrayMemberValue arrayMemberValue = new ArrayMemberValue( + new ClassMemberValue(arr[0].getClass().getName(), constPool), constPool); + arrayMemberValue.setValue( + Arrays + .stream(arr) + .map(o -> createMemberValue(o, constPool)) + .toArray(MemberValue[]::new)); + memberValue = arrayMemberValue; + + } + return memberValue; + } + + public Proxy custom(Consumer ctClassConsumer) { + ctClassConsumer.accept(ctClass); + return this; + } + + @SneakyThrows + public Proxy addField(String code, Class annotation, Map annotationProperties) { + return handleException(() -> { + CtField ctField = CtField.make(code, ctClass); + if (null != annotation) { + ConstPool constPool = ctClass.getClassFile().getConstPool(); + AnnotationsAttribute attributeInfo = new AnnotationsAttribute(constPool, AnnotationsAttribute.visibleTag); + Annotation ann = new javassist.bytecode.annotation.Annotation(annotation.getName(), constPool); + if (null != annotationProperties) { + annotationProperties.forEach((key, value) -> { + MemberValue memberValue = createMemberValue(value, constPool); + if (memberValue != null) { + ann.addMemberValue(key, memberValue); + } + }); + } + attributeInfo.addAnnotation(ann); + ctField.getFieldInfo().addAttribute(attributeInfo); + } + ctClass.addField(ctField); + }); + } + + @SneakyThrows + private Proxy handleException(Task task) { + task.run(); + return this; + } + + + @SneakyThrows + public I newInstance() { + return getTargetClass().getConstructor().newInstance(); + } + + @SneakyThrows + @SuppressWarnings("all") + public Class getTargetClass() { + if (targetClass == null) { + byte[] code = ctClass.toBytecode(); + targetClass = (Class) defineClass(null, code, 0, code.length); + } + return targetClass; + } + + interface Task { + void run() throws Exception; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/AnnotationUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/AnnotationUtils.java new file mode 100644 index 000000000..7f0469f24 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/AnnotationUtils.java @@ -0,0 +1,113 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.hswebframework.web.utils; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +public final class AnnotationUtils { + + private AnnotationUtils() { + } + + public static T findMethodAnnotation(Class targetClass, Method method, Class annClass) { + Method m = method; + T a = org.springframework.core.annotation.AnnotationUtils.findAnnotation(m, annClass); + if (a != null) { + return a; + } + m = ClassUtils.getMostSpecificMethod(m, targetClass); + a = org.springframework.core.annotation.AnnotationUtils.findAnnotation(m, annClass); + if (a == null) { + List supers = new ArrayList<>(Arrays.asList(targetClass.getInterfaces())); + if (targetClass.getSuperclass() != Object.class) { + supers.add(targetClass.getSuperclass()); + } + + for (Class aClass : supers) { + if(aClass==null){ + continue; + } + AtomicReference methodRef = new AtomicReference<>(); + ReflectionUtils.doWithMethods(aClass, im -> { + if (im.getName().equals(method.getName()) && im.getParameterCount() == method.getParameterCount()) { + methodRef.set(im); + } + }); + + if (methodRef.get() != null) { + a = findMethodAnnotation(aClass, methodRef.get(), annClass); + if (a != null) { + return a; + } + } + } + } + return a; + } + + public static T findAnnotation(Class targetClass, Class annClass) { + return org.springframework.core.annotation.AnnotationUtils.findAnnotation(targetClass, annClass); + } + + public static T findAnnotation(Class targetClass, Method method, Class annClass) { + T a = findMethodAnnotation(targetClass, method, annClass); + if (a != null) { + return a; + } + return findAnnotation(targetClass, annClass); + } + + public static T findAnnotation(JoinPoint pjp, Class annClass) { + MethodSignature signature = (MethodSignature) pjp.getSignature(); + Method m = signature.getMethod(); + Class targetClass = pjp.getTarget().getClass(); + return findAnnotation(targetClass, m, annClass); + } + + public static String getMethodBody(JoinPoint pjp) { + StringBuilder methodName = new StringBuilder(pjp.getSignature().getName()).append("("); + MethodSignature signature = (MethodSignature) pjp.getSignature(); + String[] names = signature.getParameterNames(); + Class[] args = signature.getParameterTypes(); + for (int i = 0, len = args.length; i < len; i++) { + if (i != 0) { + methodName.append(","); + } + methodName.append(args[i].getSimpleName()).append(" ").append(names[i]); + } + return methodName.append(")").toString(); + } + + public static Map getArgsMap(JoinPoint pjp) { + MethodSignature signature = (MethodSignature) pjp.getSignature(); + Map args = new LinkedHashMap<>(); + String names[] = signature.getParameterNames(); + for (int i = 0, len = names.length; i < len; i++) { + args.put(names[i], pjp.getArgs()[i]); + } + return args; + } +} \ No newline at end of file diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/CollectionUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/CollectionUtils.java new file mode 100644 index 000000000..dae958e08 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/CollectionUtils.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.utils; + +import reactor.function.Consumer3; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; + +public class CollectionUtils { + + @SafeVarargs + public static Map pairingArrayMap(A... array) { + return pairingArray(array, LinkedHashMap::new, Map::put); + } + + public static T pairingArray(A[] array, + Supplier supplier, + Consumer3 mapping) { + T container = supplier.get(); + for (int i = 0, len = array.length / 2; i < len; i++) { + mapping.accept(container, array[i * 2], array[i * 2 + 1]); + } + return container; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/DigestUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/DigestUtils.java new file mode 100644 index 000000000..4c863a9c8 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/DigestUtils.java @@ -0,0 +1,127 @@ +package org.hswebframework.web.utils; + +import io.netty.util.concurrent.FastThreadLocal; +import io.seruco.encoding.base62.Base62; +import org.apache.commons.codec.binary.Hex; +import org.hswebframework.web.id.RandomIdGenerator; + +import java.security.MessageDigest; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public class DigestUtils { + + public static final FastThreadLocal md5 = new FastThreadLocal() { + @Override + protected MessageDigest initialValue() { + return org.apache.commons.codec.digest.DigestUtils.getMd5Digest(); + } + }; + + public static final FastThreadLocal sha256 = new FastThreadLocal() { + @Override + protected MessageDigest initialValue() { + return org.apache.commons.codec.digest.DigestUtils.getSha256Digest(); + } + }; + + public static final FastThreadLocal sha1 = new FastThreadLocal() { + @Override + protected MessageDigest initialValue() { + return org.apache.commons.codec.digest.DigestUtils.getSha1Digest(); + } + }; + + private static final Base62 base62 = Base62.createInstance(); + + + public static Base62 base62(){ + return base62; + } + public static byte[] md5(Consumer digestHandler) { + return digest(md5::get, digestHandler); + } + + public static String md5Hex(Consumer digestHandler) { + return digestHex(md5::get, digestHandler); + } + + public static byte[] sha1(Consumer digestHandler) { + return digest(sha1::get, digestHandler); + } + + public static String sha1Hex(Consumer digestHandler) { + return digestHex(sha1::get, digestHandler); + } + + public static byte[] sha256(Consumer digestHandler) { + return digest(sha256::get, digestHandler); + } + + public static String sha256Hex(Consumer digestHandler) { + return digestHex(sha1::get, digestHandler); + } + + public static byte[] md5(byte[] data) { + return org.apache.commons.codec.digest.DigestUtils.digest(md5.get(), data); + } + + public static byte[] md5(String str) { + return md5(str.getBytes()); + } + + public static String md5Hex(String str) { + return Hex.encodeHexString(md5(str.getBytes())); + } + public static String md5Base62(String str) { + return new String(base62.encode(md5(str.getBytes()))); + } + + public static byte[] sha256(byte[] data) { + return org.apache.commons.codec.digest.DigestUtils.digest(sha256.get(), data); + } + + public static byte[] sha256(String str) { + return sha256(str.getBytes()); + } + + public static String sha256Hex(String str) { + return Hex.encodeHexString(sha256(str.getBytes())); + } + + public static byte[] sha1(byte[] data) { + return org.apache.commons.codec.digest.DigestUtils.digest(sha1.get(), data); + } + + public static byte[] sha1(String str) { + return sha1(str.getBytes()); + } + + public static String sha1Hex(String str) { + return Hex.encodeHexString(sha1(str.getBytes())); + } + + public static byte[] digest(MessageDigest digest, byte[] data) { + return org.apache.commons.codec.digest.DigestUtils.digest(digest, data); + } + + public static byte[] digest(MessageDigest digest, String str) { + return digest(digest, str.getBytes()); + } + + public static String digestHex(MessageDigest digest, String str) { + return Hex.encodeHexString(digest(digest, str)); + } + + private static byte[] digest(Supplier digestSupplier, + Consumer digestHandler) { + MessageDigest digest = digestSupplier.get(); + digestHandler.accept(digest); + return digest.digest(); + } + + private static String digestHex(Supplier digestSupplier, + Consumer digestHandler) { + return Hex.encodeHexString(digest(digestSupplier, digestHandler)); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/DynamicArrayList.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/DynamicArrayList.java new file mode 100644 index 000000000..e5992b2d6 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/DynamicArrayList.java @@ -0,0 +1,22 @@ +package org.hswebframework.web.utils; + +import lombok.AllArgsConstructor; + +import java.lang.reflect.Array; +import java.util.AbstractList; + +@AllArgsConstructor +public class DynamicArrayList extends AbstractList { + + private final Object value; + + @Override + public E get(int index) { + return (E) Array.get(value, index); + } + + @Override + public int size() { + return Array.getLength(value); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/ExpressionUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/ExpressionUtils.java new file mode 100644 index 000000000..9ebceb7f6 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/ExpressionUtils.java @@ -0,0 +1,128 @@ +package org.hswebframework.web.utils; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.beanutils.BeanUtilsBean2; +import org.hswebframework.expands.script.engine.DynamicScriptEngine; +import org.hswebframework.expands.script.engine.DynamicScriptEngineFactory; +import org.hswebframework.expands.script.engine.ExecuteResult; +import org.springframework.util.StringUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 表达式工具,用户解析表达式为字符串 + * + * @author zhouhao + * @since 3.0 + */ +@Slf4j +public class ExpressionUtils { + + //表达式提取正则 ${.+?} + private static final Pattern PATTERN = Pattern.compile("(?<=\\$\\{)(.+?)(?=})"); + + /** + * 获取默认的表达式变量 + * + * @return 变量集合 + */ + public static Map getDefaultVar() { + return new HashMap<>(); + } + + /** + * 获取默认的表达式变量并将制定的变量合并在一起 + * + * @param var 要合并的变量集合 + * @return 变量集合 + */ + public static Map getDefaultVar(Map var) { + Map vars = getDefaultVar(); + vars.putAll(var); + return vars; + } + + /** + * 使用默认的变量解析表达式 + * + * @param expression 表达式字符串 + * @param language 表达式语言 + * @return 解析结果 + * @throws Exception 解析错误 + * @see ExpressionUtils#analytical(String, Map, String) + */ + public static String analytical(String expression, String language) throws Exception { + return analytical(expression, new HashMap<>(), language); + } + + /** + * 解析表达式,表达式使用{@link ExpressionUtils#PATTERN}进行提取
+ * 如调用 analytical("http://${3+2}/test",var,"spel")
+ * 支持的表达式语言: + *
    + *
  • freemarker
  • + *
  • spel
  • + *
  • ognl
  • + *
  • groovy
  • + *
  • js
  • + *
+ * + * @param expression 表达式字符串 + * @param vars 变量 + * @param language 表达式语言 + * @return 解析结果 + */ + @SneakyThrows + public static String analytical(String expression, Map vars, String language) { + if (!expression.contains("${")) { + return expression; + } + DynamicScriptEngine engine = DynamicScriptEngineFactory.getEngine(language); + if (engine == null) { + return expression; + } + + return TemplateParser.parse(expression, var -> { + if (StringUtils.isEmpty(var)) { + return ""; + } + Object val = vars.get(var); + if (val != null) { + return String.valueOf(val); + } + if ("spel".equalsIgnoreCase(language) && !var.contains("#")) { + try { + Object fast = BeanUtilsBean2.getInstance().getPropertyUtils().getProperty(vars, var); + if (fast != null) { + return fast.toString(); + } + } catch (Exception ignore) { + //ignore + return ""; + } + } + String id = DigestUtils.md5Hex(var); + try { + if (!engine.compiled(id)) { + engine.compile(id, var); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + try { + return String.valueOf(engine.execute(id, vars).getIfSuccess()); + } catch (Exception e) { + log.error(e.getLocalizedMessage(), e); + return ""; + } + + }); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/FluxCache.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/FluxCache.java new file mode 100644 index 000000000..a8c40c33a --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/FluxCache.java @@ -0,0 +1,30 @@ +package org.hswebframework.web.utils; + +import org.reactivestreams.Publisher; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +public class FluxCache { + + + public static Flux cache(Flux source, Function, Publisher> handler) { + Disposable[] ref = new Disposable[1]; + Flux cache = source + .doFinally((s) -> ref[0] = null) + .replay() + .autoConnect(1, dis -> ref[0] = dis); + return Mono + .from(handler.apply(cache)) + .thenMany(cache) + .doFinally((s) -> { + if (ref[0] != null) { + ref[0].dispose(); + } + }); + + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/HttpParameterConverter.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/HttpParameterConverter.java new file mode 100644 index 000000000..d61c1b0aa --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/HttpParameterConverter.java @@ -0,0 +1,119 @@ +package org.hswebframework.web.utils; + +import org.apache.commons.beanutils.BeanMap; +import org.hswebframework.utils.time.DateFormatter; +import org.hswebframework.web.bean.FastBeanCopier; + +import java.util.*; +import java.util.function.Function; + +public class HttpParameterConverter { + + private Map beanMap; + + private Map parameter = new HashMap<>(); + + private String prefix = ""; + + private static final Map> convertMap = new HashMap<>(); + + private static Function defaultConvert = String::valueOf; + + private static final Set basicClass = new HashSet<>(); + + static { + basicClass.add(int.class); + basicClass.add(double.class); + basicClass.add(float.class); + basicClass.add(byte.class); + basicClass.add(short.class); + basicClass.add(char.class); + basicClass.add(boolean.class); + + basicClass.add(Integer.class); + basicClass.add(Double.class); + basicClass.add(Float.class); + basicClass.add(Byte.class); + basicClass.add(Short.class); + basicClass.add(Character.class); + basicClass.add(String.class); + basicClass.add(Boolean.class); + + basicClass.add(Date.class); + + + putConvert(Date.class, (date) -> DateFormatter.toString(date, "yyyy-MM-dd HH:mm:ss")); + + + } + + @SuppressWarnings("unchecked") + private static void putConvert(Class type, Function convert) { + convertMap.put(type, (Function) convert); + + } + + private String convertValue(Object value) { + return convertMap.getOrDefault(value.getClass(), defaultConvert).apply(value); + } + + @SuppressWarnings("unchecked") + public HttpParameterConverter(Object bean) { + if (bean instanceof Map) { + beanMap = ((Map) bean); + } else { + beanMap = FastBeanCopier.copy(bean,new HashMap<>()); + } + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + private void doConvert(String key, Object value) { + if (value == null) { + return; + } + if(value instanceof Class){ + return; + } + Class type = org.springframework.util.ClassUtils.getUserClass(value); + + if (basicClass.contains(type) || value instanceof Number || value instanceof Enum) { + parameter.put(getParameterKey(key), convertValue(value)); + return; + } + + if (value instanceof Object[]) { + value = Arrays.asList(((Object[]) value)); + } + + if (value instanceof Collection) { + Collection coll = ((Collection) value); + int count = 0; + for (Object o : coll) { + doConvert(key + "[" + count++ + "]", o); + } + } else { + HttpParameterConverter converter = new HttpParameterConverter(value); + converter.setPrefix(getParameterKey(key).concat(".")); + parameter.putAll(converter.convert()); + } + } + + private void doConvert() { + beanMap.forEach(this::doConvert); + } + + + private String getParameterKey(String property) { + return prefix.concat(property); + } + + public Map convert() { + doConvert(); + + return parameter; + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/ModuleUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/ModuleUtils.java new file mode 100644 index 000000000..46e92c6f2 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/ModuleUtils.java @@ -0,0 +1,157 @@ +package org.hswebframework.web.utils; + +import com.alibaba.fastjson.JSON; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author zhouhao + * @since 3.0.6 + */ +@Slf4j +public abstract class ModuleUtils { + + private ModuleUtils() { + + } + + private final static Map classModuleInfoRepository; + + private final static Map nameModuleInfoRepository; + + static { + classModuleInfoRepository = new ConcurrentHashMap<>(); + nameModuleInfoRepository = new ConcurrentHashMap<>(); + try { + log.info("init module info"); + Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:/hsweb-module.json"); + for (Resource resource : resources) { + String classPath = getClassPath(resource.getURL().toString(), "hsweb-module.json"); + ModuleInfo moduleInfo = JSON.parseObject(resource.getInputStream(), ModuleInfo.class); + moduleInfo.setClassPath(classPath); + ModuleUtils.register(moduleInfo); + } + } catch (Exception e) { + log.error(e.getLocalizedMessage(), e); + } + } + + public static ModuleInfo getModuleByClass(Class type) { + return classModuleInfoRepository.computeIfAbsent(type, ModuleUtils::parse); + } + + public static String getClassPath(Class type) { + ProtectionDomain domain = type.getProtectionDomain(); + CodeSource codeSource = domain.getCodeSource(); + if (codeSource == null) { + return getClassPath(type.getResource("").getPath(), type.getPackage().getName()); + } + String path = codeSource.getLocation().toString(); + + boolean isJar = path.contains("!/") && path.contains(".jar"); + + if (isJar) { + return path.substring(0, path.lastIndexOf(".jar") + 4); + } + + if (path.endsWith("/")) { + return path.substring(0, path.length() - 1); + } + return path; + } + + public static String getClassPath(String path, String packages) { + if (path.endsWith(".jar")) { + return path; + } + boolean isJar = path.contains("!/") && path.contains(".jar"); + + if (isJar) { + return path.substring(0, path.lastIndexOf(".jar") + 4); + } + + int pos = path.endsWith("/") ? 2 : 1; + return path.substring(0, path.length() - packages.length() - pos); + } + + private static ModuleInfo parse(Class type) { + String classpath = getClassPath(type); + return nameModuleInfoRepository.values() + .stream() + .filter(moduleInfo -> classpath.equals(moduleInfo.classPath)) + .findFirst() + .orElse(noneInfo); + } + + public static ModuleInfo getModule(String id) { + return nameModuleInfoRepository.get(id); + } + + public static void register(ModuleInfo moduleInfo) { + nameModuleInfoRepository.put(moduleInfo.getId(), moduleInfo); + } + + private static final ModuleInfo noneInfo = new ModuleInfo(); + + @Getter + @Setter + public static class ModuleInfo { + + private String classPath; + + private String id; + + private String groupId; + + private String path; + + private String artifactId; + + private String gitCommitHash; + + private String gitRepository; + + private String comment; + + private String version; + + public String getGitLocation() { + String gitCommitHash = this.gitCommitHash; + if (gitCommitHash == null || gitCommitHash.contains("$") || gitCommitHash.contains("@")) { + gitCommitHash = "master"; + } + return gitRepository + "/blob/" + gitCommitHash + "/" + path + "/"; + } + + public String getGitClassLocation(Class clazz) { + return getGitLocation() + "src/main/java/" + (ClassUtils.getPackageName(clazz).replace(".", "/")) + + "/" + clazz.getSimpleName() + ".java"; + } + + public String getGitClassLocation(Class clazz, long line, long lineTo) { + return getGitLocation() + "src/main/java/" + (ClassUtils.getPackageName(clazz).replace(".", "/")) + + "/" + clazz.getSimpleName() + ".java#L" + line + "-" + "L" + lineTo; + } + + public String getId() { + if (StringUtils.isEmpty(id)) { + id = groupId + "/" + artifactId; + } + return id; + } + + public boolean isNone() { + return StringUtils.isEmpty(classPath); + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/ReactiveWebUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/ReactiveWebUtils.java new file mode 100644 index 000000000..ced5b8630 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/ReactiveWebUtils.java @@ -0,0 +1,37 @@ +package org.hswebframework.web.utils; + +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; + +import java.net.InetSocketAddress; +import java.util.Optional; + + +public class ReactiveWebUtils { + + static final String[] ipHeaders = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP" + }; + + /** + * 获取请求客户端的真实ip地址 + * + * @param request 请求对象 + * @return ip地址 + */ + public static String getIpAddr(ServerHttpRequest request) { + for (String ipHeader : ipHeaders) { + String ip = request.getHeaders().getFirst(ipHeader); + if (!StringUtils.isEmpty(ip) && !ip.contains("unknown")) { + return ip; + } + } + return Optional.ofNullable(request.getRemoteAddress()) + .map(addr->addr.getAddress().getHostAddress()) + .orElse("unknown"); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/TemplateParser.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/TemplateParser.java new file mode 100644 index 000000000..275d8d8f3 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/TemplateParser.java @@ -0,0 +1,147 @@ +package org.hswebframework.web.utils; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.beanutils.BeanUtilsBean; + +import java.util.Arrays; +import java.util.function.Function; + + +@Slf4j +public class TemplateParser { + private static final char[] DEFAULT_PREPARE_START_SYMBOL = "${".toCharArray(); + + private static final char[] DEFAULT_PREPARE_END_SYMBOL = "}".toCharArray(); + + @Getter + @Setter + private char[] prepareStartSymbol = DEFAULT_PREPARE_START_SYMBOL; + + @Getter + @Setter + private char[] prepareEndSymbol = DEFAULT_PREPARE_END_SYMBOL; + + @Getter + @Setter + private String template; + + @Getter + @Setter + private Object parameter; + + private char[] templateArray; + + private int pos; + + private char symbol; + + private char[] newArr; + + private int len = 0; + + private byte prepareFlag = 0; + + public void setParsed(char[] chars, int end) { + for (int i = 0; i < end; i++) { + char aChar = chars[i]; + if (newArr.length <= len) { + newArr = Arrays.copyOf(newArr, len + templateArray.length); + } + newArr[len++] = aChar; + } + + } + + public void setParsed(char... chars) { + setParsed(chars, chars.length); + } + + private void init() { + templateArray = template.toCharArray(); + pos = 0; + newArr = new char[templateArray.length * 2]; + } + + private boolean isPreparing() { + return prepareFlag > 0; + } + + private boolean isPrepare() { + if (prepareStartSymbol[prepareFlag] == symbol) { + prepareFlag++; + } + if (prepareFlag >= prepareStartSymbol.length) { + prepareFlag = 0; + return true; + } + return false; + } + + private boolean isPrepareEnd() { + for (char c : prepareEndSymbol) { + if (c == symbol) { + return true; + } + } + return false; + } + + private boolean next() { + symbol = templateArray[pos++]; + return pos < templateArray.length; + } + + public String parse(Function propertyMapping) { + init(); + boolean inPrepare = false; + + char[] expression = new char[128]; + int expressionPos = 0; + + while (next()) { + if (isPrepare()) { + inPrepare = true; + } else if (inPrepare && isPrepareEnd()) { + inPrepare = false; + setParsed(propertyMapping.apply(new String(expression, 0, expressionPos)).toCharArray()); + expressionPos = 0; + } else if (inPrepare) { + if (expression.length <= expressionPos) { + expression = Arrays.copyOf(expression, (int)(expression.length * 1.5)); + } + expression[expressionPos++] = symbol; + } else if (!isPreparing()) { + setParsed(symbol); + } + } + + if (isPrepareEnd() && expressionPos > 0) { + setParsed(propertyMapping.apply(new String(expression, 0, expressionPos)).toCharArray()); + } else { + setParsed(symbol); + } + + return new String(newArr, 0, len); + } + + + public static String parse(String template, Object parameter) { + return parse(template, var -> { + + try { + return BeanUtilsBean.getInstance().getProperty(parameter, var); + } catch (Exception e) { + log.warn(e.getMessage(), e); + } + return ""; + }); + } + + public static String parse(String template, Function parameterGetter) { + TemplateParser parser = new TemplateParser(); + parser.template = template; + return parser.parse(parameterGetter); + } +} \ No newline at end of file diff --git a/hsweb-core/src/main/java/org/hswebframework/web/utils/WebUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/utils/WebUtils.java new file mode 100644 index 000000000..55a83d816 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/utils/WebUtils.java @@ -0,0 +1,136 @@ +/* + * Copyright 2020 http://www.hswebframework.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.hswebframework.web.utils; + +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Web常用工具集,用于获取当前登录用户,请求信息等 + * + * @since 3.0 + */ +public class WebUtils { + + /** + * 将对象转为http请求参数: + *
+     *     {name:"test",org:[1,2,3]} => {"name":"test","org[0]":1,"org[1]":2,"org[2]":3}
+     * 
+ * + * @param object + * @return + */ + public static Map objectToHttpParameters(Object object) { + return new HttpParameterConverter(object).convert(); + } + + public static Map queryStringToMap(String queryString,String charset){ + try { + Map map = new HashMap<>(); + + String[] decode = URLDecoder.decode(queryString,charset).split("&"); + for (String keyValue : decode) { + String[] kv = keyValue.split("[=]",2); + map.put(kv[0],kv.length>1?kv[1]:""); + } + return map; + } catch (UnsupportedEncodingException e) { + throw new UnsupportedOperationException(e); + } + } + /** + * 尝试获取当前请求的HttpServletRequest实例 + * + * @return HttpServletRequest + */ + public static HttpServletRequest getHttpServletRequest() { + try { + return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + } catch (Exception e) { + return null; + } + } + + public static Map getParameters(HttpServletRequest request) { + Map parameters = new HashMap<>(); + Enumeration enumeration = request.getParameterNames(); + while (enumeration.hasMoreElements()) { + String name = String.valueOf(enumeration.nextElement()); + parameters.put(name, request.getParameter(name)); + } + return parameters; + } + + public static Map getHeaders(HttpServletRequest request) { + Map map = new LinkedHashMap<>(); + Enumeration enumeration = request.getHeaderNames(); + while (enumeration.hasMoreElements()) { + String key = enumeration.nextElement(); + String value = request.getHeader(key); + map.put(key, value); + } + return map; + } + + static final String[] ipHeaders = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP" + }; + + /** + * 获取请求客户端的真实ip地址 + * + * @param request 请求对象 + * @return ip地址 + */ + public static String getIpAddr(HttpServletRequest request) { + for (String ipHeader : ipHeaders) { + String ip = request.getHeader(ipHeader); + if (!StringUtils.isEmpty(ip) && !ip.contains("unknown")) { + return ip; + } + } + return request.getRemoteAddr(); + } + + /** + * web应用绝对路径 + * + * @param request 请求对象 + * @return 绝对路径 + */ + public static String getBasePath(HttpServletRequest request) { + String path = request.getContextPath(); + String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + "/"; + return basePath; + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/validator/CreateGroup.java b/hsweb-core/src/main/java/org/hswebframework/web/validator/CreateGroup.java new file mode 100644 index 000000000..57540a3de --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/validator/CreateGroup.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.validator; + +/** + * 使用此Group,只在新增时验证数据 + * + * @author zhouhao + * @since 3.0 + */ +public interface CreateGroup { +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/validator/UpdateGroup.java b/hsweb-core/src/main/java/org/hswebframework/web/validator/UpdateGroup.java new file mode 100644 index 000000000..452ca8000 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/validator/UpdateGroup.java @@ -0,0 +1,10 @@ +package org.hswebframework.web.validator; + +/** + * 使用此group,只在修改的时候才进行验证 + * + * @author zhouhao + * @since 3.0 + */ +public interface UpdateGroup { +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java new file mode 100644 index 000000000..4c5a1a748 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java @@ -0,0 +1,63 @@ +package org.hswebframework.web.validator; + +import org.hibernate.validator.BaseHibernateValidatorConfiguration; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.i18n.ContextLocaleResolver; + +import javax.validation.*; +import java.util.Set; + +public final class ValidatorUtils { + + private ValidatorUtils() { + } + + static volatile Validator validator; + + public static Validator getValidator() { + if (validator == null) { + synchronized (ValidatorUtils.class) { + if (validator != null) { + return validator; + } + Configuration configuration = Validation + .byDefaultProvider() + .configure(); + configuration.addProperty(BaseHibernateValidatorConfiguration.LOCALE_RESOLVER_CLASSNAME, + ContextLocaleResolver.class.getName()); + configuration.messageInterpolator(configuration.getDefaultMessageInterpolator()); + + ValidatorFactory factory = configuration.buildValidatorFactory(); + + return validator = factory.getValidator(); + } + } + return validator; + } + + public static T tryValidate(T bean, Class... group) { + Set> violations = getValidator().validate(bean, group); + if (!violations.isEmpty()) { + throw new ValidationException(violations).withSource(bean); + } + + return bean; + } + + public static T tryValidate(T bean, String property, Class... group) { + Set> violations = getValidator().validateProperty(bean, property, group); + if (!violations.isEmpty()) { + throw new ValidationException(violations).withSource(bean); + } + + return bean; + } + + public static void tryValidate(Class bean, String property, Object value, Class... group) { + Set> violations = getValidator().validateValue(bean, property, value, group); + if (!violations.isEmpty()) { + throw new ValidationException(violations).withSource(value); + } + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/warn/Warning.java b/hsweb-core/src/main/java/org/hswebframework/web/warn/Warning.java new file mode 100644 index 000000000..9147d537f --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/warn/Warning.java @@ -0,0 +1,66 @@ +package org.hswebframework.web.warn; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +@Getter +@AllArgsConstructor +public class Warning { + + private static final Object CONTEXT_KEY = Warning.class; + + private final String code; + + private final Object[] args; + + + public static Context addWarnToContext(ContextView context, Supplier warning) { + Context ctx = createWarning(context); + List warnings = ctx.get(CONTEXT_KEY); + warnings.add(warning.get()); + return ctx; + } + + public static Context createWarning(ContextView context) { + Context ctx = Context.of(context); + if (!ctx.hasKey(CONTEXT_KEY)) { + ctx = ctx.put(CONTEXT_KEY, new CopyOnWriteArrayList<>()); + } + return ctx; + } + + + public static Function> resumeFluxError( + Throwable error, + Function builder) { + return err -> Flux.deferContextual(ctx -> { + Warning warning = builder.apply(err); + if (warning != null && ctx.hasKey(CONTEXT_KEY)) { + ctx.>get(CONTEXT_KEY).add(warning); + } + return Mono.empty(); + }); + } + + public static Function> resumeMonoError( + Throwable error, + Function builder) { + return err -> Mono.deferContextual(ctx -> { + Warning warning = builder.apply(err); + if (warning != null && ctx.hasKey(CONTEXT_KEY)) { + ctx.>get(CONTEXT_KEY).add(warning); + } + return Mono.empty(); + }); + } +} diff --git a/hsweb-core/src/main/resources/i18n/core/messages_en.properties b/hsweb-core/src/main/resources/i18n/core/messages_en.properties new file mode 100644 index 000000000..27ba5f25b --- /dev/null +++ b/hsweb-core/src/main/resources/i18n/core/messages_en.properties @@ -0,0 +1,4 @@ +error.not_found=The data does not exist +error.cant_create_instance=Unable to create instance:{0} +validation.parameter_does_not_exist_in_enums=Parameter {0} does not exist in option +validation.property_validate_failed={0} {1} \ No newline at end of file diff --git a/hsweb-core/src/main/resources/i18n/core/messages_zh.properties b/hsweb-core/src/main/resources/i18n/core/messages_zh.properties new file mode 100644 index 000000000..181700341 --- /dev/null +++ b/hsweb-core/src/main/resources/i18n/core/messages_zh.properties @@ -0,0 +1,5 @@ +error.not_found=数据不存在 +error.cant_create_instance=无法创建实例:{0} + +validation.parameter_does_not_exist_in_enums=参数[{0}]在选择中不存在 +validation.property_validate_failed={0}{1} \ No newline at end of file diff --git a/hsweb-core/src/test/java/org/hswebframework/web/bean/Color.java b/hsweb-core/src/test/java/org/hswebframework/web/bean/Color.java new file mode 100644 index 000000000..214226d54 --- /dev/null +++ b/hsweb-core/src/test/java/org/hswebframework/web/bean/Color.java @@ -0,0 +1,19 @@ +package org.hswebframework.web.bean; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.hswebframework.web.dict.EnumDict; + +@Getter +@AllArgsConstructor +public enum Color implements EnumDict { + RED(1, "红色"), + BLUE(2, "蓝色"); + + private Integer value; + + private String text; + + + +} diff --git a/hsweb-core/src/test/java/org/hswebframework/web/bean/CompareUtilsTest.java b/hsweb-core/src/test/java/org/hswebframework/web/bean/CompareUtilsTest.java new file mode 100644 index 000000000..f6b090a4c --- /dev/null +++ b/hsweb-core/src/test/java/org/hswebframework/web/bean/CompareUtilsTest.java @@ -0,0 +1,190 @@ +package org.hswebframework.web.bean; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.utils.time.DateFormatter; +import org.hswebframework.web.dict.EnumDict; +import org.junit.Assert; +import org.junit.Test; + +import java.math.BigDecimal; +import java.util.*; + +public class CompareUtilsTest { + + @Test + public void nullTest() { + + Assert.assertFalse(CompareUtils.compare(1, null)); + + Assert.assertFalse(CompareUtils.compare((Object) null, 1)); + Assert.assertTrue(CompareUtils.compare((Object) null, null)); + Assert.assertFalse(CompareUtils.compare((Number) null, 1)); + Assert.assertTrue(CompareUtils.compare((Number) null, null)); + Assert.assertFalse(CompareUtils.compare((Date) null, 1)); + Assert.assertTrue(CompareUtils.compare((Date) null, null)); + Assert.assertFalse(CompareUtils.compare((String) null, 1)); + Assert.assertTrue(CompareUtils.compare((String) null, null)); + Assert.assertFalse(CompareUtils.compare((Collection) null, 1)); + Assert.assertTrue(CompareUtils.compare((Collection) null, null)); + Assert.assertFalse(CompareUtils.compare((Map) null, 1)); + Assert.assertTrue(CompareUtils.compare((Map) null, null)); + + } + + @Test + public void numberTest() { + Assert.assertTrue(CompareUtils.compare(1, 1)); + Assert.assertTrue(CompareUtils.compare(1, 1D)); + Assert.assertTrue(CompareUtils.compare(1, 1.0D)); + Assert.assertTrue(CompareUtils.compare(1e3, "1e3")); + Assert.assertTrue(CompareUtils.compare(1e3, "1000")); + + Assert.assertTrue(CompareUtils.compare(1, "1")); + Assert.assertTrue(CompareUtils.compare("1.0", 1)); + Assert.assertFalse(CompareUtils.compare(1, "1a")); + } + + @Test + public void enumTest() { + + Assert.assertTrue(CompareUtils.compare(TestEnum.BLUE, "blue")); + + Assert.assertFalse(CompareUtils.compare(TestEnum.RED, "blue")); + + + Assert.assertTrue(CompareUtils.compare(TestEnumDic.BLUE, "blue")); + + Assert.assertFalse(CompareUtils.compare(TestEnumDic.RED, "blue")); + + + Assert.assertTrue(CompareUtils.compare(TestEnumDic.BLUE, "蓝色")); + + Assert.assertFalse(CompareUtils.compare(TestEnumDic.RED, "蓝色")); + + Assert.assertFalse(CompareUtils.compare((Object) TestEnumDic.RED, TestEnumDic.BLUE)); + + Assert.assertTrue(CompareUtils.compare((Object) TestEnumDic.RED, TestEnumDic.RED)); + + + } + + @Test + public void stringTest() { + + Assert.assertTrue(CompareUtils.compare("20180101", DateFormatter.fromString("20180101"))); + + Assert.assertTrue(CompareUtils.compare(1, "1")); + + Assert.assertTrue(CompareUtils.compare("1", 1)); + + Assert.assertTrue(CompareUtils.compare("1.0", 1.0D)); + + Assert.assertTrue(CompareUtils.compare("1.01", 1.01D)); + + Assert.assertTrue(CompareUtils.compare("1,2,3", Arrays.asList(1, 2, 3))); + + Assert.assertTrue(CompareUtils.compare("blue", TestEnumDic.BLUE)); + + Assert.assertTrue(CompareUtils.compare("BLUE", TestEnum.BLUE)); + + + } + + @Test + public void dateTest() { + + Date date = new Date(); + + Assert.assertTrue(CompareUtils.compare(date, new Date(date.getTime()))); + Assert.assertTrue(CompareUtils.compare(date, DateFormatter.toString(date, "yyyy-MM-dd"))); + Assert.assertTrue(CompareUtils.compare(date, DateFormatter.toString(date, "yyyy-MM-dd HH:mm:ss"))); + + + Assert.assertTrue(CompareUtils.compare(date, date.getTime())); + Assert.assertTrue(CompareUtils.compare(date.getTime(), date)); + + } + + @Test + public void connectionTest() { + Date date = new Date(); + + Assert.assertTrue(CompareUtils.compare(100, new BigDecimal("100"))); + + Assert.assertTrue(CompareUtils.compare(new BigDecimal("100"), 100.0D)); + + Assert.assertTrue(CompareUtils.compare(Arrays.asList(1, 2, 3), Arrays.asList("3", "2", "1"))); + + Assert.assertFalse(CompareUtils.compare(Arrays.asList(1, 2, 3), Arrays.asList("3", "3", "1"))); + + Assert.assertFalse(CompareUtils.compare(Arrays.asList(1, 2, 3), Arrays.asList("3", "1"))); + + Assert.assertFalse(CompareUtils.compare(Arrays.asList(1, 2, 3), Collections.emptyList())); + Assert.assertFalse(CompareUtils.compare(Collections.emptyList(), Arrays.asList(1, 2, 3))); + + Assert.assertTrue(CompareUtils.compare(Arrays.asList(date, 3), Arrays.asList("3", DateFormatter.toString(date, "yyyy-MM-dd")))); + + } + + @Test + public void mapTest() { + Date date = new Date(); + + Assert.assertTrue(CompareUtils.compare(Collections.singletonMap("test", "123"), Collections.singletonMap("test", 123))); + + + Assert.assertFalse(CompareUtils.compare(Collections.singletonMap("test", "123"), Collections.emptyMap())); + + Assert.assertTrue(CompareUtils.compare(Collections.singletonMap("test", "123"), new TestBean("123"))); + + Assert.assertTrue(CompareUtils.compare(Collections.singletonMap("test", date), new TestBean(DateFormatter.toString(date, "yyyy-MM-dd")))); + + } + + @Test + public void beanTest() { + Date date = new Date(); + + Assert.assertTrue(CompareUtils.compare(new TestBean(date), new TestBean(DateFormatter.toString(date, "yyyy-MM-dd")))); + + Assert.assertTrue(CompareUtils.compare(new TestBean(1), new TestBean("1"))); + + Assert.assertTrue(CompareUtils.compare(new TestBean(1), new TestBean("1.0"))); + Assert.assertFalse(CompareUtils.compare(new TestBean(1), new TestBean("1.0000000001"))); + + } + + @Getter + @Setter + @AllArgsConstructor + public static class TestBean { + private Object test; + } + + + enum TestEnum { + RED, BLUE + } + + @Getter + @AllArgsConstructor + enum TestEnumDic implements EnumDict { + RED("RED", "红色") { + public void function() { + + } + }, + BLUE("BLUE", "蓝色") { + public void function() { + + } + }; + + private final String value; + private final String text; + + } + +} \ No newline at end of file diff --git a/hsweb-core/src/test/java/org/hswebframework/web/bean/DiffTest.java b/hsweb-core/src/test/java/org/hswebframework/web/bean/DiffTest.java new file mode 100644 index 000000000..a87694160 --- /dev/null +++ b/hsweb-core/src/test/java/org/hswebframework/web/bean/DiffTest.java @@ -0,0 +1,35 @@ +package org.hswebframework.web.bean; + +import org.hswebframework.utils.time.DateFormatter; +import org.junit.Assert; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class DiffTest { + + @Test + public void mapTest() { + Map before = new HashMap<>(); + before.put("name", "name"); + before.put("age",21); + before.put("bool", true); + before.put("bool", false); + before.put("birthday", DateFormatter.fromString("19910101")); + + Map after = new HashMap<>(); + after.put("name", "name"); + after.put("age", "21"); + after.put("bool", "true"); + after.put("bool", "false"); + after.put("birthday", "1991-01-01"); + + + List diffs = Diff.of(before, after); + System.out.println(diffs); + Assert.assertTrue(diffs.isEmpty()); + + } +} \ No newline at end of file diff --git a/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java b/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java new file mode 100644 index 000000000..c12dba4c6 --- /dev/null +++ b/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java @@ -0,0 +1,308 @@ +package org.hswebframework.web.bean; + +import com.google.common.collect.ImmutableMap; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import org.hswebframework.ezorm.core.DefaultExtendable; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.util.ClassUtils; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; + +/** + * @author zhouhao + * @since 3.0 + */ +public class FastBeanCopierTest { + + @Test + public void testExtendableToExtendable() { + ExtendableEntity source = new ExtendableEntity(); + source.setName("test"); + source.setExtension("age", 123); + source.setExtension("color", Color.RED); + + ExtendableEntity e = FastBeanCopier.copy(source, new ExtendableEntity()); + + Assert.assertEquals(source.getName(), e.getName()); + Assert.assertEquals(source.getExtension("age"), e.getExtension("age")); + Assert.assertEquals(source.getExtension("color"), e.getExtension("color")); + + } + @Test + public void testToExtendable() { + Source source = new Source(); + source.setName("test"); + source.setAge(123); + source.setColor(Color.RED); + ExtendableEntity e = FastBeanCopier.copy(source, new ExtendableEntity()); + + Assert.assertEquals(source.getName(), e.getName()); + Assert.assertEquals(source.getAge(), e.getExtension("age")); + Assert.assertEquals(source.getColor(), e.getExtension("color")); + + Map map = FastBeanCopier.copy(e, new HashMap<>()); + System.out.println(map); + + ExtendableEntity t = FastBeanCopier.copy(map, new ExtendableEntity()); + Assert.assertEquals(e.getName(), t.getName()); + + System.out.println(e.extensions()); + System.out.println(t.extensions()); + Assert.assertEquals(e.extensions(), t.extensions()); + + } + + @Test + public void testFromExtendable() { + Source source = new Source(); + ExtendableEntity e = FastBeanCopier.copy(source, new ExtendableEntity()); + e.setName("test"); + e.setExtension("age",123); + FastBeanCopier.copy(e, source); + Assert.assertEquals(e.getName(), source.getName()); + Assert.assertEquals(e.getExtension("age"), source.getAge()); + + + } + @Test + public void testMapToExtendable() { + Source source = new Source(); + source.setName("test"); + source.setAge(123); + source.setColor(Color.RED); + Map map = FastBeanCopier.copy(source, new HashMap<>()); + ExtendableEntity e = FastBeanCopier.copy(map, new ExtendableEntity()); + Assert.assertEquals(source.getName(), e.getName()); + Assert.assertEquals(source.getAge(), e.getExtension("age")); + Assert.assertEquals(source.getColor(), e.getExtension("color")); + } + + + @Getter + @Setter + public static class ExtendableEntity extends DefaultExtendable { + + private String name; + + private boolean boy2; + } + + @Test + public void test() throws InvocationTargetException, IllegalAccessException { + Source source = new Source(); + source.setAge(100); + source.setName("测试"); + source.setIds(new String[]{"1", "2", "3"}); + source.setAge2(2); + source.setBoy2(true); + source.setColor(Color.RED); + source.setNestObject2(Collections.singletonMap("name", "mapTest")); + NestObject nestObject = new NestObject(); + nestObject.setAge(10); + nestObject.setPassword("1234567"); + nestObject.setName("测试2"); + source.setNestObject(nestObject); + source.setNestObject3(nestObject); + + Target target = new Target(); + FastBeanCopier.copy(source, target); + + + System.out.println(source); + System.out.println(target); + System.out.println(target.getNestObject() == source.getNestObject()); + } + + @Test + public void testMapArray() { + Map data = new HashMap<>(); + data.put("colors", Arrays.asList("RED")); + + + Target target = new Target(); + FastBeanCopier.copy(data, target); + + + System.out.println(target); + Assert.assertNotNull(target.getColors()); + Assert.assertSame(target.getColors()[0], Color.RED); + + } + + @Test + public void testMapList() { + Map data = new HashMap<>(); + data.put("templates", new HashMap() { + { + put("0", Collections.singletonMap("name", "test")); + put("1", Collections.singletonMap("name", "test")); + } + }); + + Config config = FastBeanCopier.copy(data, new Config()); + + Assert.assertNotNull(config); + Assert.assertNotNull(config.templates); + System.out.println(config.templates); + Assert.assertEquals(2, config.templates.size()); + + + } + + @Getter + @Setter + public static class Config { + private List