diff --git a/.gitee/ISSUE_TEMPLATE.zh-CN.md b/.gitee/ISSUE_TEMPLATE.zh-CN.md new file mode 100644 index 000000000..8248afe06 --- /dev/null +++ b/.gitee/ISSUE_TEMPLATE.zh-CN.md @@ -0,0 +1,25 @@ +碰到问题,请在 搜索是否存在相似的 issue。 + +不按照模板提交的 issue,会被系统自动删除。 + +### 基本信息 + +- ruoyi-vue-pro 版本: +- 操作系统: +- 数据库: + +### 你猜测可能的原因 + +(必填)我花费了 2-4 小时自查,发现可能的原因是:xxxxxx + +### 复现步骤 + +第一步, + +第二步, + +第三步, + +### 报错信息 + +带上必要的截图 diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..4dce870b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,33 @@ +--- +name: 问题反馈 +about: 请详细描述,以便更高快的获得到解决 +title: '' +labels: '' +assignees: '' + +--- + + +不按照模板提交的 issue,会被系统自动删除。 + +### 基本信息 + +- ruoyi-vue-pro 版本: +- 操作系统: +- 数据库: + +### 你猜测可能的原因 + +(必填)我花费了 2-4 小时自查,发现可能的原因是:xxxxxx + +### 复现步骤 + +第一步, + +第二步, + +第三步, + +### 报错信息 + +带上必要的截图 diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 000000000..7c765927a --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,30 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: [ master ] + # pull_request: + # branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + java: [ '8', '11', '17' ] + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK ${{ matrix.Java }} + uses: actions/setup-java@v2 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml -Dmaven.test.skip=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e55eb64b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ + +# 查看更多 .gitignore 配置 -> https://help.github.com/articles/ignoring-files/ + +target/ +!.mvn/wrapper/maven-wrapper.jar + +.flattened-pom.xml + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +*.class +target/* + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +/build/ + + + +### admin-web ### + +# dependencies +**/node_modules + +# roadhog-api-doc ignore +/src/utils/request-temp.js +_roadhog-api-doc + +# production +/dist +/.vscode + +# misc +.DS_Store +npm-debug.log* +yarn-error.log + +/coverage +.idea +yarn.lock +package-lock.json +*bak +.vscode + +# visual studio code +.history +*.log + +functions/mock +.temp/** + +# umi +.umi +.umi-production + +# screenshot +screenshot +.firebase +sessionStore diff --git a/.image/Java监控.jpg b/.image/Java监控.jpg new file mode 100644 index 000000000..6ad522ab0 Binary files /dev/null and b/.image/Java监控.jpg differ diff --git a/.image/MySQL.jpg b/.image/MySQL.jpg new file mode 100644 index 000000000..64a1940c2 Binary files /dev/null and b/.image/MySQL.jpg differ diff --git a/.image/OA请假-列表.jpg b/.image/OA请假-列表.jpg new file mode 100644 index 000000000..787bb73fa Binary files /dev/null and b/.image/OA请假-列表.jpg differ diff --git a/.image/OA请假-发起.jpg b/.image/OA请假-发起.jpg new file mode 100644 index 000000000..1a7342d7a Binary files /dev/null and b/.image/OA请假-发起.jpg differ diff --git a/.image/OA请假-详情.jpg b/.image/OA请假-详情.jpg new file mode 100644 index 000000000..a83e7c148 Binary files /dev/null and b/.image/OA请假-详情.jpg differ diff --git a/.image/Redis.jpg b/.image/Redis.jpg new file mode 100644 index 000000000..956935264 Binary files /dev/null and b/.image/Redis.jpg differ diff --git a/.image/admin-uniapp/01.png b/.image/admin-uniapp/01.png new file mode 100644 index 000000000..0f65d99e3 Binary files /dev/null and b/.image/admin-uniapp/01.png differ diff --git a/.image/admin-uniapp/02.png b/.image/admin-uniapp/02.png new file mode 100644 index 000000000..05ec781c7 Binary files /dev/null and b/.image/admin-uniapp/02.png differ diff --git a/.image/admin-uniapp/03.png b/.image/admin-uniapp/03.png new file mode 100644 index 000000000..f400c6884 Binary files /dev/null and b/.image/admin-uniapp/03.png differ diff --git a/.image/admin-uniapp/04.png b/.image/admin-uniapp/04.png new file mode 100644 index 000000000..d5d5ea072 Binary files /dev/null and b/.image/admin-uniapp/04.png differ diff --git a/.image/admin-uniapp/05.png b/.image/admin-uniapp/05.png new file mode 100644 index 000000000..1de6d8ae1 Binary files /dev/null and b/.image/admin-uniapp/05.png differ diff --git a/.image/admin-uniapp/06.png b/.image/admin-uniapp/06.png new file mode 100644 index 000000000..400ae90b1 Binary files /dev/null and b/.image/admin-uniapp/06.png differ diff --git a/.image/admin-uniapp/07.png b/.image/admin-uniapp/07.png new file mode 100644 index 000000000..2ed8c0ff6 Binary files /dev/null and b/.image/admin-uniapp/07.png differ diff --git a/.image/admin-uniapp/08.png b/.image/admin-uniapp/08.png new file mode 100644 index 000000000..090e64a67 Binary files /dev/null and b/.image/admin-uniapp/08.png differ diff --git a/.image/admin-uniapp/09.png b/.image/admin-uniapp/09.png new file mode 100644 index 000000000..f2032c8a8 Binary files /dev/null and b/.image/admin-uniapp/09.png differ diff --git a/.image/common/mall-feature.png b/.image/common/mall-feature.png new file mode 100644 index 000000000..cca05c0e6 Binary files /dev/null and b/.image/common/mall-feature.png differ diff --git a/.image/common/mall-preview.png b/.image/common/mall-preview.png new file mode 100644 index 000000000..e164cd2a1 Binary files /dev/null and b/.image/common/mall-preview.png differ diff --git a/.image/common/project-vs.png b/.image/common/project-vs.png new file mode 100644 index 000000000..561e092f4 Binary files /dev/null and b/.image/common/project-vs.png differ diff --git a/.image/common/ruoyi-vue-pro-architecture.png b/.image/common/ruoyi-vue-pro-architecture.png new file mode 100644 index 000000000..7bd7d59ab Binary files /dev/null and b/.image/common/ruoyi-vue-pro-architecture.png differ diff --git a/.image/common/ruoyi-vue-pro-biz.png b/.image/common/ruoyi-vue-pro-biz.png new file mode 100644 index 000000000..24a385abe Binary files /dev/null and b/.image/common/ruoyi-vue-pro-biz.png differ diff --git a/.image/common/yudao-cloud-architecture.png b/.image/common/yudao-cloud-architecture.png new file mode 100644 index 000000000..59416d806 Binary files /dev/null and b/.image/common/yudao-cloud-architecture.png differ diff --git a/.image/common/yudao-roadmap.png b/.image/common/yudao-roadmap.png new file mode 100644 index 000000000..f4becc98c Binary files /dev/null and b/.image/common/yudao-roadmap.png differ diff --git a/.image/个人中心.jpg b/.image/个人中心.jpg new file mode 100644 index 000000000..ce57f6e1e Binary files /dev/null and b/.image/个人中心.jpg differ diff --git a/.image/代码生成.jpg b/.image/代码生成.jpg new file mode 100644 index 000000000..751603efb Binary files /dev/null and b/.image/代码生成.jpg differ diff --git a/.image/令牌管理.jpg b/.image/令牌管理.jpg new file mode 100644 index 000000000..04abf4d21 Binary files /dev/null and b/.image/令牌管理.jpg differ diff --git a/.image/任务列表-审批.jpg b/.image/任务列表-审批.jpg new file mode 100644 index 000000000..cba312a0e Binary files /dev/null and b/.image/任务列表-审批.jpg differ diff --git a/.image/任务列表-已办.jpg b/.image/任务列表-已办.jpg new file mode 100644 index 000000000..7a8d0fb17 Binary files /dev/null and b/.image/任务列表-已办.jpg differ diff --git a/.image/任务列表-待办.jpg b/.image/任务列表-待办.jpg new file mode 100644 index 000000000..a90323fb1 Binary files /dev/null and b/.image/任务列表-待办.jpg differ diff --git a/.image/任务日志.jpg b/.image/任务日志.jpg new file mode 100644 index 000000000..599e50a9c Binary files /dev/null and b/.image/任务日志.jpg differ diff --git a/.image/商户信息.jpg b/.image/商户信息.jpg new file mode 100644 index 000000000..483eace14 Binary files /dev/null and b/.image/商户信息.jpg differ diff --git a/.image/在线用户.jpg b/.image/在线用户.jpg new file mode 100644 index 000000000..b183009b6 Binary files /dev/null and b/.image/在线用户.jpg differ diff --git a/.image/大屏设计器-列表.jpg b/.image/大屏设计器-列表.jpg new file mode 100644 index 000000000..9a45c3bd1 Binary files /dev/null and b/.image/大屏设计器-列表.jpg differ diff --git a/.image/大屏设计器-编辑.jpg b/.image/大屏设计器-编辑.jpg new file mode 100644 index 000000000..63298a0c1 Binary files /dev/null and b/.image/大屏设计器-编辑.jpg differ diff --git a/.image/大屏设计器-预览.jpg b/.image/大屏设计器-预览.jpg new file mode 100644 index 000000000..501d9ea2c Binary files /dev/null and b/.image/大屏设计器-预览.jpg differ diff --git a/.image/字典数据.jpg b/.image/字典数据.jpg new file mode 100644 index 000000000..8298c893f Binary files /dev/null and b/.image/字典数据.jpg differ diff --git a/.image/字典类型.jpg b/.image/字典类型.jpg new file mode 100644 index 000000000..6613392f6 Binary files /dev/null and b/.image/字典类型.jpg differ diff --git a/.image/定时任务.jpg b/.image/定时任务.jpg new file mode 100644 index 000000000..d5bbd8510 Binary files /dev/null and b/.image/定时任务.jpg differ diff --git a/.image/岗位管理.jpg b/.image/岗位管理.jpg new file mode 100644 index 000000000..42b64d2c6 Binary files /dev/null and b/.image/岗位管理.jpg differ diff --git a/.image/应用信息-列表.jpg b/.image/应用信息-列表.jpg new file mode 100644 index 000000000..da419a24a Binary files /dev/null and b/.image/应用信息-列表.jpg differ diff --git a/.image/应用信息-编辑.jpg b/.image/应用信息-编辑.jpg new file mode 100644 index 000000000..913cfbc81 Binary files /dev/null and b/.image/应用信息-编辑.jpg differ diff --git a/.image/应用管理.jpg b/.image/应用管理.jpg new file mode 100644 index 000000000..6e7789fca Binary files /dev/null and b/.image/应用管理.jpg differ diff --git a/.image/我的流程-列表.jpg b/.image/我的流程-列表.jpg new file mode 100644 index 000000000..223d17afa Binary files /dev/null and b/.image/我的流程-列表.jpg differ diff --git a/.image/我的流程-发起.jpg b/.image/我的流程-发起.jpg new file mode 100644 index 000000000..7a833062b Binary files /dev/null and b/.image/我的流程-发起.jpg differ diff --git a/.image/我的流程-详情.jpg b/.image/我的流程-详情.jpg new file mode 100644 index 000000000..6a0154182 Binary files /dev/null and b/.image/我的流程-详情.jpg differ diff --git a/.image/报表设计器-图形报表.jpg b/.image/报表设计器-图形报表.jpg new file mode 100644 index 000000000..681b31852 Binary files /dev/null and b/.image/报表设计器-图形报表.jpg differ diff --git a/.image/报表设计器-打印设计.jpg b/.image/报表设计器-打印设计.jpg new file mode 100644 index 000000000..bb86da64e Binary files /dev/null and b/.image/报表设计器-打印设计.jpg differ diff --git a/.image/报表设计器-数据报表.jpg b/.image/报表设计器-数据报表.jpg new file mode 100644 index 000000000..9ca5b9b64 Binary files /dev/null and b/.image/报表设计器-数据报表.jpg differ diff --git a/.image/操作日志.jpg b/.image/操作日志.jpg new file mode 100644 index 000000000..4a0611a34 Binary files /dev/null and b/.image/操作日志.jpg differ diff --git a/.image/支付订单.jpg b/.image/支付订单.jpg new file mode 100644 index 000000000..0a56dd74d Binary files /dev/null and b/.image/支付订单.jpg differ diff --git a/.image/敏感词.jpg b/.image/敏感词.jpg new file mode 100644 index 000000000..92a539747 Binary files /dev/null and b/.image/敏感词.jpg differ diff --git a/.image/数据库文档.jpg b/.image/数据库文档.jpg new file mode 100644 index 000000000..a4339d960 Binary files /dev/null and b/.image/数据库文档.jpg differ diff --git a/.image/文件管理.jpg b/.image/文件管理.jpg new file mode 100644 index 000000000..054b19f1e Binary files /dev/null and b/.image/文件管理.jpg differ diff --git a/.image/文件管理2.jpg b/.image/文件管理2.jpg new file mode 100644 index 000000000..b12e5c3cc Binary files /dev/null and b/.image/文件管理2.jpg differ diff --git a/.image/文件配置.jpg b/.image/文件配置.jpg new file mode 100644 index 000000000..e618049ae Binary files /dev/null and b/.image/文件配置.jpg differ diff --git a/.image/日志中心.jpg b/.image/日志中心.jpg new file mode 100644 index 000000000..27c1c6cb5 Binary files /dev/null and b/.image/日志中心.jpg differ diff --git a/.image/流程模型-列表.jpg b/.image/流程模型-列表.jpg new file mode 100644 index 000000000..ffdc58405 Binary files /dev/null and b/.image/流程模型-列表.jpg differ diff --git a/.image/流程模型-定义.jpg b/.image/流程模型-定义.jpg new file mode 100644 index 000000000..18b316c7c Binary files /dev/null and b/.image/流程模型-定义.jpg differ diff --git a/.image/流程模型-设计.jpg b/.image/流程模型-设计.jpg new file mode 100644 index 000000000..961496905 Binary files /dev/null and b/.image/流程模型-设计.jpg differ diff --git a/.image/流程表单.jpg b/.image/流程表单.jpg new file mode 100644 index 000000000..60669c148 Binary files /dev/null and b/.image/流程表单.jpg differ diff --git a/.image/生成效果.jpg b/.image/生成效果.jpg new file mode 100644 index 000000000..98ff2cca5 Binary files /dev/null and b/.image/生成效果.jpg differ diff --git a/.image/用户分组.jpg b/.image/用户分组.jpg new file mode 100644 index 000000000..39af1cd19 Binary files /dev/null and b/.image/用户分组.jpg differ diff --git a/.image/用户管理.jpg b/.image/用户管理.jpg new file mode 100644 index 000000000..844604a64 Binary files /dev/null and b/.image/用户管理.jpg differ diff --git a/.image/登录.jpg b/.image/登录.jpg new file mode 100644 index 000000000..b782b988c Binary files /dev/null and b/.image/登录.jpg differ diff --git a/.image/登录日志.jpg b/.image/登录日志.jpg new file mode 100644 index 000000000..25662d97b Binary files /dev/null and b/.image/登录日志.jpg differ diff --git a/.image/短信日志.jpg b/.image/短信日志.jpg new file mode 100644 index 000000000..ada8e56db Binary files /dev/null and b/.image/短信日志.jpg differ diff --git a/.image/短信模板.jpg b/.image/短信模板.jpg new file mode 100644 index 000000000..09381cc51 Binary files /dev/null and b/.image/短信模板.jpg differ diff --git a/.image/短信渠道.jpg b/.image/短信渠道.jpg new file mode 100644 index 000000000..df3a5c39b Binary files /dev/null and b/.image/短信渠道.jpg differ diff --git a/.image/租户套餐.png b/.image/租户套餐.png new file mode 100644 index 000000000..966316798 Binary files /dev/null and b/.image/租户套餐.png differ diff --git a/.image/租户管理.jpg b/.image/租户管理.jpg new file mode 100644 index 000000000..647416a9f Binary files /dev/null and b/.image/租户管理.jpg differ diff --git a/.image/系统接口.jpg b/.image/系统接口.jpg new file mode 100644 index 000000000..6d39d4211 Binary files /dev/null and b/.image/系统接口.jpg differ diff --git a/.image/菜单管理.jpg b/.image/菜单管理.jpg new file mode 100644 index 000000000..ad3b7979c Binary files /dev/null and b/.image/菜单管理.jpg differ diff --git a/.image/表单构建.jpg b/.image/表单构建.jpg new file mode 100644 index 000000000..81f037466 Binary files /dev/null and b/.image/表单构建.jpg differ diff --git a/.image/角色管理.jpg b/.image/角色管理.jpg new file mode 100644 index 000000000..eed776e86 Binary files /dev/null and b/.image/角色管理.jpg differ diff --git a/.image/访问日志.jpg b/.image/访问日志.jpg new file mode 100644 index 000000000..ef301aad4 Binary files /dev/null and b/.image/访问日志.jpg differ diff --git a/.image/退款订单.jpg b/.image/退款订单.jpg new file mode 100644 index 000000000..2c6c6c9ee Binary files /dev/null and b/.image/退款订单.jpg differ diff --git a/.image/通知公告.jpg b/.image/通知公告.jpg new file mode 100644 index 000000000..97bb42fe4 Binary files /dev/null and b/.image/通知公告.jpg differ diff --git a/.image/部门管理.jpg b/.image/部门管理.jpg new file mode 100644 index 000000000..6eab2330c Binary files /dev/null and b/.image/部门管理.jpg differ diff --git a/.image/配置管理.jpg b/.image/配置管理.jpg new file mode 100644 index 000000000..0abaec932 Binary files /dev/null and b/.image/配置管理.jpg differ diff --git a/.image/链路追踪.jpg b/.image/链路追踪.jpg new file mode 100644 index 000000000..12f7aa8ef Binary files /dev/null and b/.image/链路追踪.jpg differ diff --git a/.image/错误日志.jpg b/.image/错误日志.jpg new file mode 100644 index 000000000..eb615ea3f Binary files /dev/null and b/.image/错误日志.jpg differ diff --git a/.image/错误码管理.jpg b/.image/错误码管理.jpg new file mode 100644 index 000000000..ea91dde14 Binary files /dev/null and b/.image/错误码管理.jpg differ diff --git a/.image/首页.jpg b/.image/首页.jpg new file mode 100644 index 000000000..10a7fde76 Binary files /dev/null and b/.image/首页.jpg differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..ddd2f12f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2021 cf-cloud + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 16860fdd1..28e4c96bd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,151 @@ -# cf_imes_server +## 🐶 新手必读 + +* 启动文档: + +## 🐯 平台简介 + +![架构图](/.image/common/cf-cloud-architecture.png) + +* Java 后端:`master` 分支为 JDK 8 + Spring Boot 2.7.18,`master-jdk21` 分支为 JDK21 + Spring Boot 3.2.0 +* 管理后台的电脑端:Vue3 提供 [element-plus](https://gitee.com/chenfengcode/cf-ui-admin-vue3)、[vben(ant-design-vue)](https://gitee.com/chenfengcode/cf-ui-admin-vben) 两个版本,Vue2 提供 [element-ui](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master/cf-ui-admin) 版本 +* 管理后台的移动端:采用 [uni-app](https://github.com/dcloudio/uni-app) 方案,一份代码多终端适配,同时支持 APP、小程序、H5! +* 后端采用 Spring Cloud Alibaba 微服务架构,注册中心 + 配置中心 Nacos,定时任务 XXL-Job,服务保障 Sentinel,服务网关 Gateway,分布式事务 Seata +* 数据库可使用 MySQL、Oracle、PostgreSQL、SQL Server、MariaDB、国产达梦 DM、TiDB 等,基于 MyBatis Plus、Redis + Redisson 操作 +* 消息队列可使用 Event、Redis、RabbitMQ、Kafka、RocketMQ 等 +* 权限认证使用 Spring Security & Token & Redis,支持多终端、多种用户的认证系统,支持 SSO 单点登录 +* 支持加载动态权限菜单,按钮级别权限控制,Redis 缓存提升性能 +* 支持 SaaS 多组织系统,可自定义每个组织的权限,提供透明化的多组织底层封装 +* 高效率开发,使用代码生成器可以一键生成 Java、Vue 前后端代码、SQL 脚本、接口文档,支持单表、树表、主子表 +* 实时通信,采用 Spring WebSocket 实现,内置 Token 身份校验,支持 WebSocket 集群 +* 集成阿里云、腾讯云等短信渠道,集成 MinIO、阿里云、腾讯云、七牛云等云存储服务 + +## 🐼 内置功能 + +系统内置多种多种业务功能,可以用于快速你的业务系统: + +![功能分层](/.image/common/ruoyi-vue-pro-biz.png) + +* 系统功能 +* 基础设施 + +🙂 所有功能,都通过 **单元测试** 保证高质量。 + +### 系统功能 + +| | 功能 | 描述 | +|-----|-------|---------------------------------| +| | 用户管理 | 用户是系统操作者,该功能主要完成系统用户配置 | +| ⭐️ | 在线用户 | 当前系统中活跃用户状态监控,支持手动踢下线 | +| | 角色管理 | 角色菜单权限分配、设置角色按机构进行数据范围权限划分 | +| | 菜单管理 | 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 | +| | 部门管理 | 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 | +| | 岗位管理 | 配置系统用户所属担任职务 | +| 🚀 | 组织管理 | 配置系统组织,支持 SaaS 场景下的多组织功能 | +| 🚀 | 组织套餐 | 配置组织套餐,自定每个组织的菜单、操作、按钮的权限 | +| | 字典管理 | 对系统中经常使用的一些较为固定的数据进行维护 | +| 🚀 | 短信管理 | 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台 | +| 🚀 | 邮件管理 | 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 | +| 🚀 | 站内信 | 系统内的消息通知,提供站内信模版、站内信消息 | +| 🚀 | 操作日志 | 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 | +| ⭐️ | 登录日志 | 系统登录日志记录查询,包含登录异常 | +| 🚀 | 错误码管理 | 系统所有错误码的管理,可在线修改错误提示,无需重启服务 | +| | 通知公告 | 系统通知公告信息发布维护 | +| 🚀 | 敏感词 | 配置系统敏感词,支持标签分组 | +| 🚀 | 应用管理 | 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 | +| 🚀 | 地区管理 | 展示省份、城市、区镇等城市信息,支持 IP 对应城市 | + +### 基础设施 + +| | 功能 | 描述 | +|----|-----------|----------------------------------------------| +| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 | +| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 | +| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 | +| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 | +| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 | +| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 | +| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 | +| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 | +| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 | +| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 | +| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 | +| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 | +| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 | +| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 | +| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 | +| 🚀 | 分布式锁 | 基于 Redis 实现分布式锁,满足并发场景 | +| 🚀 | 幂等组件 | 基于 Redis 实现幂等组件,解决重复请求问题 | +| 🚀 | 服务保障 | 基于 Resilience4j 实现服务的稳定性,包括限流、熔断等功能 | +| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 | +| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 | + + +## 🐨 技术栈 + +### 微服务 + +| 项目 | 说明 | +|--------------------------------------------------------------------------|--------------------| +| `cf-dependencies` | Maven 依赖版本管理 | +| `cf-framework` | Java 框架拓展 | +| `cf-server` | 管理后台 + 用户 APP 的服务端 | +| `cf-module-system` | 系统功能的 Module 模块 | +| `cf-module-infra` | 基础设施的 Module 模块 | + +### 框架 + +| 框架 | 说明 | 版本 | 学习指南 | +|---------------------------------------------------------------------------------------------|------------------|-------------|---------------------------------------------------------------------| +| [Spring Cloud Alibaba](https://github.com/alibaba/spring-cloud-alibaba) | 微服务框架 | 2021.0.4.0 | [文档](https://github.com/YunaiV/SpringBoot-Labs) | +| [Nacos](https://github.com/alibaba/nacos) | 配置中心 & 注册中心 | 2.0.4 | [文档](https://www.cf.com/categories/Nacos/?chenfeng) | +| [RocketMQ](https://github.com/apache/rocketmq) | 消息队列 | 4.9.4 | [文档](https://www.cf.com/categories/RocketMQ/?chenfeng) | +| [Sentinel](https://github.com/alibaba/sentinel) | 服务保障 | 1.8.6 | [文档](https://www.cf.com/categories/Sentinel/?chenfeng) | +| [XXL Job](https://github.com/xuxueli/xxl-job) | 定时任务 | 2.3.1 | [文档](https://www.cf.com/XXL-JOB/good-collection/?chenfeng) | +| [Spring Cloud Gateway](https://github.com/spring-cloud/spring-cloud-gateway) | 服务网关 | 3.4.1 | [文档](https://www.cf.com/categories/Spring-Cloud-Gateway/?chenfeng) | +| [Seata](https://github.com/seata/seata) | 分布式事务 | 1.6.1 | [文档](https://www.cf.com/categories/Seata/?chenfeng) | +| [MySQL](https://www.mysql.com/cn/) | 数据库服务器 | 5.7 / 8.0+ | | +| [Druid](https://github.com/alibaba/druid) | JDBC 连接池、监控组件 | 1.2.19 | [文档](http://www.cf.com/Spring-Boot/datasource-pool/?chenfeng) | +| [MyBatis Plus](https://mp.baomidou.com/) | MyBatis 增强工具包 | 3.5.3.1 | [文档](http://www.cf.com/Spring-Boot/MyBatis/?chenfeng) | +| [Dynamic Datasource](https://dynamic-datasource.com/) | 动态数据源 | 3.6.0 | [文档](http://www.cf.com/Spring-Boot/datasource-pool/?chenfeng) | +| [Redis](https://redis.io/) | key-value 数据库 | 5.0 / 6.0 | | +| [Redisson](https://github.com/redisson/redisson) | Redis 客户端 | 3.18.0 | [文档](http://www.cf.com/Spring-Boot/Redis/?chenfeng) | +| [Spring MVC](https://github.com/spring-projects/spring-framework/tree/master/spring-webmvc) | MVC 框架 | 5.3.24 | [文档](http://www.cf.com/SpringMVC/MVC/?chenfeng) | +| [Spring Security](https://github.com/spring-projects/spring-security) | Spring 安全框架 | 5.7.5 | [文档](http://www.cf.com/Spring-Boot/Spring-Security/?chenfeng) | +| [Hibernate Validator](https://github.com/hibernate/hibernate-validator) | 参数校验组件 | 6.2.5 | [文档](http://www.cf.com/Spring-Boot/Validation/?chenfeng) | +| [Flowable](https://github.com/flowable/flowable-engine) | 工作流引擎 | 6.7.2 | [文档](https://doc.iocoder.cn/bpm/) | +| [Knife4j](https://gitee.com/xiaoym/knife4j) | Swagger 增强 UI 实现 | 4.0.0 | [文档](http://www.cf.com/Spring-Boot/Swagger/?chenfeng) | +| [SkyWalking](https://skywalking.apache.org/) | 分布式应用追踪系统 | 8.12.0 | [文档](http://www.cf.com/Spring-Boot/SkyWalking/?chenfeng) | +| [Spring Boot Admin](https://github.com/codecentric/spring-boot-admin) | Spring Boot 监控平台 | 2.7.10 | [文档](http://www.cf.com/Spring-Boot/Admin/?chenfeng) | +| [Jackson](https://github.com/FasterXML/jackson) | JSON 工具库 | 2.13.3 | | +| [MapStruct](https://mapstruct.org/) | Java Bean 转换 | 1.5.5.Final | [文档](http://www.cf.com/Spring-Boot/MapStruct/?chenfeng) | +| [Lombok](https://projectlombok.org/) | 消除冗长的 Java 代码 | 1.18.26 | [文档](http://www.cf.com/Spring-Boot/Lombok/?chenfeng) | +| [JUnit](https://junit.org/junit5/) | Java 单元测试框架 | 5.8.2 | - | +| [Mockito](https://github.com/mockito/mockito) | Java Mock 框架 | 4.8.0 | - | + +### 系统功能 + +| 模块 | biu | biu | biu | +|----------|-----------------------------|---------------------------|--------------------------| +| 登录 & 首页 | ![登录](/.image/登录.jpg) | ![首页](/.image/首页.jpg) | ![个人中心](/.image/个人中心.jpg) | +| 用户 & 应用 | ![用户管理](/.image/用户管理.jpg) | ![令牌管理](/.image/令牌管理.jpg) | ![应用管理](/.image/应用管理.jpg) | +| 组织 & 套餐 | ![组织管理](/.image/组织管理.jpg) | ![组织套餐](/.image/组织套餐.png) | - | +| 部门 & 岗位 | ![部门管理](/.image/部门管理.jpg) | ![岗位管理](/.image/岗位管理.jpg) | - | +| 菜单 & 角色 | ![菜单管理](/.image/菜单管理.jpg) | ![角色管理](/.image/角色管理.jpg) | - | +| 审计日志 | ![操作日志](/.image/操作日志.jpg) | ![登录日志](/.image/登录日志.jpg) | - | +| 短信 | ![短信渠道](/.image/短信渠道.jpg) | ![短信模板](/.image/短信模板.jpg) | ![短信日志](/.image/短信日志.jpg) | +| 字典 & 敏感词 | ![字典类型](/.image/字典类型.jpg) | ![字典数据](/.image/字典数据.jpg) | ![敏感词](/.image/敏感词.jpg) | +| 错误码 & 通知 | ![错误码管理](/.image/错误码管理.jpg) | ![通知公告](/.image/通知公告.jpg) | - | + +### 基础设施 + +| 模块 | biu | biu | biu | +|---------------|-------------------------------|-----------------------------|---------------------------| +| 代码生成 | ![代码生成](/.image/代码生成.jpg) | ![生成效果](/.image/生成效果.jpg) | - | +| 文档 | ![系统接口](/.image/系统接口.jpg) | ![数据库文档](/.image/数据库文档.jpg) | - | +| 文件 & 配置 | ![文件配置](/.image/文件配置.jpg) | ![文件管理](/.image/文件管理2.jpg) | ![配置管理](/.image/配置管理.jpg) | +| 定时任务 | ![定时任务](/.image/定时任务.jpg) | ![任务日志](/.image/任务日志.jpg) | - | +| API 日志 | ![访问日志](/.image/访问日志.jpg) | ![错误日志](/.image/错误日志.jpg) | - | +| MySQL & Redis | ![MySQL](/.image/MySQL.jpg) | ![Redis](/.image/Redis.jpg) | - | +| 监控平台 | ![Java监控](/.image/Java监控.jpg) | ![链路追踪](/.image/链路追踪.jpg) | ![日志中心](/.image/日志中心.jpg) | + -iMES系统服务端 \ No newline at end of file diff --git a/cf-dependencies/pom.xml b/cf-dependencies/pom.xml new file mode 100644 index 000000000..748a3d17a --- /dev/null +++ b/cf-dependencies/pom.xml @@ -0,0 +1,752 @@ + + + 4.0.0 + + com.cf.imes + cf-dependencies + ${revision} + pom + + ${project.artifactId} + 基础 bom 文件,管理整个项目的依赖版本 + + + 2.0.0-jdk8-snapshot + 1.2.4 + + 2.7.18 + 2021.0.5 + 2021.0.4.0 + + 2.5 + 1.6.15 + 4.3.0 + + 1.2.21 + 3.5.5 + 3.5.5 + 4.3.0 + 1.4.10 + 3.18.0 + + 8.1.3.62 + 8.8.1 + 2.1.1 + + 2.2.3 + + + 1.9.2 + + 2.3.1 + + 2.2.7 + 1.7.1 + + 8.12.0 + 2.7.15 + 0.33.0 + + 7.2.11.RELEASE + 1.0.13 + 4.11.0 + + 6.8.0 + + 1.0.8 + 1.17.2 + 1.18.30 + 1.5.5.Final + 5.8.25 + 3.3.3 + 2.3 + 1.0.5 + 1.2.83 + 33.0.0-jre + 5.1.0 + 2.14.5 + 3.10.0 + 0.1.55 + 2.9.1 + 2.7.0 + 3.0.6 + 0.10.2 + + 3.5.0 + 4.11.0 + 2.15.1 + 8.5.7 + 4.6.4 + 2.2.1 + 3.1.880 + 1.0.8 + 1.6.6 + 2.12.2 + 4.6.0 + 17 + + + 2.5.1 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + org.springframework.cloud + spring-cloud-dependencies + ${spring.cloud.version} + pom + import + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + ${spring.cloud.alibaba.version} + pom + import + + + + + com.cf.imes + cf-spring-boot-starter-banner + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-operatelog + ${revision} + + + io.github.mouzt + bizlog-sdk + ${bizlog-sdk.version} + + + org.springframework.boot + spring-boot-starter + + + + + com.cf.imes + cf-spring-boot-starter-biz-dict + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-sms + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-pay + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-organ + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-data-permission + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-error-code + ${revision} + + + com.cf.imes + cf-spring-boot-starter-biz-ip + ${revision} + + + com.cf.imes + cf-spring-boot-starter-captcha + ${revision} + + + com.cf.imes + cf-spring-boot-starter-desensitize + ${revision} + + + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring.boot.version} + + + + com.cf.imes + cf-spring-boot-starter-env + ${revision} + + + + + com.cf.imes + cf-spring-boot-starter-web + ${revision} + + + + com.cf.imes + cf-spring-boot-starter-security + ${revision} + + + + com.cf.imes + cf-spring-boot-starter-websocket + ${revision} + + + + org.springdoc + springdoc-openapi-ui + ${springdoc.version} + + + com.github.xiaoymin + knife4j-openapi3-spring-boot-starter + ${knife4j.version} + + + com.github.xiaoymin + knife4j-gateway-spring-boot-starter + ${knife4j.version} + + + + + com.cf.imes + cf-spring-boot-starter-mybatis + ${revision} + + + org.springdoc + springdoc-openapi-webflux-ui + ${springdoc.version} + + + + com.alibaba + druid-spring-boot-starter + ${druid.version} + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + com.baomidou + mybatis-plus-generator + ${mybatis-plus-generator.version} + + + com.baomidou + dynamic-datasource-spring-boot-starter + ${dynamic-datasource.version} + + + com.github.yulichang + mybatis-plus-join-boot-starter + ${mybatis-plus-join.version} + + + + com.cf.imes + cf-spring-boot-starter-redis + ${revision} + + + + org.redisson + redisson-spring-boot-starter + ${redisson.version} + + + + com.dameng + DmJdbcDriver18 + ${dm8.jdbc.version} + + + + + com.cf.imes + cf-spring-boot-starter-rpc + ${revision} + + + + + + + + + com.xuxueli + xxl-job-core + ${xxl-job.version} + + + com.cf.imes + cf-spring-boot-starter-job + ${revision} + + + + + com.cf.imes + cf-spring-boot-starter-mq + ${revision} + + + + org.apache.rocketmq + rocketmq-spring-boot-starter + ${rocketmq-spring.version} + + + + + com.cf.imes + cf-spring-boot-starter-protection + ${revision} + + + + com.baomidou + lock4j-redisson-spring-boot-starter + ${lock4j.version} + + + redisson-spring-boot-starter + org.redisson + + + + + + io.github.resilience4j + resilience4j-ratelimiter + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-spring-boot2 + ${resilience4j.version} + + + + + com.cf.imes + cf-spring-boot-starter-monitor + ${revision} + + + + org.apache.skywalking + apm-toolkit-trace + ${skywalking.version} + + + org.apache.skywalking + apm-toolkit-logback-1.x + ${skywalking.version} + + + org.apache.skywalking + apm-toolkit-opentracing + ${skywalking.version} + + + + + + + + + + + + + io.opentracing + opentracing-api + ${opentracing.version} + + + io.opentracing + opentracing-util + ${opentracing.version} + + + io.opentracing + opentracing-noop + ${opentracing.version} + + + + de.codecentric + spring-boot-admin-starter-server + ${spring-boot-admin.version} + + + de.codecentric + spring-boot-admin-starter-client + ${spring-boot-admin.version} + + + + + com.cf.imes + cf-spring-boot-starter-test + ${revision} + test + + + + org.mockito + mockito-inline + ${mockito-inline.version} + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + + + asm + org.ow2.asm + + + org.mockito + mockito-core + + + + + + com.github.fppt + jedis-mock + ${jedis-mock.version} + + + + uk.co.jemos.podam + podam + ${podam.version} + + + + + com.cf.imes + cf-spring-boot-starter-flowable + ${revision} + + + org.flowable + flowable-spring-boot-starter-process + ${flowable.version} + + + org.flowable + flowable-spring-boot-starter-actuator + ${flowable.version} + + + + + + com.cf.imes + cf-common + ${revision} + + + + com.cf.imes + cf-spring-boot-starter-excel + ${revision} + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-jdk8 + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + cn.hutool + hutool-all + ${hutool.version} + + + + com.alibaba + easyexcel + ${easyexcel.verion} + + + + commons-io + commons-io + ${commons-io.version} + + + + org.apache.tika + tika-core + ${tika-core.version} + + + + org.apache.velocity + velocity-engine-core + ${velocity.version} + + + + com.alibaba + fastjson + ${fastjson.version} + + + + cn.smallbun.screw + screw-core + ${screw.version} + + + org.freemarker + freemarker + + + com.alibaba + fastjson + + + + + + com.google.guava + guava + ${guava.version} + + + + com.google.inject + guice + ${guice.version} + + + + com.alibaba + transmittable-thread-local + ${transmittable-thread-local.version} + + + + commons-net + commons-net + ${commons-net.version} + + + com.jcraft + jsch + ${jsch.version} + + + + com.xingyuv + spring-boot-starter-captcha-plus + ${captcha-plus.version} + + + + org.lionsoul + ip2region + ${ip2region.version} + + + + org.jsoup + jsoup + ${jsoup.version} + + + + org.reflections + reflections + ${reflections.version} + + + + + com.squareup.okio + okio + ${okio.version} + + + com.squareup.okhttp3 + okhttp + ${okhttp3.version} + + + com.cf.imes + cf-spring-boot-starter-file + ${revision} + + + io.minio + minio + ${minio.version} + + + + com.github.binarywang + weixin-java-pay + ${weixin-java.version} + + + com.github.binarywang + wx-java-mp-spring-boot-starter + ${weixin-java.version} + + + com.github.binarywang + wx-java-miniapp-spring-boot-starter + ${weixin-java.version} + + + com.belerweb + pinyin4j + ${pingyinfj.version} + + + + + com.aliyun + aliyun-java-sdk-core + ${aliyun-java-sdk-core.version} + + + opentracing-api + io.opentracing + + + opentracing-util + io.opentracing + + + + + com.aliyun + aliyun-java-sdk-dysmsapi + ${aliyun-java-sdk-dysmsapi.version} + + + com.tencentcloudapi + tencentcloud-sdk-java-sms + ${tencentcloud-sdk-java.version} + + + + + com.xingyuv + spring-boot-starter-justauth + ${justauth.version} + + + cn.hutool + hutool-core + + + + + + + org.jeecgframework.jimureport + jimureport-spring-boot-starter + ${jimureport.version} + + + com.alibaba + druid + + + + + xerces + xercesImpl + ${xercesImpl.version} + + + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + resolveCiFriendliesOnly + true + + + + + flatten + + flatten + process-resources + + + + clean + + flatten.clean + clean + + + + + + + diff --git a/cf-framework/cf-common/pom.xml b/cf-framework/cf-common/pom.xml new file mode 100644 index 000000000..986262ca7 --- /dev/null +++ b/cf-framework/cf-common/pom.xml @@ -0,0 +1,159 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-common + jar + + ${project.artifactId} + 定义基础 pojo 类、枚举、工具类等等 + + + 17 + 17 + 17 + + + + + + org.springframework + spring-core + provided + + + org.springframework + spring-expression + provided + + + org.springframework + spring-aop + provided + + + org.aspectj + aspectjweaver + provided + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework + spring-web + provided + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + org.springdoc + springdoc-openapi-ui + provided + + + + + org.apache.skywalking + apm-toolkit-trace + + + + + org.projectlombok + lombok + + + + org.mapstruct + mapstruct + + + org.mapstruct + mapstruct-jdk8 + + + org.mapstruct + mapstruct-processor + + + + com.google.guava + guava + provided + + + + com.fasterxml.jackson.core + jackson-databind + provided + + + com.fasterxml.jackson.core + jackson-core + provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + provided + + + + org.slf4j + slf4j-api + provided + + + + jakarta.validation + jakarta.validation-api + provided + + + + cn.hutool + hutool-all + + + + com.alibaba + transmittable-thread-local + + + + org.jsoup + jsoup + + + + com.belerweb + pinyin4j + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/core/IntArrayValuable.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/core/IntArrayValuable.java new file mode 100644 index 000000000..6ad406899 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/core/IntArrayValuable.java @@ -0,0 +1,15 @@ +package com.cf.imes.framework.common.core; + +/** + * 可生成 Int 数组的接口 + * + * @author 晨丰科技 + */ +public interface IntArrayValuable { + + /** + * @return int 数组 + */ + int[] array(); + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/core/KeyValue.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/core/KeyValue.java new file mode 100644 index 000000000..e1c9767ba --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/core/KeyValue.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.common.core; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * Key Value 的键值对 + * + * @author 晨丰科技 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class KeyValue implements Serializable { + + private K key; + private V value; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/CommonStatusEnum.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/CommonStatusEnum.java new file mode 100644 index 000000000..9be2626f4 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/CommonStatusEnum.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.common.enums; + +import cn.hutool.core.util.ObjUtil; +import com.cf.imes.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 通用状态枚举 + * + * @author 晨丰科技 + */ +@Getter +@AllArgsConstructor +public enum CommonStatusEnum implements IntArrayValuable { + + ENABLE(0, "开启"), + DISABLE(1, "关闭"); + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CommonStatusEnum::getStatus).toArray(); + + /** + * 状态值 + */ + private final Integer status; + /** + * 状态名 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + + public static boolean isEnable(Integer status) { + return ObjUtil.equal(ENABLE.status, status); + } + + public static boolean isDisable(Integer status) { + return ObjUtil.equal(DISABLE.status, status); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/DocumentEnum.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/DocumentEnum.java new file mode 100644 index 000000000..cb443e03a --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/DocumentEnum.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文档地址 + * + * @author 晨丰科技 + */ +@Getter +@AllArgsConstructor +public enum DocumentEnum { + + REDIS_INSTALL("https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4VCSJ", "Redis 安装文档"), + ORGAN("https://doc.iocoder.cn", "SaaS 多组织文档"); + + private final String url; + private final String memo; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/RpcConstants.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/RpcConstants.java new file mode 100644 index 000000000..809fd1423 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/RpcConstants.java @@ -0,0 +1,17 @@ +package com.cf.imes.framework.common.enums; + +/** + * RPC 相关的枚举 + * + * 虽然放在 cf-spring-boot-starter-rpc 会相对合适,但是每个 API 模块需要使用到,所以暂时只好放在此处 + * + * @author 晨丰科技 + */ +public class RpcConstants { + + /** + * RPC API 的前缀 + */ + public static final String RPC_API_PREFIX = "/rpc-api"; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/TerminalEnum.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/TerminalEnum.java new file mode 100644 index 000000000..10f6154fa --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/TerminalEnum.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.common.enums; + +import com.cf.imes.framework.common.core.IntArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * 终端的枚举 + * + * @author 晨丰科技 + */ +@RequiredArgsConstructor +@Getter +public enum TerminalEnum implements IntArrayValuable { + + UNKNOWN(0, "未知"), // 目的:在无法解析到 terminal 时,使用它 + WECHAT_MINI_PROGRAM(10, "微信小程序"), + WECHAT_WAP(11, "微信公众号"), + H5(20, "H5 网页"), + APP(31, "手机 App"), + ; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(TerminalEnum::getTerminal).toArray(); + + /** + * 终端 + */ + private final Integer terminal; + /** + * 终端名 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/UserTypeEnum.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/UserTypeEnum.java new file mode 100644 index 000000000..0d24d9c44 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/UserTypeEnum.java @@ -0,0 +1,39 @@ +package com.cf.imes.framework.common.enums; + +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 全局用户类型枚举 + */ +@AllArgsConstructor +@Getter +public enum UserTypeEnum implements IntArrayValuable { + + MEMBER(1, "会员"), // 面向 c 端,普通用户 + ADMIN(2, "管理员"); // 面向 b 端,管理后台 + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(UserTypeEnum::getValue).toArray(); + + /** + * 类型 + */ + private final Integer value; + /** + * 类型名 + */ + private final String name; + + public static UserTypeEnum valueOf(Integer value) { + return ArrayUtil.firstMatch(userType -> userType.getValue().equals(value), UserTypeEnum.values()); + } + + @Override + public int[] array() { + return ARRAYS; + } +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/WebFilterOrderEnum.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/WebFilterOrderEnum.java new file mode 100644 index 000000000..604738cec --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/enums/WebFilterOrderEnum.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.common.enums; + +/** + * Web 过滤器顺序的枚举类,保证过滤器按照符合我们的预期 + * + * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 enum 包下 + * + * @author 晨丰科技 + */ +public interface WebFilterOrderEnum { + + int CORS_FILTER = Integer.MIN_VALUE; + + int TRACE_FILTER = CORS_FILTER + 1; + + int ENV_TAG_FILTER = TRACE_FILTER + 1; + + int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500; + + // OrderedRequestContextFilter 默认为 -105,用于国际化上下文等等 + + int TENANT_CONTEXT_FILTER = - 104; // 需要保证在 ApiAccessLogFilter 前面 + + int API_ACCESS_LOG_FILTER = -103; // 需要保证在 RequestBodyCacheFilter 后面 + + int XSS_FILTER = -102; // 需要保证在 RequestBodyCacheFilter 后面 + + // Spring Security Filter 默认为 -100,可见 org.springframework.boot.autoconfigure.security.SecurityProperties 配置属性类 + + int TENANT_SECURITY_FILTER = -99; // 需要保证在 Spring Security 过滤器后面 + + int FLOWABLE_FILTER = -98; // 需要保证在 Spring Security 过滤后面 + + int DEMO_FILTER = Integer.MAX_VALUE; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ErrorCode.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ErrorCode.java new file mode 100644 index 000000000..be1219aef --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ErrorCode.java @@ -0,0 +1,32 @@ +package com.cf.imes.framework.common.exception; + +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.cf.imes.framework.common.exception.enums.ServiceErrorCodeRange; +import lombok.Data; + +/** + * 错误码对象 + * + * 全局错误码,占用 [0, 999], 参见 {@link GlobalErrorCodeConstants} + * 业务异常错误码,占用 [1 000 000 000, +∞),参见 {@link ServiceErrorCodeRange} + * + * TODO 错误码设计成对象的原因,为未来的 i18 国际化做准备 + */ +@Data +public class ErrorCode { + + /** + * 错误码 + */ + private final Integer code; + /** + * 错误提示 + */ + private final String msg; + + public ErrorCode(Integer code, String message) { + this.code = code; + this.msg = message; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ServerException.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ServerException.java new file mode 100644 index 000000000..7325b3600 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ServerException.java @@ -0,0 +1,60 @@ +package com.cf.imes.framework.common.exception; + +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 服务器异常 Exception + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class ServerException extends RuntimeException { + + /** + * 全局错误码 + * + * @see GlobalErrorCodeConstants + */ + private Integer code; + /** + * 错误提示 + */ + private String message; + + /** + * 空构造方法,避免反序列化问题 + */ + public ServerException() { + } + + public ServerException(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMsg(); + } + + public ServerException(Integer code, String message) { + this.code = code; + this.message = message; + } + + public Integer getCode() { + return code; + } + + public ServerException setCode(Integer code) { + this.code = code; + return this; + } + + @Override + public String getMessage() { + return message; + } + + public ServerException setMessage(String message) { + this.message = message; + return this; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ServiceException.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ServiceException.java new file mode 100644 index 000000000..af9ebf33b --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/ServiceException.java @@ -0,0 +1,60 @@ +package com.cf.imes.framework.common.exception; + +import com.cf.imes.framework.common.exception.enums.ServiceErrorCodeRange; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 业务逻辑异常 Exception + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class ServiceException extends RuntimeException { + + /** + * 业务错误码 + * + * @see ServiceErrorCodeRange + */ + private Integer code; + /** + * 错误提示 + */ + private String message; + + /** + * 空构造方法,避免反序列化问题 + */ + public ServiceException() { + } + + public ServiceException(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMsg(); + } + + public ServiceException(Integer code, String message) { + this.code = code; + this.message = message; + } + + public Integer getCode() { + return code; + } + + public ServiceException setCode(Integer code) { + this.code = code; + return this; + } + + @Override + public String getMessage() { + return message; + } + + public ServiceException setMessage(String message) { + this.message = message; + return this; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/enums/GlobalErrorCodeConstants.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/enums/GlobalErrorCodeConstants.java new file mode 100644 index 000000000..f7c448038 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/enums/GlobalErrorCodeConstants.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.common.exception.enums; + +import com.cf.imes.framework.common.exception.ErrorCode; + +/** + * 全局错误码枚举 + * 0-999 系统异常编码保留 + * + * 一般情况下,使用 HTTP 响应状态码 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status + * 虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的 + * 比较特殊的是,因为之前一直使用 0 作为成功,就不使用 200 啦。 + * + * @author 晨丰科技 + */ +public interface GlobalErrorCodeConstants { + + ErrorCode SUCCESS = new ErrorCode(0, "成功"); + + // ========== 客户端错误段 ========== + + ErrorCode BAD_REQUEST = new ErrorCode(400, "请求参数不正确"); + ErrorCode UNAUTHORIZED = new ErrorCode(401, "账号未登录"); + ErrorCode FORBIDDEN = new ErrorCode(403, "没有该操作权限"); + ErrorCode NOT_FOUND = new ErrorCode(404, "请求未找到"); + ErrorCode METHOD_NOT_ALLOWED = new ErrorCode(405, "请求方法不正确"); + ErrorCode LOCKED = new ErrorCode(423, "请求失败,请稍后重试"); // 并发请求,不允许 + ErrorCode TOO_MANY_REQUESTS = new ErrorCode(429, "请求过于频繁,请稍后重试"); + ErrorCode DATA_SOURCE_CODE_NOT_FOUND = new ErrorCode(430, "未传递数据源标识"); + + // ========== 服务端错误段 ========== + + ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常"); + ErrorCode NOT_IMPLEMENTED = new ErrorCode(501, "功能未实现/未开启"); + ErrorCode ERROR_CONFIGURATION = new ErrorCode(502, "错误的配置项"); + + // ========== 自定义错误段 ========== + ErrorCode REPEATED_REQUESTS = new ErrorCode(900, "重复请求,请稍后重试"); // 重复请求 + ErrorCode DEMO_DENY = new ErrorCode(901, "演示模式,禁止写操作"); + + ErrorCode UNKNOWN = new ErrorCode(999, "未知错误"); + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/enums/ServiceErrorCodeRange.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/enums/ServiceErrorCodeRange.java new file mode 100644 index 000000000..1071f38ad --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/enums/ServiceErrorCodeRange.java @@ -0,0 +1,38 @@ +package com.cf.imes.framework.common.exception.enums; + +/** + * 业务异常的错误码区间,解决:解决各模块错误码定义,避免重复,在此只声明不做实际使用 + * + * 一共 10 位,分成四段 + * + * 第一段,1 位,类型 + * 1 - 业务级别异常 + * x - 预留 + * 第二段,3 位,系统类型 + * 001 - 用户系统 + * 002 - 商品系统 + * 003 - 订单系统 + * 004 - 支付系统 + * 005 - 优惠劵系统 + * ... - ... + * 第三段,3 位,模块 + * 不限制规则。 + * 一般建议,每个系统里面,可能有多个模块,可以再去做分段。以用户系统为例子: + * 001 - OAuth2 模块 + * 002 - User 模块 + * 003 - MobileCode 模块 + * 第四段,3 位,错误码 + * 不限制规则。 + * 一般建议,每个模块自增。 + * + * @author 晨丰科技 + */ +public class ServiceErrorCodeRange { + + // 模块 infra 错误码区间 [1-001-000-000 ~ 1-001-999-999) + // 模块 system 错误码区间 [1-002-000-000 ~ 1-002-999-999) + // 模块 executor 错误码区间 [1-003-000-000 ~ 1-003-999-999) + // 模块 manage 错误码区间 [1-004-000-000 ~ 1-004-999-999) + // 模块 plan 错误码区间 [1-005-000-000 ~ 1-005-999-999) + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/util/ServiceExceptionUtil.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/util/ServiceExceptionUtil.java new file mode 100644 index 000000000..2ad93a359 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/exception/util/ServiceExceptionUtil.java @@ -0,0 +1,127 @@ +package com.cf.imes.framework.common.exception.util; + +import com.cf.imes.framework.common.exception.ErrorCode; +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * {@link ServiceException} 工具类 + * + * 目的在于,格式化异常信息提示。 + * 考虑到 String.format 在参数不正确时会报错,因此使用 {} 作为占位符,并使用 {@link #doFormat(int, String, Object...)} 方法来格式化 + * + * 因为 {@link #MESSAGES} 里面默认是没有异常信息提示的模板的,所以需要使用方自己初始化进去。目前想到的有几种方式: + * + * 1. 异常提示信息,写在枚举类中,例如说,cn.iocoder.oceans.user.api.constants.ErrorCodeEnum 类 + ServiceExceptionConfiguration + * 2. 异常提示信息,写在 .properties 等等配置文件 + * 3. 异常提示信息,写在 Apollo 等等配置中心中,从而实现可动态刷新 + * 4. 异常提示信息,存储在 db 等等数据库中,从而实现可动态刷新 + */ +@Slf4j +public class ServiceExceptionUtil { + + /** + * 错误码提示模板 + */ + private static final ConcurrentMap MESSAGES = new ConcurrentHashMap<>(); + + public static void putAll(Map messages) { + ServiceExceptionUtil.MESSAGES.putAll(messages); + } + + public static void put(Integer code, String message) { + ServiceExceptionUtil.MESSAGES.put(code, message); + } + + public static void delete(Integer code, String message) { + ServiceExceptionUtil.MESSAGES.remove(code, message); + } + + // ========== 和 ServiceException 的集成 ========== + + public static ServiceException exception(ErrorCode errorCode) { + String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMsg()); + return exception0(errorCode.getCode(), messagePattern); + } + + public static ServiceException exception(ErrorCode errorCode, Object... params) { + String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMsg()); + return exception0(errorCode.getCode(), messagePattern, params); + } + + /** + * 创建指定编号的 ServiceException 的异常 + * + * @param code 编号 + * @return 异常 + */ + public static ServiceException exception(Integer code) { + return exception0(code, MESSAGES.get(code)); + } + + /** + * 创建指定编号的 ServiceException 的异常 + * + * @param code 编号 + * @param params 消息提示的占位符对应的参数 + * @return 异常 + */ + public static ServiceException exception(Integer code, Object... params) { + return exception0(code, MESSAGES.get(code), params); + } + + public static ServiceException exception0(Integer code, String messagePattern, Object... params) { + String message = doFormat(code, messagePattern, params); + return new ServiceException(code, message); + } + + public static ServiceException invalidParamException(String messagePattern, Object... params) { + return exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), messagePattern, params); + } + + // ========== 格式化方法 ========== + + /** + * 将错误编号对应的消息使用 params 进行格式化。 + * + * @param code 错误编号 + * @param messagePattern 消息模版 + * @param params 参数 + * @return 格式化后的提示 + */ + @VisibleForTesting + public static String doFormat(int code, String messagePattern, Object... params) { + StringBuilder sbuf = new StringBuilder(messagePattern.length() + 50); + int i = 0; + int j; + int l; + for (l = 0; l < params.length; l++) { + j = messagePattern.indexOf("{}", i); + if (j == -1) { + log.error("[doFormat][参数过多:错误码({})|错误内容({})|参数({})", code, messagePattern, params); + if (i == 0) { + return messagePattern; + } else { + sbuf.append(messagePattern.substring(i)); + return sbuf.toString(); + } + } else { + sbuf.append(messagePattern, i, j); + sbuf.append(params[l]); + i = j + 2; + } + } + if (messagePattern.indexOf("{}", i) != -1) { + log.error("[doFormat][参数过少:错误码({})|错误内容({})|参数({})", code, messagePattern, params); + } + sbuf.append(messagePattern.substring(i)); + return sbuf.toString(); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/package-info.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/package-info.java new file mode 100644 index 000000000..646b3f4f8 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/package-info.java @@ -0,0 +1,6 @@ +/** + * 基础的通用类,和框架无关 + * + * 例如说,CommonResult 为通用返回 + */ +package com.cf.imes.framework.common; diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/CommonResult.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/CommonResult.java new file mode 100644 index 000000000..685629d9e --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/CommonResult.java @@ -0,0 +1,112 @@ +package com.cf.imes.framework.common.pojo; + +import com.cf.imes.framework.common.exception.ErrorCode; +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.util.Objects; + +/** + * 通用返回 + * + * @param 数据泛型 + */ +@Data +public class CommonResult implements Serializable { + + /** + * 错误码 + * + * @see ErrorCode#getCode() + */ + private Integer code; + /** + * 返回数据 + */ + private T data; + /** + * 错误提示,用户可阅读 + * + * @see ErrorCode#getMsg() () + */ + private String msg; + + /** + * 将传入的 result 对象,转换成另外一个泛型结果的对象 + * + * 因为 A 方法返回的 CommonResult 对象,不满足调用其的 B 方法的返回,所以需要进行转换。 + * + * @param result 传入的 result 对象 + * @param 返回的泛型 + * @return 新的 CommonResult 对象 + */ + public static CommonResult error(CommonResult result) { + return error(result.getCode(), result.getMsg()); + } + + public static CommonResult error(Integer code, String message) { + Assert.isTrue(!GlobalErrorCodeConstants.SUCCESS.getCode().equals(code), "code 必须是错误的!"); + CommonResult result = new CommonResult<>(); + result.code = code; + result.msg = message; + return result; + } + + public static CommonResult error(ErrorCode errorCode) { + return error(errorCode.getCode(), errorCode.getMsg()); + } + + public static CommonResult success(T data) { + CommonResult result = new CommonResult<>(); + result.code = GlobalErrorCodeConstants.SUCCESS.getCode(); + result.data = data; + result.msg = ""; + return result; + } + + public static boolean isSuccess(Integer code) { + return Objects.equals(code, GlobalErrorCodeConstants.SUCCESS.getCode()); + } + + @JsonIgnore // 避免 jackson 序列化 + public boolean isSuccess() { + return isSuccess(code); + } + + @JsonIgnore // 避免 jackson 序列化 + public boolean isError() { + return !isSuccess(); + } + + // ========= 和 Exception 异常体系集成 ========= + + /** + * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常 + */ + public void checkError() throws ServiceException { + if (isSuccess()) { + return; + } + // 业务异常 + throw new ServiceException(code, msg); + } + + /** + * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常 + * 如果没有,则返回 {@link #data} 数据 + */ + @JsonIgnore // 避免 jackson 序列化 + public T getCheckedData() { + checkError(); + return data; + } + + public static CommonResult error(ServiceException serviceException) { + return error(serviceException.getCode(), serviceException.getMessage()); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/PageParam.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/PageParam.java new file mode 100644 index 000000000..a26fb6694 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/PageParam.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.common.pojo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.Min; +import javax.validation.constraints.Max; +import javax.validation.constraints.NotNull; +import java.io.Serializable; + +@Schema(description="分页参数") +@Data +public class PageParam implements Serializable { + + private static final Integer PAGE_NO = 1; + private static final Integer PAGE_SIZE = 10; + + /** + * 每页条数 - 不分页 + * + * 例如说,导出接口,可以设置 {@link #pageSize} 为 -1 不分页,查询所有数据。 + */ + public static final Integer PAGE_SIZE_NONE = -1; + + @Schema(description = "页码,从 1 开始", requiredMode = Schema.RequiredMode.REQUIRED,example = "1") + @NotNull(message = "页码不能为空") + @Min(value = 1, message = "页码最小值为 1") + private Integer pageNo = PAGE_NO; + + @Schema(description = "每页条数,最大值为 100", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "每页条数不能为空") + @Min(value = 1, message = "每页条数最小值为 1") + @Max(value = 100, message = "每页条数最大值为 100") + private Integer pageSize = PAGE_SIZE; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/PageResult.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/PageResult.java new file mode 100644 index 000000000..5ddbb3543 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/PageResult.java @@ -0,0 +1,43 @@ +package com.cf.imes.framework.common.pojo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "分页结果") +@Data +public final class PageResult implements Serializable { + + @Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED) + private List list; + + @Schema(description = "总量", requiredMode = Schema.RequiredMode.REQUIRED) + private Long total; + + public PageResult() { + this.list = new ArrayList<>(); + this.total = 0L; + } + + public PageResult(List list, Long total) { + this.list = list; + this.total = total; + } + + public PageResult(Long total) { + this.list = new ArrayList<>(); + this.total = total; + } + + public static PageResult empty() { + return new PageResult<>(0L); + } + + public static PageResult empty(Long total) { + return new PageResult<>(total); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/SortablePageParam.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/SortablePageParam.java new file mode 100644 index 000000000..d61625413 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/SortablePageParam.java @@ -0,0 +1,19 @@ +package com.cf.imes.framework.common.pojo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +@Schema(description = "可排序的分页参数") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class SortablePageParam extends PageParam { + + @Schema(description = "排序字段") + private List sortingFields; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/SortingField.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/SortingField.java new file mode 100644 index 000000000..359d7ec81 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/pojo/SortingField.java @@ -0,0 +1,37 @@ +package com.cf.imes.framework.common.pojo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 排序字段 DTO + * + * 类名加了 ing 的原因是,避免和 ES SortField 重名。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SortingField implements Serializable { + + /** + * 顺序 - 升序 + */ + public static final String ORDER_ASC = "asc"; + /** + * 顺序 - 降序 + */ + public static final String ORDER_DESC = "desc"; + + /** + * 字段 + */ + private String field; + /** + * 顺序 + */ + private String order; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/cache/CacheUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/cache/CacheUtils.java new file mode 100644 index 000000000..7a180960f --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/cache/CacheUtils.java @@ -0,0 +1,29 @@ +package com.cf.imes.framework.common.util.cache; + +import com.alibaba.ttl.threadpool.TtlExecutors; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import java.time.Duration; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * Cache 工具类 + * + * @author 晨丰科技 + */ +public class CacheUtils { + + public static LoadingCache buildAsyncReloadingCache(Duration duration, CacheLoader loader) { + Executor executor = Executors.newCachedThreadPool( // TODO 晨丰:可能要思考下,未来要不要做成可配置 + TtlExecutors.getDefaultDisableInheritableThreadFactory()); // TTL 保证 ThreadLocal 可以透传 + return CacheBuilder.newBuilder() + // 只阻塞当前数据加载线程,其他线程返回旧值 + .refreshAfterWrite(duration) + // 通过 asyncReloading 实现全异步加载,包括 refreshAfterWrite 被阻塞的加载线程 + .build(CacheLoader.asyncReloading(loader, executor)); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/ArrayUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/ArrayUtils.java new file mode 100644 index 000000000..2666a323e --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/ArrayUtils.java @@ -0,0 +1,58 @@ +package com.cf.imes.framework.common.util.collection; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.collection.IterUtil; +import cn.hutool.core.util.ArrayUtil; + +import java.util.Collection; +import java.util.function.Consumer; +import java.util.function.Function; + +import static com.cf.imes.framework.common.util.collection.CollectionUtils.convertList; + +/** + * Array 工具类 + * + * @author 晨丰科技 + */ +public class ArrayUtils { + + /** + * 将 object 和 newElements 合并成一个数组 + * + * @param object 对象 + * @param newElements 数组 + * @param 泛型 + * @return 结果数组 + */ + @SafeVarargs + public static Consumer[] append(Consumer object, Consumer... newElements) { + if (object == null) { + return newElements; + } + Consumer[] result = ArrayUtil.newArray(Consumer.class, 1 + newElements.length); + result[0] = object; + System.arraycopy(newElements, 0, result, 1, newElements.length); + return result; + } + + public static V[] toArray(Collection from, Function mapper) { + return toArray(CollectionUtils.convertList(from, mapper)); + } + + @SuppressWarnings("unchecked") + public static T[] toArray(Collection from) { + if (CollectionUtil.isEmpty(from)) { + return (T[]) (new Object[0]); + } + return ArrayUtil.toArray(from, (Class) IterUtil.getElementType(from.iterator())); + } + + public static T get(T[] array, int index) { + if (null == array || index >= array.length) { + return null; + } + return array[index]; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/CollectionUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/CollectionUtils.java new file mode 100644 index 000000000..834d8d032 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/CollectionUtils.java @@ -0,0 +1,309 @@ +package com.cf.imes.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ArrayUtil; +import com.google.common.collect.ImmutableMap; + +import java.util.*; +import java.util.function.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; + +/** + * Collection 工具类 + * + * @author 晨丰科技 + */ +public class CollectionUtils { + + public static boolean containsAny(Object source, Object... targets) { + return asList(targets).contains(source); + } + + public static boolean isAnyEmpty(Collection... collections) { + return Arrays.stream(collections).anyMatch(CollectionUtil::isEmpty); + } + + public static boolean anyMatch(Collection from, Predicate predicate) { + return from.stream().anyMatch(predicate); + } + + public static List filterList(Collection from, Predicate predicate) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().filter(predicate).collect(Collectors.toList()); + } + + public static List distinct(Collection from, Function keyMapper) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return distinct(from, keyMapper, (t1, t2) -> t1); + } + + public static List distinct(Collection from, Function keyMapper, BinaryOperator cover) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return new ArrayList<>(convertMap(from, keyMapper, Function.identity(), cover).values()); + } + + public static List convertList(T[] from, Function func) { + if (ArrayUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return convertList(Arrays.asList(from), func); + } + + public static List convertList(Collection from, Function func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static List convertList(Collection from, Function func, Predicate filter) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static List convertListByFlatMap(Collection from, + Function> func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().flatMap(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static List convertListByFlatMap(Collection from, + Function mapper, + Function> func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().map(mapper).flatMap(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static List mergeValuesFromMap(Map> map) { + return map.values() + .stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + } + + public static Set convertSet(Collection from, Function func) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static Set convertSet(Collection from, Function func, Predicate filter) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static Map convertMapByFilter(Collection from, Predicate filter, Function keyFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().filter(filter).collect(Collectors.toMap(keyFunc, v -> v)); + } + + public static Set convertSetByFlatMap(Collection from, + Function> func) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().flatMap(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static Set convertSetByFlatMap(Collection from, + Function mapper, + Function> func) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().map(mapper).flatMap(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static Map convertMap(Collection from, Function keyFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return convertMap(from, keyFunc, Function.identity()); + } + + public static Map convertMap(Collection from, Function keyFunc, Supplier> supplier) { + if (CollUtil.isEmpty(from)) { + return supplier.get(); + } + return convertMap(from, keyFunc, Function.identity(), supplier); + } + + public static Map convertMap(Collection from, Function keyFunc, Function valueFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1); + } + + public static Map convertMap(Collection from, Function keyFunc, Function valueFunc, BinaryOperator mergeFunction) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return convertMap(from, keyFunc, valueFunc, mergeFunction, HashMap::new); + } + + public static Map convertMap(Collection from, Function keyFunc, Function valueFunc, Supplier> supplier) { + if (CollUtil.isEmpty(from)) { + return supplier.get(); + } + return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1, supplier); + } + + public static Map convertMap(Collection from, Function keyFunc, Function valueFunc, BinaryOperator mergeFunction, Supplier> supplier) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().collect(Collectors.toMap(keyFunc, valueFunc, mergeFunction, supplier)); + } + + public static Map> convertMultiMap(Collection from, Function keyFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(t -> t, Collectors.toList()))); + } + + public static Map> convertMultiMap(Collection from, Function keyFunc, Function valueFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream() + .collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toList()))); + } + + // 暂时没想好名字,先以 2 结尾噶 + public static Map> convertMultiMap2(Collection from, Function keyFunc, Function valueFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toSet()))); + } + + public static Map convertImmutableMap(Collection from, Function keyFunc) { + if (CollUtil.isEmpty(from)) { + return Collections.emptyMap(); + } + ImmutableMap.Builder builder = ImmutableMap.builder(); + from.forEach(item -> builder.put(keyFunc.apply(item), item)); + return builder.build(); + } + + /** + * 对比老、新两个列表,找出新增、修改、删除的数据 + * + * @param oldList 老列表 + * @param newList 新列表 + * @param sameFunc 对比函数,返回 true 表示相同,返回 false 表示不同 + * 注意,same 是通过每个元素的“标识”,判断它们是不是同一个数据 + * @return [新增列表、修改列表、删除列表] + */ + public static List> diffList(Collection oldList, Collection newList, + BiFunction sameFunc) { + List createList = new LinkedList<>(newList); // 默认都认为是新增的,后续会进行移除 + List updateList = new ArrayList<>(); + List deleteList = new ArrayList<>(); + + // 通过以 oldList 为主遍历,找出 updateList 和 deleteList + for (T oldObj : oldList) { + // 1. 寻找是否有匹配的 + T foundObj = null; + for (Iterator iterator = createList.iterator(); iterator.hasNext(); ) { + T newObj = iterator.next(); + // 1.1 不匹配,则直接跳过 + if (!sameFunc.apply(oldObj, newObj)) { + continue; + } + // 1.2 匹配,则移除,并结束寻找 + iterator.remove(); + foundObj = newObj; + break; + } + // 2. 匹配添加到 updateList;不匹配则添加到 deleteList 中 + if (foundObj != null) { + updateList.add(foundObj); + } else { + deleteList.add(oldObj); + } + } + return asList(createList, updateList, deleteList); + } + + public static boolean containsAny(Collection source, Collection candidates) { + return org.springframework.util.CollectionUtils.containsAny(source, candidates); + } + + public static T getFirst(List from) { + return !CollectionUtil.isEmpty(from) ? from.get(0) : null; + } + + public static T findFirst(List from, Predicate predicate) { + return findFirst(from, predicate, Function.identity()); + } + + public static U findFirst(List from, Predicate predicate, Function func) { + if (CollUtil.isEmpty(from)) { + return null; + } + return from.stream().filter(predicate).findFirst().map(func).orElse(null); + } + + public static > V getMaxValue(Collection from, Function valueFunc) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert !from.isEmpty(); // 断言,避免告警 + T t = from.stream().max(Comparator.comparing(valueFunc)).get(); + return valueFunc.apply(t); + } + + public static > V getMinValue(List from, Function valueFunc) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert from.size() > 0; // 断言,避免告警 + T t = from.stream().min(Comparator.comparing(valueFunc)).get(); + return valueFunc.apply(t); + } + + public static > V getSumValue(List from, Function valueFunc, + BinaryOperator accumulator) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert from.size() > 0; // 断言,避免告警 + return from.stream().map(valueFunc).reduce(accumulator).get(); + } + + public static void addIfNotNull(Collection coll, T item) { + if (item == null) { + return; + } + coll.add(item); + } + + public static Collection singleton(T deptId) { + return deptId == null ? Collections.emptyList() : Collections.singleton(deptId); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/MapUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/MapUtils.java new file mode 100644 index 000000000..579456af6 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/MapUtils.java @@ -0,0 +1,66 @@ +package com.cf.imes.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.CollectionUtil; +import com.cf.imes.framework.common.core.KeyValue; +import com.google.common.collect.Maps; +import com.google.common.collect.Multimap; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Map 工具类 + * + * @author 晨丰科技 + */ +public class MapUtils { + + /** + * 从哈希表表中,获得 keys 对应的所有 value 数组 + * + * @param multimap 哈希表 + * @param keys keys + * @return value 数组 + */ + public static List getList(Multimap multimap, Collection keys) { + List result = new ArrayList<>(); + keys.forEach(k -> { + Collection values = multimap.get(k); + if (CollectionUtil.isEmpty(values)) { + return; + } + result.addAll(values); + }); + return result; + } + + /** + * 从哈希表查找到 key 对应的 value,然后进一步处理 + * 注意,如果查找到的 value 为 null 时,不进行处理 + * + * @param map 哈希表 + * @param key key + * @param consumer 进一步处理的逻辑 + */ + public static void findAndThen(Map map, K key, Consumer consumer) { + if (CollUtil.isEmpty(map)) { + return; + } + V value = map.get(key); + if (value == null) { + return; + } + consumer.accept(value); + } + + public static Map convertMap(List> keyValues) { + Map map = Maps.newLinkedHashMapWithExpectedSize(keyValues.size()); + keyValues.forEach(keyValue -> map.put(keyValue.getKey(), keyValue.getValue())); + return map; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/SetUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/SetUtils.java new file mode 100644 index 000000000..16c352995 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/collection/SetUtils.java @@ -0,0 +1,19 @@ +package com.cf.imes.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; + +import java.util.Set; + +/** + * Set 工具类 + * + * @author 晨丰科技 + */ +public class SetUtils { + + @SafeVarargs + public static Set asSet(T... objs) { + return CollUtil.newHashSet(objs); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/date/DateUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/date/DateUtils.java new file mode 100644 index 000000000..1723ddac7 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/date/DateUtils.java @@ -0,0 +1,190 @@ +package com.cf.imes.framework.common.util.date; + +import cn.hutool.core.date.LocalDateTimeUtil; + +import java.time.*; +import java.util.Calendar; +import java.util.Date; + +/** + * 时间工具类 + * + * @author 晨丰科技 + */ +public class DateUtils { + + /** + * 时区 - 默认 + */ + public static final String TIME_ZONE_DEFAULT = "GMT+8"; + + /** + * 秒转换成毫秒 + */ + public static final long SECOND_MILLIS = 1000; + + public static final String FORMAT_YEAR_MONTH_DAY = "yyyy-MM-dd"; + + public static final String FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND = "yyyy-MM-dd HH:mm:ss"; + + public static final String FORMAT_HOUR_MINUTE_SECOND = "HH:mm:ss"; + + /** + * 将 LocalDateTime 转换成 Date + * + * @param date LocalDateTime + * @return LocalDateTime + */ + public static Date of(LocalDateTime date) { + if (date == null) { + return null; + } + // 将此日期时间与时区相结合以创建 ZonedDateTime + ZonedDateTime zonedDateTime = date.atZone(ZoneId.systemDefault()); + // 本地时间线 LocalDateTime 到即时时间线 Instant 时间戳 + Instant instant = zonedDateTime.toInstant(); + // UTC时间(世界协调时间,UTC + 00:00)转北京(北京,UTC + 8:00)时间 + return Date.from(instant); + } + + /** + * 将 Date 转换成 LocalDateTime + * + * @param date Date + * @return LocalDateTime + */ + public static LocalDateTime of(Date date) { + if (date == null) { + return null; + } + // 转为时间戳 + Instant instant = date.toInstant(); + // UTC时间(世界协调时间,UTC + 00:00)转北京(北京,UTC + 8:00)时间 + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } + + public static Date addTime(Duration duration) { + return new Date(System.currentTimeMillis() + duration.toMillis()); + } + + public static boolean isExpired(Date time) { + return System.currentTimeMillis() > time.getTime(); + } + + public static boolean isExpired(LocalDateTime time) { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(time); + } + + public static long diff(Date endTime, Date startTime) { + return endTime.getTime() - startTime.getTime(); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param mouth 月 + * @param day 日 + * @return 指定时间 + */ + public static Date buildTime(int year, int mouth, int day) { + return buildTime(year, mouth, day, 0, 0, 0); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param mouth 月 + * @param day 日 + * @param hour 小时 + * @param minute 分钟 + * @param second 秒 + * @return 指定时间 + */ + public static Date buildTime(int year, int mouth, int day, + int hour, int minute, int second) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, mouth - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, 0); // 一般情况下,都是 0 毫秒 + return calendar.getTime(); + } + + public static Date max(Date a, Date b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a.compareTo(b) > 0 ? a : b; + } + + public static LocalDateTime max(LocalDateTime a, LocalDateTime b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a.isAfter(b) ? a : b; + } + + /** + * 计算当期时间相差的日期 + * + * @param field 日历字段.
eg:Calendar.MONTH,Calendar.DAY_OF_MONTH,
Calendar.HOUR_OF_DAY等. + * @param amount 相差的数值 + * @return 计算后的日志 + */ + public static Date addDate(int field, int amount) { + return addDate(null, field, amount); + } + + /** + * 计算当期时间相差的日期 + * + * @param date 设置时间 + * @param field 日历字段 例如说,{@link Calendar#DAY_OF_MONTH} 等 + * @param amount 相差的数值 + * @return 计算后的日志 + */ + public static Date addDate(Date date, int field, int amount) { + if (amount == 0) { + return date; + } + Calendar c = Calendar.getInstance(); + if (date != null) { + c.setTime(date); + } + c.add(field, amount); + return c.getTime(); + } + + /** + * 是否今天 + * + * @param date 日期 + * @return 是否 + */ + public static boolean isToday(LocalDateTime date) { + return LocalDateTimeUtil.isSameDay(date, LocalDateTime.now()); + } + + /** + * 是否昨天 + * + * @param date 日期 + * @return 是否 + */ + public static boolean isYesterday(LocalDateTime date) { + return LocalDateTimeUtil.isSameDay(date, LocalDateTime.now().minusDays(1)); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/date/LocalDateTimeUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/date/LocalDateTimeUtils.java new file mode 100644 index 000000000..682b669ea --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/date/LocalDateTimeUtils.java @@ -0,0 +1,135 @@ +package com.cf.imes.framework.common.util.date; + +import cn.hutool.core.date.LocalDateTimeUtil; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; + +/** + * 时间工具类,用于 {@link java.time.LocalDateTime} + * + * @author 晨丰科技 + */ +public class LocalDateTimeUtils { + + /** + * 空的 LocalDateTime 对象,主要用于 DB 唯一索引的默认值 + */ + public static LocalDateTime EMPTY = buildTime(1970, 1, 1); + + public static LocalDateTime addTime(Duration duration) { + return LocalDateTime.now().plus(duration); + } + + public static LocalDateTime minusTime(Duration duration) { + return LocalDateTime.now().minus(duration); + } + + public static boolean beforeNow(LocalDateTime date) { + return date.isBefore(LocalDateTime.now()); + } + + public static boolean afterNow(LocalDateTime date) { + return date.isAfter(LocalDateTime.now()); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param mouth 月 + * @param day 日 + * @return 指定时间 + */ + public static LocalDateTime buildTime(int year, int mouth, int day) { + return LocalDateTime.of(year, mouth, day, 0, 0, 0); + } + + public static LocalDateTime[] buildBetweenTime(int year1, int mouth1, int day1, + int year2, int mouth2, int day2) { + return new LocalDateTime[]{buildTime(year1, mouth1, day1), buildTime(year2, mouth2, day2)}; + } + + /** + * 判断当前时间是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 是否 + */ + public static boolean isBetween(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + return false; + } + return LocalDateTimeUtil.isIn(LocalDateTime.now(), startTime, endTime); + } + + /** + * 判断当前时间是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 是否 + */ + public static boolean isBetween(String startTime, String endTime) { + if (startTime == null || endTime == null) { + return false; + } + LocalDate nowDate = LocalDate.now(); + return LocalDateTimeUtil.isIn(LocalDateTime.now(), + LocalDateTime.of(nowDate, LocalTime.parse(startTime)), + LocalDateTime.of(nowDate, LocalTime.parse(endTime))); + } + + /** + * 判断时间段是否重叠 + * + * @param startTime1 开始 time1 + * @param endTime1 结束 time1 + * @param startTime2 开始 time2 + * @param endTime2 结束 time2 + * @return 重叠:true 不重叠:false + */ + public static boolean isOverlap(LocalTime startTime1, LocalTime endTime1, LocalTime startTime2, LocalTime endTime2) { + LocalDate nowDate = LocalDate.now(); + return LocalDateTimeUtil.isOverlap(LocalDateTime.of(nowDate, startTime1), LocalDateTime.of(nowDate, endTime1), + LocalDateTime.of(nowDate, startTime2), LocalDateTime.of(nowDate, endTime2)); + } + + /** + * 获取指定日期所在的月份的开始时间 + * 例如:2023-09-30 00:00:00,000 + * + * @param date 日期 + * @return 月份的开始时间 + */ + public static LocalDateTime beginOfMonth(LocalDateTime date) { + return date.with(TemporalAdjusters.firstDayOfMonth()).with(LocalTime.MIN); + } + + /** + * 获取指定日期所在的月份的最后时间 + * 例如:2023-09-30 23:59:59,999 + * + * @param date 日期 + * @return 月份的结束时间 + */ + public static LocalDateTime endOfMonth(LocalDateTime date) { + return date.with(TemporalAdjusters.lastDayOfMonth()).with(LocalTime.MAX); + } + + /** + * 获取指定日期到现在过了几天,如果指定日期在当前日期之后,获取结果为负 + * + * @param dateTime 日期 + * @return 相差天数 + */ + public static Long between(LocalDateTime dateTime) { + return LocalDateTimeUtil.between(dateTime, LocalDateTime.now(), ChronoUnit.DAYS); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/http/HttpUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/http/HttpUtils.java new file mode 100644 index 000000000..28c954326 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/http/HttpUtils.java @@ -0,0 +1,126 @@ +package com.cf.imes.framework.common.util.http; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.map.TableMap; +import cn.hutool.core.net.url.UrlBuilder; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.http.HttpServletRequest; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Map; + +/** + * HTTP 工具类 + * + * @author 晨丰科技 + */ +public class HttpUtils { + + @SuppressWarnings("unchecked") + public static String replaceUrlQuery(String url, String key, String value) { + UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset()); + // 先移除 + TableMap query = (TableMap) + ReflectUtil.getFieldValue(builder.getQuery(), "query"); + query.remove(key); + // 后添加 + builder.addQuery(key, value); + return builder.build(); + } + + private String append(String base, Map query, boolean fragment) { + return append(base, query, null, fragment); + } + + /** + * 拼接 URL + * + * copy from Spring Security OAuth2 的 AuthorizationEndpoint 类的 append 方法 + * + * @param base 基础 URL + * @param query 查询参数 + * @param keys query 的 key,对应的原本的 key 的映射。例如说 query 里有个 key 是 xx,实际它的 key 是 extra_xx,则通过 keys 里添加这个映射 + * @param fragment URL 的 fragment,即拼接到 # 中 + * @return 拼接后的 URL + */ + public static String append(String base, Map query, Map keys, boolean fragment) { + UriComponentsBuilder template = UriComponentsBuilder.newInstance(); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(base); + URI redirectUri; + try { + // assume it's encoded to start with (if it came in over the wire) + redirectUri = builder.build(true).toUri(); + } catch (Exception e) { + // ... but allow client registrations to contain hard-coded non-encoded values + redirectUri = builder.build().toUri(); + builder = UriComponentsBuilder.fromUri(redirectUri); + } + template.scheme(redirectUri.getScheme()).port(redirectUri.getPort()).host(redirectUri.getHost()) + .userInfo(redirectUri.getUserInfo()).path(redirectUri.getPath()); + + if (fragment) { + StringBuilder values = new StringBuilder(); + if (redirectUri.getFragment() != null) { + String append = redirectUri.getFragment(); + values.append(append); + } + for (String key : query.keySet()) { + if (values.length() > 0) { + values.append("&"); + } + String name = key; + if (keys != null && keys.containsKey(key)) { + name = keys.get(key); + } + values.append(name).append("={").append(key).append("}"); + } + if (values.length() > 0) { + template.fragment(values.toString()); + } + UriComponents encoded = template.build().expand(query).encode(); + builder.fragment(encoded.getFragment()); + } else { + for (String key : query.keySet()) { + String name = key; + if (keys != null && keys.containsKey(key)) { + name = keys.get(key); + } + template.queryParam(name, "{" + key + "}"); + } + template.fragment(redirectUri.getFragment()); + UriComponents encoded = template.build().expand(query).encode(); + builder.query(encoded.getQuery()); + } + return builder.build().toUriString(); + } + + public static String[] obtainBasicAuthorization(HttpServletRequest request) { + String clientId; + String clientSecret; + // 先从 Header 中获取 + String authorization = request.getHeader("Authorization"); + authorization = StrUtil.subAfter(authorization, "Basic ", true); + if (StringUtils.hasText(authorization)) { + authorization = Base64.decodeStr(authorization); + clientId = StrUtil.subBefore(authorization, ":", false); + clientSecret = StrUtil.subAfter(authorization, ":", false); + // 再从 Param 中获取 + } else { + clientId = request.getParameter("client_id"); + clientSecret = request.getParameter("client_secret"); + } + + // 如果两者非空,则返回 + if (StrUtil.isNotEmpty(clientId) && StrUtil.isNotEmpty(clientSecret)) { + return new String[]{clientId, clientSecret}; + } + return null; + } + + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/io/FileUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/io/FileUtils.java new file mode 100644 index 000000000..726739395 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/io/FileUtils.java @@ -0,0 +1,84 @@ +package com.cf.imes.framework.common.util.io; + +import cn.hutool.core.io.FileTypeUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import lombok.SneakyThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; + +/** + * 文件工具类 + * + * @author 晨丰科技 + */ +public class FileUtils { + + /** + * 创建临时文件 + * 该文件会在 JVM 退出时,进行删除 + * + * @param data 文件内容 + * @return 文件 + */ + @SneakyThrows + public static File createTempFile(String data) { + File file = createTempFile(); + // 写入内容 + FileUtil.writeUtf8String(data, file); + return file; + } + + /** + * 创建临时文件 + * 该文件会在 JVM 退出时,进行删除 + * + * @param data 文件内容 + * @return 文件 + */ + @SneakyThrows + public static File createTempFile(byte[] data) { + File file = createTempFile(); + // 写入内容 + FileUtil.writeBytes(data, file); + return file; + } + + /** + * 创建临时文件,无内容 + * 该文件会在 JVM 退出时,进行删除 + * + * @return 文件 + */ + @SneakyThrows + public static File createTempFile() { + // 创建文件,通过 UUID 保证唯一 + File file = File.createTempFile(IdUtil.simpleUUID(), null); + // 标记 JVM 退出时,自动删除 + file.deleteOnExit(); + return file; + } + + /** + * 生成文件路径 + * + * @param content 文件内容 + * @param originalName 原始文件名 + * @return path,唯一不可重复 + */ + public static String generatePath(byte[] content, String originalName) { + String sha256Hex = DigestUtil.sha256Hex(content); + // 情况一:如果存在 name,则优先使用 name 的后缀 + if (StrUtil.isNotBlank(originalName)) { + String extName = FileNameUtil.extName(originalName); + return StrUtil.isBlank(extName) ? sha256Hex : sha256Hex + "." + extName; + } + // 情况二:基于 content 计算 + return sha256Hex + '.' + FileTypeUtil.getType(new ByteArrayInputStream(content)); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/io/IoUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/io/IoUtils.java new file mode 100644 index 000000000..1c5d08c06 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/io/IoUtils.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.common.util.io; + +import cn.hutool.core.io.IORuntimeException; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; + +import java.io.InputStream; + +/** + * IO 工具类,用于 {@link cn.hutool.core.io.IoUtil} 缺失的方法 + * + * @author 晨丰科技 + */ +public class IoUtils { + + /** + * 从流中读取 UTF8 编码的内容 + * + * @param in 输入流 + * @param isClose 是否关闭 + * @return 内容 + * @throws IORuntimeException IO 异常 + */ + public static String readUtf8(InputStream in, boolean isClose) throws IORuntimeException { + return StrUtil.utf8Str(IoUtil.read(in, isClose)); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/json/JsonUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/json/JsonUtils.java new file mode 100644 index 000000000..4be3b0821 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/json/JsonUtils.java @@ -0,0 +1,187 @@ +package com.cf.imes.framework.common.util.json; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * JSON 工具类 + * + * @author 晨丰科技 + */ +@Slf4j +public class JsonUtils { + + private static ObjectMapper objectMapper = new ObjectMapper(); + + static { + objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 忽略 null 值 + objectMapper.registerModules(new JavaTimeModule()); // 解决 LocalDateTime 的序列化 + } + + /** + * 初始化 objectMapper 属性 + *

+ * 通过这样的方式,使用 Spring 创建的 ObjectMapper Bean + * + * @param objectMapper ObjectMapper 对象 + */ + public static void init(ObjectMapper objectMapper) { + JsonUtils.objectMapper = objectMapper; + } + + @SneakyThrows + public static String toJsonString(Object object) { + return objectMapper.writeValueAsString(object); + } + + @SneakyThrows + public static byte[] toJsonByte(Object object) { + return objectMapper.writeValueAsBytes(object); + } + + @SneakyThrows + public static String toJsonPrettyString(Object object) { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(object); + } + + public static T parseObject(String text, Class clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + return objectMapper.readValue(text, clazz); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static T parseObject(String text, String path, Class clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + JsonNode treeNode = objectMapper.readTree(text); + JsonNode pathNode = treeNode.path(path); + return objectMapper.readValue(pathNode.toString(), clazz); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static T parseObject(String text, Type type) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + return objectMapper.readValue(text, objectMapper.getTypeFactory().constructType(type)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + /** + * 将字符串解析成指定类型的对象 + * 使用 {@link #parseObject(String, Class)} 时,在@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 的场景下, + * 如果 text 没有 class 属性,则会报错。此时,使用这个方法,可以解决。 + * + * @param text 字符串 + * @param clazz 类型 + * @return 对象 + */ + public static T parseObject2(String text, Class clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + return JSONUtil.toBean(text, clazz); + } + + public static T parseObject(byte[] bytes, Class clazz) { + if (ArrayUtil.isEmpty(bytes)) { + return null; + } + try { + return objectMapper.readValue(bytes, clazz); + } catch (IOException e) { + log.error("json parse err,json:{}", bytes, e); + throw new RuntimeException(e); + } + } + + public static T parseObject(String text, TypeReference typeReference) { + try { + return objectMapper.readValue(text, typeReference); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static List parseArray(String text, Class clazz) { + if (StrUtil.isEmpty(text)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static List parseArray(String text, String path, Class clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + JsonNode treeNode = objectMapper.readTree(text); + JsonNode pathNode = treeNode.path(path); + return objectMapper.readValue(pathNode.toString(), objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static JsonNode parseTree(String text) { + try { + return objectMapper.readTree(text); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static JsonNode parseTree(byte[] text) { + try { + return objectMapper.readTree(text); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static boolean isJson(String text) { + return JSONUtil.isTypeJSON(text); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/monitor/TracerUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/monitor/TracerUtils.java new file mode 100644 index 000000000..6db6891fe --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/monitor/TracerUtils.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.common.util.monitor; + +import org.apache.skywalking.apm.toolkit.trace.TraceContext; + +/** + * 链路追踪工具类 + * + * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 util 包下 + * + * @author 晨丰科技 + */ +public class TracerUtils { + + /** + * 私有化构造方法 + */ + private TracerUtils() { + } + + /** + * 获得链路追踪编号,直接返回 SkyWalking 的 TraceId。 + * 如果不存在的话为空字符串!!! + * + * @return 链路追踪编号 + */ + public static String getTraceId() { + return TraceContext.traceId(); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/number/MoneyUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/number/MoneyUtils.java new file mode 100644 index 000000000..1aa5d337a --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/number/MoneyUtils.java @@ -0,0 +1,73 @@ +package com.cf.imes.framework.common.util.number; + +import cn.hutool.core.math.Money; +import cn.hutool.core.util.NumberUtil; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 金额工具类 + * + * @author 晨丰科技 + */ +public class MoneyUtils { + + /** + * 计算百分比金额,四舍五入 + * + * @param price 金额 + * @param rate 百分比,例如说 56.77% 则传入 56.77 + * @return 百分比金额 + */ + public static Integer calculateRatePrice(Integer price, Double rate) { + return calculateRatePrice(price, rate, 0, RoundingMode.HALF_UP).intValue(); + } + + /** + * 计算百分比金额,向下传入 + * + * @param price 金额 + * @param rate 百分比,例如说 56.77% 则传入 56.77 + * @return 百分比金额 + */ + public static Integer calculateRatePriceFloor(Integer price, Double rate) { + return calculateRatePrice(price, rate, 0, RoundingMode.FLOOR).intValue(); + } + + /** + * 计算百分比金额 + * + * @param price 金额 + * @param rate 百分比,例如说 56.77% 则传入 56.77 + * @param scale 保留小数位数 + * @param roundingMode 舍入模式 + */ + public static BigDecimal calculateRatePrice(Number price, Number rate, int scale, RoundingMode roundingMode) { + return NumberUtil.toBigDecimal(price).multiply(NumberUtil.toBigDecimal(rate)) // 乘以 + .divide(BigDecimal.valueOf(100), scale, roundingMode); // 除以 100 + } + + /** + * 分转元 + * + * @param fen 分 + * @return 元 + */ + public static BigDecimal fenToYuan(int fen) { + return new Money(0, fen).getAmount(); + } + + /** + * 分转元(字符串) + * + * 例如说 fen 为 1 时,则结果为 0.01 + * + * @param fen 分 + * @return 元 + */ + public static String fenToYuanStr(int fen) { + return new Money(0, fen).toString(); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/number/NumberUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/number/NumberUtils.java new file mode 100644 index 000000000..3c46e3dcc --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/number/NumberUtils.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.common.util.number; + +import cn.hutool.core.util.StrUtil; + +/** + * 数字的工具类,补全 {@link cn.hutool.core.util.NumberUtil} 的功能 + * + * @author 晨丰科技 + */ +public class NumberUtils { + + public static Long parseLong(String str) { + return StrUtil.isNotEmpty(str) ? Long.valueOf(str) : null; + } + + /** + * 通过经纬度获取地球上两点之间的距离 + * + * 参考 <DistanceUtil> 实现,目前它已经被 hutool 删除 + * + * @param lat1 经度1 + * @param lng1 纬度1 + * @param lat2 经度2 + * @param lng2 纬度2 + * @return 距离,单位:千米 + */ + public static double getDistance(double lat1, double lng1, double lat2, double lng2) { + double radLat1 = lat1 * Math.PI / 180.0; + double radLat2 = lat2 * Math.PI / 180.0; + double a = radLat1 - radLat2; + double b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0; + double distance = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + + Math.cos(radLat1) * Math.cos(radLat2) + * Math.pow(Math.sin(b / 2), 2))); + distance = distance * 6378.137; + distance = Math.round(distance * 10000d) / 10000d; + return distance; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/BeanUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/BeanUtils.java new file mode 100644 index 000000000..46a963d00 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/BeanUtils.java @@ -0,0 +1,62 @@ +package com.cf.imes.framework.common.util.object; + +import cn.hutool.core.bean.BeanUtil; +import com.cf.imes.framework.common.pojo.PageResult; +import com.cf.imes.framework.common.util.collection.CollectionUtils; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Bean 工具类 + * + * 1. 默认使用 {@link cn.hutool.core.bean.BeanUtil} 作为实现类,虽然不同 bean 工具的性能有差别,但是对绝大多数同学的项目,不用在意这点性能 + * 2. 针对复杂的对象转换,可以搜参考 AuthConvert 实现,通过 mapstruct + default 配合实现 + * + * @author 晨丰科技 + */ +public class BeanUtils { + + public static T toBean(Object source, Class targetClass) { + return BeanUtil.toBean(source, targetClass); + } + + public static T toBean(Object source, Class targetClass, Consumer peek) { + T target = toBean(source, targetClass); + if (target != null) { + peek.accept(target); + } + return target; + } + + public static List toBean(List source, Class targetType) { + if (source == null) { + return null; + } + return CollectionUtils.convertList(source, s -> toBean(s, targetType)); + } + + public static List toBean(List source, Class targetType, Consumer peek) { + List list = toBean(source, targetType); + if (list != null) { + list.forEach(peek); + } + return list; + } + + public static PageResult toBean(PageResult source, Class targetType) { + return toBean(source, targetType, null); + } + + public static PageResult toBean(PageResult source, Class targetType, Consumer peek) { + if (source == null) { + return null; + } + List list = toBean(source.getList(), targetType); + if (peek != null) { + list.forEach(peek); + } + return new PageResult<>(list, source.getTotal()); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/ObjectUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/ObjectUtils.java new file mode 100644 index 000000000..dee945d9a --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/ObjectUtils.java @@ -0,0 +1,63 @@ +package com.cf.imes.framework.common.util.object; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.function.Consumer; + +/** + * Object 工具类 + * + * @author 晨丰科技 + */ +public class ObjectUtils { + + /** + * 复制对象,并忽略 Id 编号 + * + * @param object 被复制对象 + * @param consumer 消费者,可以二次编辑被复制对象 + * @return 复制后的对象 + */ + public static T cloneIgnoreId(T object, Consumer consumer) { + T result = ObjectUtil.clone(object); + // 忽略 id 编号 + Field field = ReflectUtil.getField(object.getClass(), "id"); + if (field != null) { + ReflectUtil.setFieldValue(result, field, null); + } + // 二次编辑 + if (result != null) { + consumer.accept(result); + } + return result; + } + + public static > T max(T obj1, T obj2) { + if (obj1 == null) { + return obj2; + } + if (obj2 == null) { + return obj1; + } + return obj1.compareTo(obj2) > 0 ? obj1 : obj2; + } + + @SafeVarargs + public static T defaultIfNull(T... array) { + for (T item : array) { + if (item != null) { + return item; + } + } + return null; + } + + @SafeVarargs + public static boolean equalsAny(T obj, T... array) { + return Arrays.asList(array).contains(obj); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/PageUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/PageUtils.java new file mode 100644 index 000000000..a3ee6752a --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/object/PageUtils.java @@ -0,0 +1,67 @@ +package com.cf.imes.framework.common.util.object; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.func.Func1; +import cn.hutool.core.lang.func.LambdaUtil; +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.common.pojo.PageParam; +import com.cf.imes.framework.common.pojo.SortablePageParam; +import com.cf.imes.framework.common.pojo.SortingField; +import org.springframework.util.Assert; + +import static java.util.Collections.singletonList; + +/** + * {@link PageParam} 工具类 + * + * @author 晨丰科技 + */ +public class PageUtils { + + private static final Object[] ORDER_TYPES = new String[]{SortingField.ORDER_ASC, SortingField.ORDER_DESC}; + + public static int getStart(PageParam pageParam) { + return (pageParam.getPageNo() - 1) * pageParam.getPageSize(); + } + + /** + * 构建排序字段(默认倒序) + * + * @param func 排序字段的 Lambda 表达式 + * @param 排序字段所属的类型 + * @return 排序字段 + */ + public static SortingField buildSortingField(Func1 func) { + return buildSortingField(func, SortingField.ORDER_DESC); + } + + /** + * 构建排序字段 + * + * @param func 排序字段的 Lambda 表达式 + * @param order 排序类型 {@link SortingField#ORDER_ASC} {@link SortingField#ORDER_DESC} + * @param 排序字段所属的类型 + * @return 排序字段 + */ + public static SortingField buildSortingField(Func1 func, String order) { + Assert.isTrue(ArrayUtil.contains(ORDER_TYPES, order), String.format("字段的排序类型只能是 %s/%s", ORDER_TYPES)); + + String fieldName = LambdaUtil.getFieldName(func); + return new SortingField(fieldName, order); + } + + /** + * 构建默认的排序字段 + * 如果排序字段为空,则设置排序字段;否则忽略 + * + * @param sortablePageParam 排序分页查询参数 + * @param func 排序字段的 Lambda 表达式 + * @param 排序字段所属的类型 + */ + public static void buildDefaultSortingField(SortablePageParam sortablePageParam, Func1 func) { + if (sortablePageParam != null && CollUtil.isEmpty(sortablePageParam.getSortingFields())) { + sortablePageParam.setSortingFields(singletonList(buildSortingField(func))); + } + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/package-info.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/package-info.java new file mode 100644 index 000000000..02e345039 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/package-info.java @@ -0,0 +1,7 @@ +/** + * 对于工具类的选择,优先查找 Hutool 中有没对应的方法 + * 如果没有,则自己封装对应的工具类,以 Utils 结尾,用于区分 + * + * ps:如果担心 Hutool 存在坑的问题,可以阅读 Hutool 的实现源码,以确保可靠性。并且,可以补充相关的单元测试。 + */ +package com.cf.imes.framework.common.util; diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/pinyin/PinYinUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/pinyin/PinYinUtils.java new file mode 100644 index 000000000..ef231afd4 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/pinyin/PinYinUtils.java @@ -0,0 +1,66 @@ +package com.cf.imes.framework.common.util.pinyin; + +import net.sourceforge.pinyin4j.PinyinHelper; +import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType; +import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat; +import net.sourceforge.pinyin4j.format.HanyuPinyinToneType; +import net.sourceforge.pinyin4j.format.exception.BadHanyuPinyinOutputFormatCombination; + +/** + * @author there + */ +public class PinYinUtils { + + /** + * 汉字转为拼音 + * @param chinese + * @return + */ + public static String convertToPinyin(String chinese) { + // 设置不带声调的输出选项 + HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat(); + format.setToneType(HanyuPinyinToneType.WITHOUT_TONE); + + StringBuilder output = new StringBuilder(); + for (char c : chinese.toCharArray()) { + try { + String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(c, format); + if (pinyinArray != null && pinyinArray.length > 0) { + output.append(pinyinArray[0]).append(" "); + } else { + output.append(c).append(" "); + } + } catch (Exception e) { + output.append(c).append(" "); + } + } + return output.toString().trim(); + } + + /** + * 获取字符串拼音的第一个字母 + * @param chinese + * @return + */ + public static String convertFirstChar(String chinese){ + String pinyinStr = ""; + //转为单个字符 + char[] newChar = chinese.toCharArray(); + HanyuPinyinOutputFormat defaultFormat = new HanyuPinyinOutputFormat(); + defaultFormat.setCaseType(HanyuPinyinCaseType.LOWERCASE); + defaultFormat.setToneType(HanyuPinyinToneType.WITHOUT_TONE); + for (int i = 0; i < newChar.length; i++) { + if (newChar[i] > 128) { + try { + pinyinStr += PinyinHelper.toHanyuPinyinStringArray(newChar[i], defaultFormat)[0].charAt(0); + } catch (BadHanyuPinyinOutputFormatCombination e) { + e.printStackTrace(); + } + }else{ + pinyinStr += newChar[i]; + } + } + return pinyinStr; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/servlet/ServletUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/servlet/ServletUtils.java new file mode 100644 index 000000000..0875b5a70 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/servlet/ServletUtils.java @@ -0,0 +1,111 @@ +package com.cf.imes.framework.common.util.servlet; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.ServletUtil; +import com.cf.imes.framework.common.util.json.JsonUtils; +import org.springframework.http.MediaType; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.util.Map; + +/** + * 客户端工具类 + * + * @author 晨丰科技 + */ +public class ServletUtils { + + /** + * 返回 JSON 字符串 + * + * @param response 响应 + * @param object 对象,会序列化成 JSON 字符串 + */ + @SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE,否则会乱码 + public static void writeJSON(HttpServletResponse response, Object object) { + String content = JsonUtils.toJsonString(object); + ServletUtil.write(response, content, MediaType.APPLICATION_JSON_UTF8_VALUE); + } + + /** + * 返回附件 + * + * @param response 响应 + * @param filename 文件名 + * @param content 附件内容 + */ + public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { + // 设置 header 和 contentType + response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8")); + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + // 输出附件 + IoUtil.write(response.getOutputStream(), false, content); + } + + /** + * @param request 请求 + * @return ua + */ + public static String getUserAgent(HttpServletRequest request) { + String ua = request.getHeader("User-Agent"); + return ua != null ? ua : ""; + } + + /** + * 获得请求 + * + * @return HttpServletRequest + */ + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + return ((ServletRequestAttributes) requestAttributes).getRequest(); + } + + public static String getUserAgent() { + HttpServletRequest request = getRequest(); + if (request == null) { + return null; + } + return getUserAgent(request); + } + + public static String getClientIP() { + HttpServletRequest request = getRequest(); + if (request == null) { + return null; + } + return ServletUtil.getClientIP(request); + } + + public static boolean isJsonRequest(ServletRequest request) { + return StrUtil.startWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE); + } + + public static String getBody(HttpServletRequest request) { + return ServletUtil.getBody(request); + } + + public static byte[] getBodyBytes(HttpServletRequest request) { + return ServletUtil.getBodyBytes(request); + } + + public static String getClientIP(HttpServletRequest request) { + return ServletUtil.getClientIP(request); + } + + public static Map getParamMap(HttpServletRequest request) { + return ServletUtil.getParamMap(request); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/sonwflake/SnowFlakeUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/sonwflake/SnowFlakeUtils.java new file mode 100644 index 000000000..4eddb0e72 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/sonwflake/SnowFlakeUtils.java @@ -0,0 +1,127 @@ +package com.cf.imes.framework.common.util.sonwflake; + +/** + * Java 雪花算法 + * @author there + */ +public class SnowFlakeUtils { + // 起始的时间戳,这个时间戳可以是你的系统初始时间,一般取当前时间戳 + private final static long START_TIMESTAMP = 1672502400000L; // 2023-01-01 00:00:00 + + // 每一部分占用的位数,可以根据自己的需求进行调整,这里是按照默认的占位数进行分配 + private final static long SEQUENCE_BIT = 12; // 序列号占用的位数 + private final static long MACHINE_BIT = 5; // 机器标识占用的位数 + private final static long DATA_CENTER_BIT = 5; // 数据中心占用的位数 + + // 每一部分的最大值,可以根据占用的位数进行计算得到 + private final static long MAX_SEQUENCE = ~(-1L << SEQUENCE_BIT); + private final static long MAX_MACHINE_NUM = ~(-1L << MACHINE_BIT); + private final static long MAX_DATA_CENTER_NUM = ~(-1L << DATA_CENTER_BIT); + + // 每一部分向左的位移,计算出来的值是为了后面生成 ID 做准备 + private final static long MACHINE_LEFT = SEQUENCE_BIT; + private final static long DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT; + private final static long TIMESTAMP_LEFT = DATA_CENTER_LEFT + DATA_CENTER_BIT; + + private long dataCenterId; // 数据中心 ID + private long machineId; // 机器 ID + private long sequence = 0L; // 序列号 + private long lastTimeStamp = -1L; // 上一次时间戳 + + /** + *

构造方法

+ * + * @param dataCenterId 数据中心 ID + * @param machineId 机器 ID + */ + public SnowFlakeUtils(long dataCenterId, long machineId) { + if (dataCenterId > MAX_DATA_CENTER_NUM || dataCenterId < 0) { + throw new IllegalArgumentException("数据中心标识不能大于等于 " + MAX_DATA_CENTER_NUM + " 或小于 0"); + } + if (machineId > MAX_MACHINE_NUM || machineId < 0) { + throw new IllegalArgumentException("机器标识不能大于等于 " + MAX_MACHINE_NUM + " 或小于 0"); + } + this.dataCenterId = dataCenterId; + this.machineId = machineId; + } + + /** + *

雪花算法核心方法

+ * 通过调用 nextId() 方法,让当前这台机器上的 snowflake 算法程序生成一个全局唯一的 id + */ + public synchronized long nextId() { + // 获取系统当前时间戳 + long currentTimeStamp = getSystemCurrentTimeMillis(); + if (currentTimeStamp < lastTimeStamp) { + throw new RuntimeException("时钟向后移动,拒绝生成雪花算法ID"); + } + + if (currentTimeStamp == lastTimeStamp) { + // 当前毫秒内,序列号自增 + sequence = (sequence + 1) & MAX_SEQUENCE; + // 序列号超出范围,需要等待下一毫秒 + if (sequence == 0L) { + // 获取下一毫秒 + currentTimeStamp = getNextMill(lastTimeStamp); + } + } else { + // 不同毫秒内,序列号置为 0 + sequence = 0L; + } + + lastTimeStamp = currentTimeStamp; + + // 使用位运算生成最终的 ID + return (currentTimeStamp - START_TIMESTAMP) << TIMESTAMP_LEFT + | dataCenterId << DATA_CENTER_LEFT + | machineId << MACHINE_LEFT + | sequence; + } + + /** + *

获取系统当前时间戳

+ * + * @return 当前时间(毫秒) + */ + private long getSystemCurrentTimeMillis() { + return System.currentTimeMillis(); + } + + /** + *

获取下一毫秒

+ * 当某一毫秒的时间,产生的 id 数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生 ID + * + * @param lastTimestamp 上次生成 ID 的时间截 + * @return 当前时间戳 + */ + private long getNextMill(long lastTimestamp) { + long timeMillis = getSystemCurrentTimeMillis(); + while (timeMillis <= lastTimestamp) { + timeMillis = getSystemCurrentTimeMillis(); + } + return timeMillis; + } + + /** + *

测试类

+ */ + public static void main(String[] args) { + SnowFlakeUtils worker1_1 = new SnowFlakeUtils(1, 1); + SnowFlakeUtils worker2_1 = new SnowFlakeUtils(2, 1); + SnowFlakeUtils worker3_1 = new SnowFlakeUtils(3, 1); + + SnowFlakeUtils worker1_2 = new SnowFlakeUtils(1, 2); + SnowFlakeUtils worker1_3 = new SnowFlakeUtils(2, 1); + for (int i = 0; i < 30; i++) { + System.out.println("数据中心1,实例1,雪花算法 ID:" + worker1_1.nextId()); + System.out.println("数据中心2,实例1,雪花算法 ID:" + worker2_1.nextId()); + System.out.println("数据中心3,实例1,雪花算法 ID:" + worker3_1.nextId()); + + System.out.println("数据中心1,实例2,雪花算法 ID:" + worker1_2.nextId()); + System.out.println("数据中心1,实例3,雪花算法 ID:" + worker1_2.nextId()); + } + } + + + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/spring/SpringAopUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/spring/SpringAopUtils.java new file mode 100644 index 000000000..24a26c2e0 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/spring/SpringAopUtils.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.common.util.spring; + +import cn.hutool.core.bean.BeanUtil; +import org.springframework.aop.framework.AdvisedSupport; +import org.springframework.aop.framework.AopProxy; +import org.springframework.aop.support.AopUtils; + +/** + * Spring AOP 工具类 + * + * 参考波克尔 http://www.bubuko.com/infodetail-3471885.html 实现 + */ +public class SpringAopUtils { + + /** + * 获取代理的目标对象 + * + * @param proxy 代理对象 + * @return 目标对象 + */ + public static Object getTarget(Object proxy) throws Exception { + // 不是代理对象 + if (!AopUtils.isAopProxy(proxy)) { + return proxy; + } + // Jdk 代理 + if (AopUtils.isJdkDynamicProxy(proxy)) { + return getJdkDynamicProxyTargetObject(proxy); + } + // Cglib 代理 + return getCglibProxyTargetObject(proxy); + } + + private static Object getCglibProxyTargetObject(Object proxy) throws Exception { + Object dynamicAdvisedInterceptor = BeanUtil.getFieldValue(proxy, "CGLIB$CALLBACK_0"); + AdvisedSupport advisedSupport = (AdvisedSupport) BeanUtil.getFieldValue(dynamicAdvisedInterceptor, "advised"); + return advisedSupport.getTargetSource().getTarget(); + } + + private static Object getJdkDynamicProxyTargetObject(Object proxy) throws Exception { + AopProxy aopProxy = (AopProxy) BeanUtil.getFieldValue(proxy, "h"); + AdvisedSupport advisedSupport = (AdvisedSupport) BeanUtil.getFieldValue(aopProxy, "advised"); + return advisedSupport.getTargetSource().getTarget(); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/spring/SpringExpressionUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/spring/SpringExpressionUtils.java new file mode 100644 index 000000000..a5214d34c --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/spring/SpringExpressionUtils.java @@ -0,0 +1,89 @@ +package com.cf.imes.framework.common.util.spring; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Spring EL 表达式的工具类 + * + * @author mashu + */ +public class SpringExpressionUtils { + + /** + * Spring EL 表达式解析器 + */ + private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); + /** + * 参数名发现器 + */ + private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); + + private SpringExpressionUtils() { + } + + /** + * 从切面中,单个解析 EL 表达式的结果 + * + * @param joinPoint 切面点 + * @param expressionString EL 表达式数组 + * @return 执行界面 + */ + public static Object parseExpression(JoinPoint joinPoint, String expressionString) { + Map result = parseExpressions(joinPoint, Collections.singletonList(expressionString)); + return result.get(expressionString); + } + + /** + * 从切面中,批量解析 EL 表达式的结果 + * + * @param joinPoint 切面点 + * @param expressionStrings EL 表达式数组 + * @return 结果,key 为表达式,value 为对应值 + */ + public static Map parseExpressions(JoinPoint joinPoint, List expressionStrings) { + // 如果为空,则不进行解析 + if (CollUtil.isEmpty(expressionStrings)) { + return MapUtil.newHashMap(); + } + + // 第一步,构建解析的上下文 EvaluationContext + // 通过 joinPoint 获取被注解方法 + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + Method method = methodSignature.getMethod(); + // 使用 spring 的 ParameterNameDiscoverer 获取方法形参名数组 + String[] paramNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method); + // Spring 的表达式上下文对象 + EvaluationContext context = new StandardEvaluationContext(); + // 给上下文赋值 + if (ArrayUtil.isNotEmpty(paramNames)) { + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < paramNames.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + } + + // 第二步,逐个参数解析 + Map result = MapUtil.newHashMap(expressionStrings.size(), true); + expressionStrings.forEach(key -> { + Object value = EXPRESSION_PARSER.parseExpression(key).getValue(context); + result.put(key, value); + }); + return result; + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/string/StrUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/string/StrUtils.java new file mode 100644 index 000000000..41956d202 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/string/StrUtils.java @@ -0,0 +1,69 @@ +package com.cf.imes.framework.common.util.string; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 字符串工具类 + * + * @author 晨丰科技 + */ +public class StrUtils { + + public static String maxLength(CharSequence str, int maxLength) { + return StrUtil.maxLength(str, maxLength - 3); // -3 的原因,是该方法会补充 ... 恰好 + } + + /** + * 给定字符串是否以任何一个字符串开始 + * 给定字符串和数组为空都返回 false + * + * @param str 给定字符串 + * @param prefixes 需要检测的开始字符串 + * @since 3.0.6 + */ + public static boolean startWithAny(String str, Collection prefixes) { + if (StrUtil.isEmpty(str) || ArrayUtil.isEmpty(prefixes)) { + return false; + } + + for (CharSequence suffix : prefixes) { + if (StrUtil.startWith(str, suffix, false)) { + return true; + } + } + return false; + } + + public static List splitToLong(String value, CharSequence separator) { + long[] longs = StrUtil.splitToLong(value, separator); + return Arrays.stream(longs).boxed().collect(Collectors.toList()); + } + + public static List splitToInteger(String value, CharSequence separator) { + int[] integers = StrUtil.splitToInt(value, separator); + return Arrays.stream(integers).boxed().collect(Collectors.toList()); + } + + /** + * 移除字符串中,包含指定字符串的行 + * + * @param content 字符串 + * @param sequence 包含的字符串 + * @return 移除后的字符串 + */ + public static String removeLineContains(String content, String sequence) { + if (StrUtil.isEmpty(content) || StrUtil.isEmpty(sequence)) { + return content; + } + return Arrays.stream(content.split("\n")) + .filter(line -> !line.contains(sequence)) + .collect(Collectors.joining("\n")); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/validation/ValidationUtils.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/validation/ValidationUtils.java new file mode 100644 index 000000000..ad229608d --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/util/validation/ValidationUtils.java @@ -0,0 +1,55 @@ +package com.cf.imes.framework.common.util.validation; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import org.springframework.util.StringUtils; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.Validation; +import javax.validation.Validator; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 校验工具类 + * + * @author 晨丰科技 + */ +public class ValidationUtils { + + private static final Pattern PATTERN_MOBILE = Pattern.compile("^(?:(?:\\+|00)86)?1(?:(?:3[\\d])|(?:4[0,1,4-9])|(?:5[0-3,5-9])|(?:6[2,5-7])|(?:7[0-8])|(?:8[\\d])|(?:9[0-3,5-9]))\\d{8}$"); + + private static final Pattern PATTERN_URL = Pattern.compile("^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); + + private static final Pattern PATTERN_XML_NCNAME = Pattern.compile("[a-zA-Z_][\\-_.0-9_a-zA-Z$]*"); + + public static boolean isMobile(String mobile) { + return StringUtils.hasText(mobile) + && PATTERN_MOBILE.matcher(mobile).matches(); + } + + public static boolean isURL(String url) { + return StringUtils.hasText(url) + && PATTERN_URL.matcher(url).matches(); + } + + public static boolean isXmlNCName(String str) { + return StringUtils.hasText(str) + && PATTERN_XML_NCNAME.matcher(str).matches(); + } + + public static void validate(Object object, Class... groups) { + Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + Assert.notNull(validator); + validate(validator, object, groups); + } + + public static void validate(Validator validator, Object object, Class... groups) { + Set> constraintViolations = validator.validate(object, groups); + if (CollUtil.isNotEmpty(constraintViolations)) { + throw new ConstraintViolationException(constraintViolations); + } + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnum.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnum.java new file mode 100644 index 000000000..2436d79f2 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnum.java @@ -0,0 +1,35 @@ +package com.cf.imes.framework.common.validation; + +import com.cf.imes.framework.common.core.IntArrayValuable; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, + ElementType.TYPE_USE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint( + validatedBy = {InEnumValidator.class, InEnumCollectionValidator.class} +) +public @interface InEnum { + + /** + * @return 实现 EnumValuable 接口的 + */ + Class value(); + + String message() default "必须在指定范围 {value}"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnumCollectionValidator.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnumCollectionValidator.java new file mode 100644 index 000000000..fc0f420a2 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnumCollectionValidator.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.common.validation; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.common.core.IntArrayValuable; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class InEnumCollectionValidator implements ConstraintValidator> { + + private List values; + + @Override + public void initialize(InEnum annotation) { + IntArrayValuable[] values = annotation.value().getEnumConstants(); + if (values.length == 0) { + this.values = Collections.emptyList(); + } else { + this.values = Arrays.stream(values[0].array()).boxed().collect(Collectors.toList()); + } + } + + @Override + public boolean isValid(Collection list, ConstraintValidatorContext context) { + // 校验通过 + if (CollUtil.containsAll(values, list)) { + return true; + } + // 校验不通过,自定义提示语句(因为,注解上的 value 是枚举类,无法获得枚举类的实际值) + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate() + .replaceAll("\\{value}", CollUtil.join(list, ","))).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnumValidator.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnumValidator.java new file mode 100644 index 000000000..3223b34e2 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/InEnumValidator.java @@ -0,0 +1,44 @@ +package com.cf.imes.framework.common.validation; + +import com.cf.imes.framework.common.core.IntArrayValuable; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class InEnumValidator implements ConstraintValidator { + + private List values; + + @Override + public void initialize(InEnum annotation) { + IntArrayValuable[] values = annotation.value().getEnumConstants(); + if (values.length == 0) { + this.values = Collections.emptyList(); + } else { + this.values = Arrays.stream(values[0].array()).boxed().collect(Collectors.toList()); + } + } + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + // 为空时,默认不校验,即认为通过 + if (value == null) { + return true; + } + // 校验通过 + if (values.contains(value)) { + return true; + } + // 校验不通过,自定义提示语句(因为,注解上的 value 是枚举类,无法获得枚举类的实际值) + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate() + .replaceAll("\\{value}", values.toString())).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/Mobile.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/Mobile.java new file mode 100644 index 000000000..3bfa34000 --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/Mobile.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.common.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, + ElementType.TYPE_USE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint( + validatedBy = MobileValidator.class +) +public @interface Mobile { + + String message() default "手机号格式不正确"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/MobileValidator.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/MobileValidator.java new file mode 100644 index 000000000..9ecd27c4c --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/MobileValidator.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.common.validation; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.validation.ValidationUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class MobileValidator implements ConstraintValidator { + + @Override + public void initialize(Mobile annotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // 如果手机号为空,默认不校验,即校验通过 + if (StrUtil.isEmpty(value)) { + return true; + } + // 校验手机 + return ValidationUtils.isMobile(value); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/Telephone.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/Telephone.java new file mode 100644 index 000000000..e9b56601e --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/Telephone.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.common.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, + ElementType.TYPE_USE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint( + validatedBy = TelephoneValidator.class +) +public @interface Telephone { + + String message() default "电话格式不正确"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/TelephoneValidator.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/TelephoneValidator.java new file mode 100644 index 000000000..ee3f698cd --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/TelephoneValidator.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.common.validation; + +import cn.hutool.core.text.CharSequenceUtil; +import cn.hutool.core.util.PhoneUtil; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class TelephoneValidator implements ConstraintValidator { + + @Override + public void initialize(Telephone annotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // 如果手机号为空,默认不校验,即校验通过 + if (CharSequenceUtil.isEmpty(value)) { + return true; + } + // 校验手机 + return PhoneUtil.isTel(value) || PhoneUtil.isPhone(value); + } + +} diff --git a/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/package-info.java b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/package-info.java new file mode 100644 index 000000000..eef2ba22a --- /dev/null +++ b/cf-framework/cf-common/src/main/java/com/cf/imes/framework/common/validation/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 Hibernate Validator 实现参数校验 + */ +package com.cf.imes.framework.common.validation; diff --git a/cf-framework/cf-common/src/test/java/com/cf/imes/framework/common/util/collection/CollectionUtilsTest.java b/cf-framework/cf-common/src/test/java/com/cf/imes/framework/common/util/collection/CollectionUtilsTest.java new file mode 100644 index 000000000..a6107381b --- /dev/null +++ b/cf-framework/cf-common/src/test/java/com/cf/imes/framework/common/util/collection/CollectionUtilsTest.java @@ -0,0 +1,64 @@ +package com.cf.imes.framework.common.util.collection; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link CollectionUtils} 的单元测试 + */ +public class CollectionUtilsTest { + + @Data + @AllArgsConstructor + private static class Dog { + + private Integer id; + private String name; + private String code; + + } + + @Test + public void testDiffList() { + // 准备参数 + Collection oldList = Arrays.asList( + new Dog(1, "花花", "hh"), + new Dog(2, "旺财", "wc") + ); + Collection newList = Arrays.asList( + new Dog(null, "花花2", "hh"), + new Dog(null, "小白", "xb") + ); + BiFunction sameFunc = (oldObj, newObj) -> { + boolean same = oldObj.getCode().equals(newObj.getCode()); + // 如果相等的情况下,需要设置下 id,后续好更新 + if (same) { + newObj.setId(oldObj.getId()); + } + return same; + }; + + // 调用 + List> result = CollectionUtils.diffList(oldList, newList, sameFunc); + // 断言 + assertEquals(result.size(), 3); + // 断言 create + assertEquals(result.get(0).size(), 1); + assertEquals(result.get(0).get(0), new Dog(null, "小白", "xb")); + // 断言 update + assertEquals(result.get(1).size(), 1); + assertEquals(result.get(1).get(0), new Dog(1, "花花2", "hh")); + // 断言 delete + assertEquals(result.get(2).size(), 1); + assertEquals(result.get(2).get(0), new Dog(2, "旺财", "wc")); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-banner/pom.xml b/cf-framework/cf-spring-boot-starter-banner/pom.xml new file mode 100644 index 000000000..3e88044d9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-banner/pom.xml @@ -0,0 +1,29 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + cf-spring-boot-starter-banner + jar + + ${project.artifactId} + Banner 用于在 console 控制台,打印开发文档、接口文档等 + + + + com.cf.imes + cf-common + + + + org.springframework.boot + spring-boot-starter + + + + diff --git a/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/config/ChenfengBannerAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/config/ChenfengBannerAutoConfiguration.java new file mode 100644 index 000000000..faf58504a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/config/ChenfengBannerAutoConfiguration.java @@ -0,0 +1,20 @@ +package com.cf.imes.framework.banner.config; + +import com.cf.imes.framework.banner.core.BannerApplicationRunner; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Banner 的自动配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +public class ChenfengBannerAutoConfiguration { + + @Bean + public BannerApplicationRunner bannerApplicationRunner() { + return new BannerApplicationRunner(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/core/BannerApplicationRunner.java b/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/core/BannerApplicationRunner.java new file mode 100644 index 000000000..9355e8648 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/core/BannerApplicationRunner.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.banner.core; + +import cn.hutool.core.thread.ThreadUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; + +import java.util.concurrent.TimeUnit; + +/** + * 项目启动成功后,提供文档相关的地址 + * + * @author 晨丰科技 + */ +@Slf4j +public class BannerApplicationRunner implements ApplicationRunner { + + @Override + public void run(ApplicationArguments args) { + ThreadUtil.execute(() -> { + ThreadUtil.sleep(1, TimeUnit.SECONDS); // 延迟 1 秒,保证输出到结尾 + log.info("\n----------------------------------------------------------\n\t" + + "晨丰IMES项目启动成功!\n" + + "----------------------------------------------------------" + ); + + }); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/package-info.java b/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/package-info.java new file mode 100644 index 000000000..abea5d46f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-banner/src/main/java/com/cf/imes/framework/banner/package-info.java @@ -0,0 +1,6 @@ +/** + * Banner 用于在 console 控制台,打印开发文档、接口文档等 + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.banner; diff --git a/cf-framework/cf-spring-boot-starter-banner/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-banner/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..b88a78f98 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-banner/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.banner.config.ChenfengBannerAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-banner/src/main/resources/banner.txt b/cf-framework/cf-spring-boot-starter-banner/src/main/resources/banner.txt new file mode 100644 index 000000000..ce19c4818 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-banner/src/main/resources/banner.txt @@ -0,0 +1,17 @@ +晨丰科技 http://www.cf.com +Application Version: ${chenfeng.info.version} +Spring Boot Version: ${spring-boot.version} + +.__ __. ______ .______ __ __ _______ +| \ | | / __ \ | _ \ | | | | / _____| +| \| | | | | | | |_) | | | | | | | __ +| . ` | | | | | | _ < | | | | | | |_ | +| |\ | | `--' | | |_) | | `--' | | |__| | +|__| \__| \______/ |______/ \______/ \______| + +███╗ ██╗ ██████╗ ██████╗ ██╗ ██╗ ██████╗ +████╗ ██║██╔═══██╗ ██╔══██╗██║ ██║██╔════╝ +██╔██╗ ██║██║ ██║ ██████╔╝██║ ██║██║ ███╗ +██║╚██╗██║██║ ██║ ██╔══██╗██║ ██║██║ ██║ +██║ ╚████║╚██████╔╝ ██████╔╝╚██████╔╝╚██████╔╝ +╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/pom.xml b/cf-framework/cf-spring-boot-starter-biz-data-permission/pom.xml new file mode 100644 index 000000000..fb5a0d5d5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/pom.xml @@ -0,0 +1,51 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-data-permission + jar + + ${project.artifactId} + 数据权限 + + + + com.cf.imes + cf-common + + + + + com.cf.imes + cf-spring-boot-starter-security + true + + + + + com.cf.imes + cf-spring-boot-starter-mybatis + + + + + com.cf.imes + cf-module-system-api + ${revision} + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/config/ChenfengDataPermissionAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/config/ChenfengDataPermissionAutoConfiguration.java new file mode 100644 index 000000000..1f0f7f3f6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/config/ChenfengDataPermissionAutoConfiguration.java @@ -0,0 +1,44 @@ +package com.cf.imes.framework.datapermission.config; + +import com.cf.imes.framework.datapermission.core.aop.DataPermissionAnnotationAdvisor; +import com.cf.imes.framework.datapermission.core.db.DataPermissionDatabaseInterceptor; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRule; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRuleFactory; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRuleFactoryImpl; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * 数据权限的自动配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +public class ChenfengDataPermissionAutoConfiguration { + + @Bean + public DataPermissionRuleFactory dataPermissionRuleFactory(List rules) { + return new DataPermissionRuleFactoryImpl(rules); + } + + @Bean + public DataPermissionDatabaseInterceptor dataPermissionDatabaseInterceptor(MybatisPlusInterceptor interceptor, + DataPermissionRuleFactory ruleFactory) { + // 创建 DataPermissionDatabaseInterceptor 拦截器 + DataPermissionDatabaseInterceptor inner = new DataPermissionDatabaseInterceptor(ruleFactory); + // 添加到 interceptor 中 + // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定 + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + @Bean + public DataPermissionAnnotationAdvisor dataPermissionAnnotationAdvisor() { + return new DataPermissionAnnotationAdvisor(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/config/ChenfengDeptDataPermissionAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/config/ChenfengDeptDataPermissionAutoConfiguration.java new file mode 100644 index 000000000..afe7b55b5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/config/ChenfengDeptDataPermissionAutoConfiguration.java @@ -0,0 +1,44 @@ +package com.cf.imes.framework.datapermission.config; + +import cn.hutool.extra.spring.SpringUtil; +import com.cf.imes.framework.datapermission.core.rule.dept.DeptDataPermissionRule; +import com.cf.imes.framework.datapermission.core.rule.dept.DeptDataPermissionRuleCustomizer; +import com.cf.imes.framework.security.core.LoginUser; +import com.cf.imes.module.system.api.permission.PermissionApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * 基于部门的数据权限 AutoConfiguration + * + * @author 晨丰科技 + */ +@AutoConfiguration +@ConditionalOnClass(LoginUser.class) +@ConditionalOnBean(value = DeptDataPermissionRuleCustomizer.class) +public class ChenfengDeptDataPermissionAutoConfiguration { + + @Bean + public DeptDataPermissionRule deptDataPermissionRule(PermissionApi permissionApi, + List customizers) { + // Cloud 专属逻辑:优先使用本地的 PermissionApi 实现类,而不是 Feign 调用 + // 原因:在创建组织时,组织还没创建好,导致 Feign 调用获取数据权限时,报“组织不存在”的错误 + try { + PermissionApi permissionApiImpl = SpringUtil.getBean("permissionApiImpl", PermissionApi.class); + if (permissionApiImpl != null) { + permissionApi = permissionApiImpl; + } + } catch (Exception ignored) {} + + // 创建 DeptDataPermissionRule 对象 + DeptDataPermissionRule rule = new DeptDataPermissionRule(permissionApi); + // 补全表配置 + customizers.forEach(customizer -> customizer.customize(rule)); + return rule; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/annotation/DataPermission.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/annotation/DataPermission.java new file mode 100644 index 000000000..23579356f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/annotation/DataPermission.java @@ -0,0 +1,35 @@ +package com.cf.imes.framework.datapermission.core.annotation; + +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRule; + +import java.lang.annotation.*; + +/** + * 数据权限注解 + * 可声明在类或者方法上,标识使用的数据权限规则 + * + * @author 晨丰科技 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DataPermission { + + /** + * 当前类或方法是否开启数据权限 + * 即使不添加 @DataPermission 注解,默认是开启状态 + * 可通过设置 enable 为 false 禁用 + */ + boolean enable() default true; + + /** + * 生效的数据权限规则数组,优先级高于 {@link #excludeRules()} + */ + Class[] includeRules() default {}; + + /** + * 排除的数据权限规则数组,优先级最低 + */ + Class[] excludeRules() default {}; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationAdvisor.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationAdvisor.java new file mode 100644 index 000000000..2acb2e39c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationAdvisor.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.datapermission.core.aop; + +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.aopalliance.aop.Advice; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.AbstractPointcutAdvisor; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; + +/** + * {@link DataPermission} 注解的 Advisor 实现类 + * + * @author 晨丰科技 + */ +@Getter +@EqualsAndHashCode(callSuper = true) +public class DataPermissionAnnotationAdvisor extends AbstractPointcutAdvisor { + + private final Advice advice; + + private final Pointcut pointcut; + + public DataPermissionAnnotationAdvisor() { + this.advice = new DataPermissionAnnotationInterceptor(); + this.pointcut = this.buildPointcut(); + } + + protected Pointcut buildPointcut() { + Pointcut classPointcut = new AnnotationMatchingPointcut(DataPermission.class, true); + Pointcut methodPointcut = new AnnotationMatchingPointcut(null, DataPermission.class, true); + return new ComposablePointcut(classPointcut).union(methodPointcut); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationInterceptor.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationInterceptor.java new file mode 100644 index 000000000..c975c77f3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationInterceptor.java @@ -0,0 +1,72 @@ +package com.cf.imes.framework.datapermission.core.aop; + +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import lombok.Getter; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.core.MethodClassKey; +import org.springframework.core.annotation.AnnotationUtils; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link DataPermission} 注解的拦截器 + * 1. 在执行方法前,将 @DataPermission 注解入栈 + * 2. 在执行方法后,将 @DataPermission 注解出栈 + * + * @author 晨丰科技 + */ +@DataPermission // 该注解,用于 {@link DATA_PERMISSION_NULL} 的空对象 +public class DataPermissionAnnotationInterceptor implements MethodInterceptor { + + /** + * DataPermission 空对象,用于方法无 {@link DataPermission} 注解时,使用 DATA_PERMISSION_NULL 进行占位 + */ + static final DataPermission DATA_PERMISSION_NULL = DataPermissionAnnotationInterceptor.class.getAnnotation(DataPermission.class); + + @Getter + private final Map dataPermissionCache = new ConcurrentHashMap<>(); + + @Override + public Object invoke(MethodInvocation methodInvocation) throws Throwable { + // 入栈 + DataPermission dataPermission = this.findAnnotation(methodInvocation); + if (dataPermission != null) { + DataPermissionContextHolder.add(dataPermission); + } + try { + // 执行逻辑 + return methodInvocation.proceed(); + } finally { + // 出栈 + if (dataPermission != null) { + DataPermissionContextHolder.remove(); + } + } + } + + private DataPermission findAnnotation(MethodInvocation methodInvocation) { + // 1. 从缓存中获取 + Method method = methodInvocation.getMethod(); + Object targetObject = methodInvocation.getThis(); + Class clazz = targetObject != null ? targetObject.getClass() : method.getDeclaringClass(); + MethodClassKey methodClassKey = new MethodClassKey(method, clazz); + DataPermission dataPermission = dataPermissionCache.get(methodClassKey); + if (dataPermission != null) { + return dataPermission != DATA_PERMISSION_NULL ? dataPermission : null; + } + + // 2.1 从方法中获取 + dataPermission = AnnotationUtils.findAnnotation(method, DataPermission.class); + // 2.2 从类上获取 + if (dataPermission == null) { + dataPermission = AnnotationUtils.findAnnotation(clazz, DataPermission.class); + } + // 2.3 添加到缓存中 + dataPermissionCache.put(methodClassKey, dataPermission != null ? dataPermission : DATA_PERMISSION_NULL); + return dataPermission; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionContextHolder.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionContextHolder.java new file mode 100644 index 000000000..0d7723fae --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionContextHolder.java @@ -0,0 +1,72 @@ +package com.cf.imes.framework.datapermission.core.aop; + +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import com.alibaba.ttl.TransmittableThreadLocal; + +import java.util.LinkedList; +import java.util.List; + +/** + * {@link DataPermission} 注解的 Context 上下文 + * + * @author 晨丰科技 + */ +public class DataPermissionContextHolder { + + /** + * 使用 List 的原因,可能存在方法的嵌套调用 + */ + private static final ThreadLocal> DATA_PERMISSIONS = + TransmittableThreadLocal.withInitial(LinkedList::new); + + /** + * 获得当前的 DataPermission 注解 + * + * @return DataPermission 注解 + */ + public static DataPermission get() { + return DATA_PERMISSIONS.get().peekLast(); + } + + /** + * 入栈 DataPermission 注解 + * + * @param dataPermission DataPermission 注解 + */ + public static void add(DataPermission dataPermission) { + DATA_PERMISSIONS.get().addLast(dataPermission); + } + + /** + * 出栈 DataPermission 注解 + * + * @return DataPermission 注解 + */ + public static DataPermission remove() { + DataPermission dataPermission = DATA_PERMISSIONS.get().removeLast(); + // 无元素时,清空 ThreadLocal + if (DATA_PERMISSIONS.get().isEmpty()) { + DATA_PERMISSIONS.remove(); + } + return dataPermission; + } + + /** + * 获得所有 DataPermission + * + * @return DataPermission 队列 + */ + public static List getAll() { + return DATA_PERMISSIONS.get(); + } + + /** + * 清空上下文 + * + * 目前仅仅用于单测 + */ + public static void clear() { + DATA_PERMISSIONS.remove(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptor.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptor.java new file mode 100644 index 000000000..ce3e9cfea --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptor.java @@ -0,0 +1,641 @@ +package com.cf.imes.framework.datapermission.core.db; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.common.util.collection.SetUtils; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRule; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRuleFactory; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import com.baomidou.mybatisplus.extension.parser.JsqlParserSupport; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.sf.jsqlparser.expression.*; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.conditional.OrExpression; +import net.sf.jsqlparser.expression.operators.relational.ExistsExpression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.InExpression; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.select.*; +import net.sf.jsqlparser.statement.update.Update; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; + +import java.sql.Connection; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 数据权限拦截器,通过 {@link DataPermissionRule} 数据权限规则,重写 SQL 的方式来实现 + * 主要的 SQL 重写方法,可见 {@link #builderExpression(Expression, List)} 方法 + * + * 整体的代码实现上,参考 {@link com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor} 实现。 + * 所以每次 MyBatis Plus 升级时,需要 Review 下其具体的实现是否有变更! + * + * @author 晨丰科技 + */ +@RequiredArgsConstructor +public class DataPermissionDatabaseInterceptor extends JsqlParserSupport implements InnerInterceptor { + + private final DataPermissionRuleFactory ruleFactory; + + @Getter + private final MappedStatementCache mappedStatementCache = new MappedStatementCache(); + + @Override // SELECT 场景 + public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { + // 获得 Mapper 对应的数据权限的规则 + List rules = ruleFactory.getDataPermissionRule(ms.getId()); + if (mappedStatementCache.noRewritable(ms, rules)) { // 如果无需重写,则跳过 + return; + } + + PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql); + try { + // 初始化上下文 + ContextHolder.init(rules); + // 处理 SQL + mpBs.sql(parserSingle(mpBs.sql(), null)); + } finally { + // 添加是否需要重写的缓存 + addMappedStatementCache(ms); + // 清空上下文 + ContextHolder.clear(); + } + } + + @Override // 只处理 UPDATE / DELETE 场景,不处理 INSERT 场景(因为 INSERT 不需要数据权限) + public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) { + PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh); + MappedStatement ms = mpSh.mappedStatement(); + SqlCommandType sct = ms.getSqlCommandType(); + if (sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) { + // 获得 Mapper 对应的数据权限的规则 + List rules = ruleFactory.getDataPermissionRule(ms.getId()); + if (mappedStatementCache.noRewritable(ms, rules)) { // 如果无需重写,则跳过 + return; + } + + PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql(); + try { + // 初始化上下文 + ContextHolder.init(rules); + // 处理 SQL + mpBs.sql(parserMulti(mpBs.sql(), null)); + } finally { + // 添加是否需要重写的缓存 + addMappedStatementCache(ms); + // 清空上下文 + ContextHolder.clear(); + } + } + } + + @Override + protected void processSelect(Select select, int index, String sql, Object obj) { + processSelectBody(select.getSelectBody()); + List withItemsList = select.getWithItemsList(); + if (!CollectionUtils.isEmpty(withItemsList)) { + withItemsList.forEach(this::processSelectBody); + } + } + + /** + * update 语句处理 + */ + @Override + protected void processUpdate(Update update, int index, String sql, Object obj) { + final Table table = update.getTable(); + update.setWhere(this.builderExpression(update.getWhere(), table)); + } + + /** + * delete 语句处理 + */ + @Override + protected void processDelete(Delete delete, int index, String sql, Object obj) { + delete.setWhere(this.builderExpression(delete.getWhere(), delete.getTable())); + } + + // ========== 和 TenantLineInnerInterceptor 一致的逻辑 ========== + + protected void processSelectBody(SelectBody selectBody) { + if (selectBody == null) { + return; + } + if (selectBody instanceof PlainSelect) { + processPlainSelect((PlainSelect) selectBody); + } else if (selectBody instanceof WithItem) { + WithItem withItem = (WithItem) selectBody; + processSelectBody(withItem.getSubSelect().getSelectBody()); + } else { + SetOperationList operationList = (SetOperationList) selectBody; + List selectBodyList = operationList.getSelects(); + if (CollectionUtils.isNotEmpty(selectBodyList)) { + selectBodyList.forEach(this::processSelectBody); + } + } + } + + /** + * 处理 PlainSelect + */ + protected void processPlainSelect(PlainSelect plainSelect) { + //#3087 github + List selectItems = plainSelect.getSelectItems(); + if (CollectionUtils.isNotEmpty(selectItems)) { + selectItems.forEach(this::processSelectItem); + } + + // 处理 where 中的子查询 + Expression where = plainSelect.getWhere(); + processWhereSubSelect(where); + + // 处理 fromItem + FromItem fromItem = plainSelect.getFromItem(); + List list = processFromItem(fromItem); + List
mainTables = new ArrayList<>(list); + + // 处理 join + List joins = plainSelect.getJoins(); + if (CollectionUtils.isNotEmpty(joins)) { + mainTables = processJoins(mainTables, joins); + } + + // 当有 mainTable 时,进行 where 条件追加 + if (CollectionUtils.isNotEmpty(mainTables)) { + plainSelect.setWhere(builderExpression(where, mainTables)); + } + } + + private List
processFromItem(FromItem fromItem) { + // 处理括号括起来的表达式 + while (fromItem instanceof ParenthesisFromItem) { + fromItem = ((ParenthesisFromItem) fromItem).getFromItem(); + } + + List
mainTables = new ArrayList<>(); + // 无 join 时的处理逻辑 + if (fromItem instanceof Table) { + Table fromTable = (Table) fromItem; + mainTables.add(fromTable); + } else if (fromItem instanceof SubJoin) { + // SubJoin 类型则还需要添加上 where 条件 + List
tables = processSubJoin((SubJoin) fromItem); + mainTables.addAll(tables); + } else { + // 处理下 fromItem + processOtherFromItem(fromItem); + } + return mainTables; + } + + /** + * 处理where条件内的子查询 + *

+ * 支持如下: + * 1. in + * 2. = + * 3. > + * 4. < + * 5. >= + * 6. <= + * 7. <> + * 8. EXISTS + * 9. NOT EXISTS + *

+ * 前提条件: + * 1. 子查询必须放在小括号中 + * 2. 子查询一般放在比较操作符的右边 + * + * @param where where 条件 + */ + protected void processWhereSubSelect(Expression where) { + if (where == null) { + return; + } + if (where instanceof FromItem) { + processOtherFromItem((FromItem) where); + return; + } + if (where.toString().indexOf("SELECT") > 0) { + // 有子查询 + if (where instanceof BinaryExpression) { + // 比较符号 , and , or , 等等 + BinaryExpression expression = (BinaryExpression) where; + processWhereSubSelect(expression.getLeftExpression()); + processWhereSubSelect(expression.getRightExpression()); + } else if (where instanceof InExpression) { + // in + InExpression expression = (InExpression) where; + Expression inExpression = expression.getRightExpression(); + if (inExpression instanceof SubSelect) { + processSelectBody(((SubSelect) inExpression).getSelectBody()); + } + } else if (where instanceof ExistsExpression) { + // exists + ExistsExpression expression = (ExistsExpression) where; + processWhereSubSelect(expression.getRightExpression()); + } else if (where instanceof NotExpression) { + // not exists + NotExpression expression = (NotExpression) where; + processWhereSubSelect(expression.getExpression()); + } else if (where instanceof Parenthesis) { + Parenthesis expression = (Parenthesis) where; + processWhereSubSelect(expression.getExpression()); + } + } + } + + protected void processSelectItem(SelectItem selectItem) { + if (selectItem instanceof SelectExpressionItem) { + SelectExpressionItem selectExpressionItem = (SelectExpressionItem) selectItem; + if (selectExpressionItem.getExpression() instanceof SubSelect) { + processSelectBody(((SubSelect) selectExpressionItem.getExpression()).getSelectBody()); + } else if (selectExpressionItem.getExpression() instanceof Function) { + processFunction((Function) selectExpressionItem.getExpression()); + } + } + } + + /** + * 处理函数 + *

支持: 1. select fun(args..) 2. select fun1(fun2(args..),args..)

+ *

fixed gitee pulls/141

+ * + * @param function + */ + protected void processFunction(Function function) { + ExpressionList parameters = function.getParameters(); + if (parameters != null) { + parameters.getExpressions().forEach(expression -> { + if (expression instanceof SubSelect) { + processSelectBody(((SubSelect) expression).getSelectBody()); + } else if (expression instanceof Function) { + processFunction((Function) expression); + } + }); + } + } + + /** + * 处理子查询等 + */ + protected void processOtherFromItem(FromItem fromItem) { + // 去除括号 + while (fromItem instanceof ParenthesisFromItem) { + fromItem = ((ParenthesisFromItem) fromItem).getFromItem(); + } + + if (fromItem instanceof SubSelect) { + SubSelect subSelect = (SubSelect) fromItem; + if (subSelect.getSelectBody() != null) { + processSelectBody(subSelect.getSelectBody()); + } + } else if (fromItem instanceof ValuesList) { + logger.debug("Perform a subQuery, if you do not give us feedback"); + } else if (fromItem instanceof LateralSubSelect) { + LateralSubSelect lateralSubSelect = (LateralSubSelect) fromItem; + if (lateralSubSelect.getSubSelect() != null) { + SubSelect subSelect = lateralSubSelect.getSubSelect(); + if (subSelect.getSelectBody() != null) { + processSelectBody(subSelect.getSelectBody()); + } + } + } + } + + /** + * 处理 sub join + * + * @param subJoin subJoin + * @return Table subJoin 中的主表 + */ + private List
processSubJoin(SubJoin subJoin) { + List
mainTables = new ArrayList<>(); + if (subJoin.getJoinList() != null) { + List
list = processFromItem(subJoin.getLeft()); + mainTables.addAll(list); + mainTables = processJoins(mainTables, subJoin.getJoinList()); + } + return mainTables; + } + + /** + * 处理 joins + * + * @param mainTables 可以为 null + * @param joins join 集合 + * @return List
右连接查询的 Table 列表 + */ + private List
processJoins(List
mainTables, List joins) { + // join 表达式中最终的主表 + Table mainTable = null; + // 当前 join 的左表 + Table leftTable = null; + + if (mainTables == null) { + mainTables = new ArrayList<>(); + } else if (mainTables.size() == 1) { + mainTable = mainTables.get(0); + leftTable = mainTable; + } + + //对于 on 表达式写在最后的 join,需要记录下前面多个 on 的表名 + Deque> onTableDeque = new LinkedList<>(); + for (Join join : joins) { + // 处理 on 表达式 + FromItem joinItem = join.getRightItem(); + + // 获取当前 join 的表,subJoint 可以看作是一张表 + List
joinTables = null; + if (joinItem instanceof Table) { + joinTables = new ArrayList<>(); + joinTables.add((Table) joinItem); + } else if (joinItem instanceof SubJoin) { + joinTables = processSubJoin((SubJoin) joinItem); + } + + if (joinTables != null) { + + // 如果是隐式内连接 + if (join.isSimple()) { + mainTables.addAll(joinTables); + continue; + } + + // 当前表是否忽略 + Table joinTable = joinTables.get(0); + + List
onTables = null; + // 如果不要忽略,且是右连接,则记录下当前表 + if (join.isRight()) { + mainTable = joinTable; + if (leftTable != null) { + onTables = Collections.singletonList(leftTable); + } + } else if (join.isLeft()) { + onTables = Collections.singletonList(joinTable); + } else if (join.isInner()) { + if (mainTable == null) { + onTables = Collections.singletonList(joinTable); + } else { + onTables = Arrays.asList(mainTable, joinTable); + } + mainTable = null; + } + + mainTables = new ArrayList<>(); + if (mainTable != null) { + mainTables.add(mainTable); + } + + // 获取 join 尾缀的 on 表达式列表 + Collection originOnExpressions = join.getOnExpressions(); + // 正常 join on 表达式只有一个,立刻处理 + if (originOnExpressions.size() == 1 && onTables != null) { + List onExpressions = new LinkedList<>(); + onExpressions.add(builderExpression(originOnExpressions.iterator().next(), onTables)); + join.setOnExpressions(onExpressions); + leftTable = joinTable; + continue; + } + // 表名压栈,忽略的表压入 null,以便后续不处理 + onTableDeque.push(onTables); + // 尾缀多个 on 表达式的时候统一处理 + if (originOnExpressions.size() > 1) { + Collection onExpressions = new LinkedList<>(); + for (Expression originOnExpression : originOnExpressions) { + List
currentTableList = onTableDeque.poll(); + if (CollectionUtils.isEmpty(currentTableList)) { + onExpressions.add(originOnExpression); + } else { + onExpressions.add(builderExpression(originOnExpression, currentTableList)); + } + } + join.setOnExpressions(onExpressions); + } + leftTable = joinTable; + } else { + processOtherFromItem(joinItem); + leftTable = null; + } + } + + return mainTables; + } + + // ========== 和 TenantLineInnerInterceptor 存在差异的逻辑:关键,实现权限条件的拼接 ========== + + /** + * 处理条件 + * + * @param currentExpression 当前 where 条件 + * @param table 单个表 + */ + protected Expression builderExpression(Expression currentExpression, Table table) { + return this.builderExpression(currentExpression, Collections.singletonList(table)); + } + + /** + * 处理条件 + * + * @param currentExpression 当前 where 条件 + * @param tables 多个表 + */ + protected Expression builderExpression(Expression currentExpression, List
tables) { + // 没有表需要处理直接返回 + if (CollectionUtils.isEmpty(tables)) { + return currentExpression; + } + + // 第一步,获得 Table 对应的数据权限条件 + Expression dataPermissionExpression = null; + for (Table table : tables) { + // 构建每个表的权限 Expression 条件 + Expression expression = buildDataPermissionExpression(table); + if (expression == null) { + continue; + } + // 合并到 dataPermissionExpression 中 + dataPermissionExpression = dataPermissionExpression == null ? expression + : new AndExpression(dataPermissionExpression, expression); + } + + // 第二步,合并多个 Expression 条件 + if (dataPermissionExpression == null) { + return currentExpression; + } + if (currentExpression == null) { + return dataPermissionExpression; + } + // ① 如果表达式为 Or,则需要 (currentExpression) AND dataPermissionExpression + if (currentExpression instanceof OrExpression) { + return new AndExpression(new Parenthesis(currentExpression), dataPermissionExpression); + } + // ② 如果表达式为 And,则直接返回 where AND dataPermissionExpression + return new AndExpression(currentExpression, dataPermissionExpression); + } + + /** + * 构建指定表的数据权限的 Expression 过滤条件 + * + * @param table 表 + * @return Expression 过滤条件 + */ + private Expression buildDataPermissionExpression(Table table) { + // 生成条件 + Expression allExpression = null; + for (DataPermissionRule rule : ContextHolder.getRules()) { + // 判断表名是否匹配 + String tableName = MyBatisUtils.getTableName(table); + if (!rule.getTableNames().contains(tableName)) { + continue; + } + // 如果有匹配的规则,说明可重写。 + // 为什么不是有 allExpression 非空才重写呢?在生成 column = value 过滤条件时,会因为 value 不存在,导致未重写。 + // 这样导致第一次无 value,被标记成无需重写;但是第二次有 value,此时会需要重写。 + ContextHolder.setRewrite(true); + + // 单条规则的条件 + Expression oneExpress = rule.getExpression(tableName, table.getAlias()); + if (oneExpress == null){ + continue; + } + // 拼接到 allExpression 中 + allExpression = allExpression == null ? oneExpress + : new AndExpression(allExpression, oneExpress); + } + + return allExpression; + } + + /** + * 判断 SQL 是否重写。如果没有重写,则添加到 {@link MappedStatementCache} 中 + * + * @param ms MappedStatement + */ + private void addMappedStatementCache(MappedStatement ms) { + if (ContextHolder.getRewrite()) { + return; + } + // 无重写,进行添加 + mappedStatementCache.addNoRewritable(ms, ContextHolder.getRules()); + } + + /** + * SQL 解析上下文,方便透传 {@link DataPermissionRule} 规则 + * + * @author 晨丰科技 + */ + static final class ContextHolder { + + /** + * 该 {@link MappedStatement} 对应的规则 + */ + private static final ThreadLocal> RULES = ThreadLocal.withInitial(Collections::emptyList); + /** + * SQL 是否进行重写 + */ + private static final ThreadLocal REWRITE = ThreadLocal.withInitial(() -> Boolean.FALSE); + + public static void init(List rules) { + RULES.set(rules); + REWRITE.set(false); + } + + public static void clear() { + RULES.remove(); + REWRITE.remove(); + } + + public static boolean getRewrite() { + return REWRITE.get(); + } + + public static void setRewrite(boolean rewrite) { + REWRITE.set(rewrite); + } + + public static List getRules() { + return RULES.get(); + } + + } + + /** + * {@link MappedStatement} 缓存 + * 目前主要用于,记录 {@link DataPermissionRule} 是否对指定 {@link MappedStatement} 无效 + * 如果无效,则可以避免 SQL 的解析,加快速度 + * + * @author 晨丰科技 + */ + static final class MappedStatementCache { + + /** + * 指定数据权限规则,对指定 MappedStatement 无需重写(不生效)的缓存 + * + * value:{@link MappedStatement#getId()} 编号 + */ + @Getter + private final Map, Set> noRewritableMappedStatements = new ConcurrentHashMap<>(); + + /** + * 判断是否无需重写 + * ps:虽然有点中文式英语,但是容易读懂即可 + * + * @param ms MappedStatement + * @param rules 数据权限规则数组 + * @return 是否无需重写 + */ + public boolean noRewritable(MappedStatement ms, List rules) { + // 如果规则为空,说明无需重写 + if (CollUtil.isEmpty(rules)) { + return true; + } + // 任一规则不在 noRewritableMap 中,则说明可能需要重写 + for (DataPermissionRule rule : rules) { + Set mappedStatementIds = noRewritableMappedStatements.get(rule.getClass()); + if (!CollUtil.contains(mappedStatementIds, ms.getId())) { + return false; + } + } + return true; + } + + /** + * 添加无需重写的 MappedStatement + * + * @param ms MappedStatement + * @param rules 数据权限规则数组 + */ + public void addNoRewritable(MappedStatement ms, List rules) { + for (DataPermissionRule rule : rules) { + Set mappedStatementIds = noRewritableMappedStatements.get(rule.getClass()); + if (CollUtil.isNotEmpty(mappedStatementIds)) { + mappedStatementIds.add(ms.getId()); + } else { + noRewritableMappedStatements.put(rule.getClass(), SetUtils.asSet(ms.getId())); + } + } + } + + /** + * 清空缓存 + * 目前主要提供给单元测试 + */ + public void clear() { + noRewritableMappedStatements.clear(); + } + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRule.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRule.java new file mode 100644 index 000000000..ff6e6aa66 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRule.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.datapermission.core.rule; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; + +import java.util.Set; + +/** + * 数据权限规则接口 + * 通过实现接口,自定义数据规则。例如说, + * + * @author 晨丰科技 + */ +public interface DataPermissionRule { + + /** + * 返回需要生效的表名数组 + * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据 + * + * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得 + * + * @return 表名数组 + */ + Set getTableNames(); + + /** + * 根据表名和别名,生成对应的 WHERE / OR 过滤条件 + * + * @param tableName 表名 + * @param tableAlias 别名,可能为空 + * @return 过滤条件 Expression 表达式 + */ + Expression getExpression(String tableName, Alias tableAlias); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactory.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactory.java new file mode 100644 index 000000000..fa2290757 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactory.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.datapermission.core.rule; + +import java.util.List; + +/** + * {@link DataPermissionRule} 工厂接口 + * 作为 {@link DataPermissionRule} 的容器,提供管理能力 + * + * @author 晨丰科技 + */ +public interface DataPermissionRuleFactory { + + /** + * 获得所有数据权限规则数组 + * + * @return 数据权限规则数组 + */ + List getDataPermissionRules(); + + /** + * 获得指定 Mapper 的数据权限规则数组 + * + * @param mappedStatementId 指定 Mapper 的编号 + * @return 数据权限规则数组 + */ + List getDataPermissionRule(String mappedStatementId); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java new file mode 100644 index 000000000..ec006735c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java @@ -0,0 +1,62 @@ +package com.cf.imes.framework.datapermission.core.rule; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import com.cf.imes.framework.datapermission.core.aop.DataPermissionContextHolder; +import lombok.RequiredArgsConstructor; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 默认的 DataPermissionRuleFactoryImpl 实现类 + * 支持通过 {@link DataPermissionContextHolder} 过滤数据权限 + * + * @author 晨丰科技 + */ +@RequiredArgsConstructor +public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory { + + /** + * 数据权限规则数组 + */ + private final List rules; + + @Override + public List getDataPermissionRules() { + return rules; + } + + @Override // mappedStatementId 参数,暂时没有用。以后,可以基于 mappedStatementId + DataPermission 进行缓存 + public List getDataPermissionRule(String mappedStatementId) { + // 1. 无数据权限 + if (CollUtil.isEmpty(rules)) { + return Collections.emptyList(); + } + // 2. 未配置,则默认开启 + DataPermission dataPermission = DataPermissionContextHolder.get(); + if (dataPermission == null) { + return rules; + } + // 3. 已配置,但禁用 + if (!dataPermission.enable()) { + return Collections.emptyList(); + } + + // 4. 已配置,只选择部分规则 + if (ArrayUtil.isNotEmpty(dataPermission.includeRules())) { + return rules.stream().filter(rule -> ArrayUtil.contains(dataPermission.includeRules(), rule.getClass())) + .collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询 + } + // 5. 已配置,只排除部分规则 + if (ArrayUtil.isNotEmpty(dataPermission.excludeRules())) { + return rules.stream().filter(rule -> !ArrayUtil.contains(dataPermission.excludeRules(), rule.getClass())) + .collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询 + } + // 6. 已配置,全部规则 + return rules; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java new file mode 100644 index 000000000..fb1e9b3a3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java @@ -0,0 +1,205 @@ +package com.cf.imes.framework.datapermission.core.rule.dept; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.enums.UserTypeEnum; +import com.cf.imes.framework.common.util.collection.CollectionUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRule; +import com.cf.imes.framework.mybatis.core.dataobject.BaseDO; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.cf.imes.framework.security.core.LoginUser; +import com.cf.imes.framework.security.core.util.SecurityFrameworkUtils; +import com.cf.imes.module.system.api.permission.PermissionApi; +import com.cf.imes.module.system.api.permission.dto.DeptDataPermissionRespDTO; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.sf.jsqlparser.expression.*; +import net.sf.jsqlparser.expression.operators.conditional.OrExpression; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.InExpression; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 基于部门的 {@link DataPermissionRule} 数据权限规则实现 + * + * 注意,使用 DeptDataPermissionRule 时,需要保证表中有 dept_id 部门编号的字段,可自定义。 + * + * 实际业务场景下,会存在一个经典的问题?当用户修改部门时,冗余的 dept_id 是否需要修改? + * 1. 一般情况下,dept_id 不进行修改,则会导致用户看不到之前的数据。【cf-server 采用该方案】 + * 2. 部分情况下,希望该用户还是能看到之前的数据,则有两种方式解决:【需要你改造该 DeptDataPermissionRule 的实现代码】 + * 1)编写洗数据的脚本,将 dept_id 修改成新部门的编号;【建议】 + * 最终过滤条件是 WHERE dept_id = ? + * 2)洗数据的话,可能涉及的数据量较大,也可以采用 user_id 进行过滤的方式,此时需要获取到 dept_id 对应的所有 user_id 用户编号; + * 最终过滤条件是 WHERE user_id IN (?, ?, ? ...) + * 3)想要保证原 dept_id 和 user_id 都可以看的到,此时使用 dept_id 和 user_id 一起过滤; + * 最终过滤条件是 WHERE dept_id = ? OR user_id IN (?, ?, ? ...) + * + * @author 晨丰科技 + */ +@AllArgsConstructor +@Slf4j +public class DeptDataPermissionRule implements DataPermissionRule { + + /** + * LoginUser 的 Context 缓存 Key + */ + protected static final String CONTEXT_KEY = DeptDataPermissionRule.class.getSimpleName(); + + private static final String DEPT_COLUMN_NAME = "dept_id"; + private static final String USER_COLUMN_NAME = "user_id"; + + static final Expression EXPRESSION_NULL = new NullValue(); + + private final PermissionApi permissionApi; + + /** + * 基于部门的表字段配置 + * 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。 + * + * key:表名 + * value:字段名 + */ + private final Map deptColumns = new HashMap<>(); + /** + * 基于用户的表字段配置 + * 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。 + * + * key:表名 + * value:字段名 + */ + private final Map userColumns = new HashMap<>(); + /** + * 所有表名,是 {@link #deptColumns} 和 {@link #userColumns} 的合集 + */ + private final Set TABLE_NAMES = new HashSet<>(); + + @Override + public Set getTableNames() { + return TABLE_NAMES; + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + // 只有有登陆用户的情况下,才进行数据权限的处理 + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return null; + } + // 只有管理员类型的用户,才进行数据权限的处理 + if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) { + return null; + } + + // 获得数据权限 + DeptDataPermissionRespDTO deptDataPermission = loginUser.getContext(CONTEXT_KEY, DeptDataPermissionRespDTO.class); + // 从上下文中拿不到,则调用逻辑进行获取 + if (deptDataPermission == null) { + deptDataPermission = permissionApi.getDeptDataPermission(loginUser.getId()).getCheckedData(); + if (deptDataPermission == null) { + log.error("[getExpression][LoginUser({}) 获取数据权限为 null]", JsonUtils.toJsonString(loginUser)); + throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 未返回数据权限", + loginUser.getId(), tableName, tableAlias.getName())); + } + // 添加到上下文中,避免重复计算 + loginUser.setContext(CONTEXT_KEY, deptDataPermission); + } + + // 情况一,如果是 ALL 可查看全部,则无需拼接条件 + if (deptDataPermission.getAll()) { + return null; + } + + // 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限 + if (CollUtil.isEmpty(deptDataPermission.getDeptIds()) + && Boolean.FALSE.equals(deptDataPermission.getSelf())) { + return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空 + } + + // 情况三,拼接 Dept 和 User 的条件,最后组合 + Expression deptExpression = buildDeptExpression(tableName,tableAlias, deptDataPermission.getDeptIds()); + Expression userExpression = buildUserExpression(tableName, tableAlias, deptDataPermission.getSelf(), loginUser.getId()); + if (deptExpression == null && userExpression == null) { + // TODO 晨丰:获得不到条件的时候,暂时不抛出异常,而是不返回数据 + log.warn("[getExpression][LoginUser({}) Table({}/{}) DeptDataPermission({}) 构建的条件为空]", + JsonUtils.toJsonString(loginUser), tableName, tableAlias, JsonUtils.toJsonString(deptDataPermission)); +// throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 构建的条件为空", +// loginUser.getId(), tableName, tableAlias.getName())); + return EXPRESSION_NULL; + } + if (deptExpression == null) { + return userExpression; + } + if (userExpression == null) { + return deptExpression; + } + // 目前,如果有指定部门 + 可查看自己,采用 OR 条件。即,WHERE (dept_id IN ? OR user_id = ?) + return new Parenthesis(new OrExpression(deptExpression, userExpression)); + } + + private Expression buildDeptExpression(String tableName, Alias tableAlias, Set deptIds) { + // 如果不存在配置,则无需作为条件 + String columnName = deptColumns.get(tableName); + if (StrUtil.isEmpty(columnName)) { + return null; + } + // 如果为空,则无条件 + if (CollUtil.isEmpty(deptIds)) { + return null; + } + // 拼接条件 + return new InExpression(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), + new ExpressionList(CollectionUtils.convertList(deptIds, LongValue::new))); + } + + private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) { + // 如果不查看自己,则无需作为条件 + if (Boolean.FALSE.equals(self)) { + return null; + } + String columnName = userColumns.get(tableName); + if (StrUtil.isEmpty(columnName)) { + return null; + } + // 拼接条件 + return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId)); + } + + // ==================== 添加配置 ==================== + + public void addDeptColumn(Class entityClass) { + addDeptColumn(entityClass, DEPT_COLUMN_NAME); + } + + public void addDeptColumn(Class entityClass, String columnName) { + String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName(); + addDeptColumn(tableName, columnName); + } + + public void addDeptColumn(String tableName, String columnName) { + deptColumns.put(tableName, columnName); + TABLE_NAMES.add(tableName); + } + + public void addUserColumn(Class entityClass) { + addUserColumn(entityClass, USER_COLUMN_NAME); + } + + public void addUserColumn(Class entityClass, String columnName) { + String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName(); + addUserColumn(tableName, columnName); + } + + public void addUserColumn(String tableName, String columnName) { + userColumns.put(tableName, columnName); + TABLE_NAMES.add(tableName); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRuleCustomizer.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRuleCustomizer.java new file mode 100644 index 000000000..371b0981a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRuleCustomizer.java @@ -0,0 +1,20 @@ +package com.cf.imes.framework.datapermission.core.rule.dept; + +/** + * {@link DeptDataPermissionRule} 的自定义配置接口 + * + * @author 晨丰科技 + */ +@FunctionalInterface +public interface DeptDataPermissionRuleCustomizer { + + /** + * 自定义该权限规则 + * 1. 调用 {@link DeptDataPermissionRule#addDeptColumn(Class, String)} 方法,配置基于 dept_id 的过滤规则 + * 2. 调用 {@link DeptDataPermissionRule#addUserColumn(Class, String)} 方法,配置基于 user_id 的过滤规则 + * + * @param rule 权限规则 + */ + void customize(DeptDataPermissionRule rule); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/package-info.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/package-info.java new file mode 100644 index 000000000..085d4e5ed --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/rule/dept/package-info.java @@ -0,0 +1,6 @@ +/** + * 基于部门的数据权限规则 + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.datapermission.core.rule.dept; diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/util/DataPermissionUtils.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/util/DataPermissionUtils.java new file mode 100644 index 000000000..c2d3fc0d7 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/core/util/DataPermissionUtils.java @@ -0,0 +1,43 @@ +package com.cf.imes.framework.datapermission.core.util; + +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import com.cf.imes.framework.datapermission.core.aop.DataPermissionContextHolder; +import lombok.SneakyThrows; + +/** + * 数据权限 Util + * + * @author 晨丰科技 + */ +public class DataPermissionUtils { + + private static DataPermission DATA_PERMISSION_DISABLE; + + @DataPermission(enable = false) + @SneakyThrows + private static DataPermission getDisableDataPermissionDisable() { + if (DATA_PERMISSION_DISABLE == null) { + DATA_PERMISSION_DISABLE = DataPermissionUtils.class + .getDeclaredMethod("getDisableDataPermissionDisable") + .getAnnotation(DataPermission.class); + } + return DATA_PERMISSION_DISABLE; + } + + /** + * 忽略数据权限,执行对应的逻辑 + * + * @param runnable 逻辑 + */ + public static void executeIgnore(Runnable runnable) { + DataPermission dataPermission = getDisableDataPermissionDisable(); + DataPermissionContextHolder.add(dataPermission); + try { + // 执行 runnable + runnable.run(); + } finally { + DataPermissionContextHolder.remove(); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/package-info.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/package-info.java new file mode 100644 index 000000000..5c817f769 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/java/com/cf/imes/framework/datapermission/package-info.java @@ -0,0 +1,4 @@ +/** + * 基于 JSqlParser 解析 SQL,增加数据权限的 WHERE 条件 + */ +package com.cf.imes.framework.datapermission; diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..1f7eb5791 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.datapermission.config.ChenfengDataPermissionAutoConfiguration +com.cf.imes.framework.datapermission.config.ChenfengDeptDataPermissionAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java new file mode 100644 index 000000000..d751665eb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java @@ -0,0 +1,109 @@ +package com.cf.imes.framework.datapermission.core.aop; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * {@link DataPermissionAnnotationInterceptor} 的单元测试 + * + * @author 晨丰科技 + */ +public class DataPermissionAnnotationInterceptorTest extends BaseMockitoUnitTest { + + @InjectMocks + private DataPermissionAnnotationInterceptor interceptor; + + @Mock + private MethodInvocation methodInvocation; + + @BeforeEach + public void setUp() { + interceptor.getDataPermissionCache().clear(); + } + + @Test // 无 @DataPermission 注解 + public void testInvoke_none() throws Throwable { + // 参数 + mockMethodInvocation(TestNone.class); + + // 调用 + Object result = interceptor.invoke(methodInvocation); + // 断言 + assertEquals("none", result); + assertEquals(1, interceptor.getDataPermissionCache().size()); + Assertions.assertTrue(CollUtil.getFirst(interceptor.getDataPermissionCache().values()).enable()); + } + + @Test // 在 Method 上有 @DataPermission 注解 + public void testInvoke_method() throws Throwable { + // 参数 + mockMethodInvocation(TestMethod.class); + + // 调用 + Object result = interceptor.invoke(methodInvocation); + // 断言 + assertEquals("method", result); + assertEquals(1, interceptor.getDataPermissionCache().size()); + Assertions.assertFalse(CollUtil.getFirst(interceptor.getDataPermissionCache().values()).enable()); + } + + @Test // 在 Class 上有 @DataPermission 注解 + public void testInvoke_class() throws Throwable { + // 参数 + mockMethodInvocation(TestClass.class); + + // 调用 + Object result = interceptor.invoke(methodInvocation); + // 断言 + assertEquals("class", result); + assertEquals(1, interceptor.getDataPermissionCache().size()); + Assertions.assertFalse(CollUtil.getFirst(interceptor.getDataPermissionCache().values()).enable()); + } + + private void mockMethodInvocation(Class clazz) throws Throwable { + Object targetObject = clazz.newInstance(); + Method method = targetObject.getClass().getMethod("echo"); + when(methodInvocation.getThis()).thenReturn(targetObject); + when(methodInvocation.getMethod()).thenReturn(method); + when(methodInvocation.proceed()).then(invocationOnMock -> method.invoke(targetObject)); + } + + static class TestMethod { + + @DataPermission(enable = false) + public String echo() { + return "method"; + } + + } + + @DataPermission(enable = false) + static class TestClass { + + public String echo() { + return "class"; + } + + } + + static class TestNone { + + public String echo() { + return "none"; + } + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionContextHolderTest.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionContextHolderTest.java new file mode 100644 index 000000000..b47a837a6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/aop/DataPermissionContextHolderTest.java @@ -0,0 +1,66 @@ +package com.cf.imes.framework.datapermission.core.aop; + +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +/** + * {@link DataPermissionContextHolder} 的单元测试 + * + * @author 晨丰科技 + */ +class DataPermissionContextHolderTest { + + @BeforeEach + public void setUp() { + DataPermissionContextHolder.clear(); + } + + @Test + public void testGet() { + // mock 方法 + DataPermission dataPermission01 = mock(DataPermission.class); + DataPermissionContextHolder.add(dataPermission01); + DataPermission dataPermission02 = mock(DataPermission.class); + DataPermissionContextHolder.add(dataPermission02); + + // 调用 + DataPermission result = DataPermissionContextHolder.get(); + // 断言 + assertSame(result, dataPermission02); + } + + @Test + public void testPush() { + // 调用 + DataPermission dataPermission01 = mock(DataPermission.class); + DataPermissionContextHolder.add(dataPermission01); + DataPermission dataPermission02 = mock(DataPermission.class); + DataPermissionContextHolder.add(dataPermission02); + // 断言 + DataPermission first = DataPermissionContextHolder.getAll().get(0); + DataPermission second = DataPermissionContextHolder.getAll().get(1); + assertSame(dataPermission01, first); + assertSame(dataPermission02, second); + } + + @Test + public void testRemove() { + // mock 方法 + DataPermission dataPermission01 = mock(DataPermission.class); + DataPermissionContextHolder.add(dataPermission01); + DataPermission dataPermission02 = mock(DataPermission.class); + DataPermissionContextHolder.add(dataPermission02); + + // 调用 + DataPermission result = DataPermissionContextHolder.remove(); + // 断言 + assertSame(result, dataPermission02); + assertEquals(1, DataPermissionContextHolder.getAll().size()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest.java new file mode 100644 index 000000000..63d6cd7dc --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest.java @@ -0,0 +1,190 @@ +package com.cf.imes.framework.datapermission.core.db; + +import com.cf.imes.framework.common.util.collection.SetUtils; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRule; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRuleFactory; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.schema.Column; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.sql.Connection; +import java.util.*; + +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link DataPermissionDatabaseInterceptor} 的单元测试 + * 主要测试 {@link DataPermissionDatabaseInterceptor#beforePrepare(StatementHandler, Connection, Integer)} + * 和 {@link DataPermissionDatabaseInterceptor#beforeUpdate(Executor, MappedStatement, Object)} + * 以及在这个过程中,ContextHolder 和 MappedStatementCache + * + * @author 晨丰科技 + */ +public class DataPermissionDatabaseInterceptorTest extends BaseMockitoUnitTest { + + @InjectMocks + private DataPermissionDatabaseInterceptor interceptor; + + @Mock + private DataPermissionRuleFactory ruleFactory; + + @BeforeEach + public void setUp() { + // 清理上下文 + DataPermissionDatabaseInterceptor.ContextHolder.clear(); + // 清空缓存 + interceptor.getMappedStatementCache().clear(); + } + + @Test // 不存在规则,且不匹配 + public void testBeforeQuery_withoutRule() { + try (MockedStatic pluginUtilsMock = mockStatic(PluginUtils.class)) { + // 准备参数 + MappedStatement mappedStatement = mock(MappedStatement.class); + BoundSql boundSql = mock(BoundSql.class); + + // 调用 + interceptor.beforeQuery(null, mappedStatement, null, null, null, boundSql); + // 断言 + pluginUtilsMock.verify(() -> PluginUtils.mpBoundSql(boundSql), never()); + } + } + + @Test // 存在规则,且不匹配 + public void testBeforeQuery_withMatchRule() { + try (MockedStatic pluginUtilsMock = mockStatic(PluginUtils.class)) { + // 准备参数 + MappedStatement mappedStatement = mock(MappedStatement.class); + BoundSql boundSql = mock(BoundSql.class); + // mock 方法(数据权限) + when(ruleFactory.getDataPermissionRule(same(mappedStatement.getId()))) + .thenReturn(singletonList(new DeptDataPermissionRule())); + // mock 方法(MPBoundSql) + PluginUtils.MPBoundSql mpBs = mock(PluginUtils.MPBoundSql.class); + pluginUtilsMock.when(() -> PluginUtils.mpBoundSql(same(boundSql))).thenReturn(mpBs); + // mock 方法(SQL) + String sql = "select * from t_user where id = 1"; + when(mpBs.sql()).thenReturn(sql); + // 针对 ContextHolder 和 MappedStatementCache 暂时不 mock,主要想校验过程中,数据是否正确 + + // 调用 + interceptor.beforeQuery(null, mappedStatement, null, null, null, boundSql); + // 断言 + verify(mpBs, times(1)).sql( + eq("SELECT * FROM t_user WHERE id = 1 AND t_user.dept_id = 100")); + // 断言缓存 + assertTrue(interceptor.getMappedStatementCache().getNoRewritableMappedStatements().isEmpty()); + } + } + + @Test // 存在规则,但不匹配 + public void testBeforeQuery_withoutMatchRule() { + try (MockedStatic pluginUtilsMock = mockStatic(PluginUtils.class)) { + // 准备参数 + MappedStatement mappedStatement = mock(MappedStatement.class); + BoundSql boundSql = mock(BoundSql.class); + // mock 方法(数据权限) + when(ruleFactory.getDataPermissionRule(same(mappedStatement.getId()))) + .thenReturn(singletonList(new DeptDataPermissionRule())); + // mock 方法(MPBoundSql) + PluginUtils.MPBoundSql mpBs = mock(PluginUtils.MPBoundSql.class); + pluginUtilsMock.when(() -> PluginUtils.mpBoundSql(same(boundSql))).thenReturn(mpBs); + // mock 方法(SQL) + String sql = "select * from t_role where id = 1"; + when(mpBs.sql()).thenReturn(sql); + // 针对 ContextHolder 和 MappedStatementCache 暂时不 mock,主要想校验过程中,数据是否正确 + + // 调用 + interceptor.beforeQuery(null, mappedStatement, null, null, null, boundSql); + // 断言 + verify(mpBs, times(1)).sql( + eq("SELECT * FROM t_role WHERE id = 1")); + // 断言缓存 + assertFalse(interceptor.getMappedStatementCache().getNoRewritableMappedStatements().isEmpty()); + } + } + + @Test + public void testAddNoRewritable() { + // 准备参数 + MappedStatement ms = mock(MappedStatement.class); + List rules = singletonList(new DeptDataPermissionRule()); + // mock 方法 + when(ms.getId()).thenReturn("selectById"); + + // 调用 + interceptor.getMappedStatementCache().addNoRewritable(ms, rules); + // 断言 + Map, Set> noRewritableMappedStatements = + interceptor.getMappedStatementCache().getNoRewritableMappedStatements(); + assertEquals(1, noRewritableMappedStatements.size()); + assertEquals(SetUtils.asSet("selectById"), noRewritableMappedStatements.get(DeptDataPermissionRule.class)); + } + + @Test + public void testNoRewritable() { + // 准备参数 + MappedStatement ms = mock(MappedStatement.class); + // mock 方法 + when(ms.getId()).thenReturn("selectById"); + // mock 数据 + List rules = singletonList(new DeptDataPermissionRule()); + interceptor.getMappedStatementCache().addNoRewritable(ms, rules); + + // 场景一,rules 为空 + assertTrue(interceptor.getMappedStatementCache().noRewritable(ms, null)); + // 场景二,rules 非空,可重写 + assertFalse(interceptor.getMappedStatementCache().noRewritable(ms, singletonList(new EmptyDataPermissionRule()))); + // 场景三,rule 非空,不可重写 + assertTrue(interceptor.getMappedStatementCache().noRewritable(ms, rules)); + } + + private static class DeptDataPermissionRule implements DataPermissionRule { + + private static final String COLUMN = "dept_id"; + + @Override + public Set getTableNames() { + return SetUtils.asSet("t_user"); + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + Column column = MyBatisUtils.buildColumn(tableName, tableAlias, COLUMN); + LongValue value = new LongValue(100L); + return new EqualsTo(column, value); + } + + } + + private static class EmptyDataPermissionRule implements DataPermissionRule { + + @Override + public Set getTableNames() { + return Collections.emptySet(); + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + return null; + } + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest2.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest2.java new file mode 100644 index 000000000..4f68613d8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/db/DataPermissionDatabaseInterceptorTest2.java @@ -0,0 +1,533 @@ +package com.cf.imes.framework.datapermission.core.db; + +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRule; +import com.cf.imes.framework.datapermission.core.rule.DataPermissionRuleFactory; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.InExpression; +import net.sf.jsqlparser.schema.Column; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Set; + +import static com.cf.imes.framework.common.util.collection.SetUtils.asSet; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link DataPermissionDatabaseInterceptor} 的单元测试 + * 主要复用了 MyBatis Plus 的 TenantLineInnerInterceptorTest 的单元测试 + * 不过它的单元测试不是很规范,考虑到是复用的,所以暂时不进行修改~ + * + * @author 晨丰科技 + */ +public class DataPermissionDatabaseInterceptorTest2 extends BaseMockitoUnitTest { + + @InjectMocks + private DataPermissionDatabaseInterceptor interceptor; + + @Mock + private DataPermissionRuleFactory ruleFactory; + + @BeforeEach + public void setUp() { + // 组织的数据权限规则 + DataPermissionRule tenantRule = new DataPermissionRule() { + + private static final String COLUMN = "organ_id"; + + @Override + public Set getTableNames() { + return asSet("entity", "entity1", "entity2", "entity3", "t1", "t2", "sys_dict_item", // 支持 MyBatis Plus 的单元测试 + "t_user", "t_role"); // 满足自己的单元测试 + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + Column column = MyBatisUtils.buildColumn(tableName, tableAlias, COLUMN); + LongValue value = new LongValue(1L); + return new EqualsTo(column, value); + } + + }; + // 部门的数据权限规则 + DataPermissionRule deptRule = new DataPermissionRule() { + + private static final String COLUMN = "dept_id"; + + @Override + public Set getTableNames() { + return asSet("t_user"); // 满足自己的单元测试 + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + Column column = MyBatisUtils.buildColumn(tableName, tableAlias, COLUMN); + ExpressionList values = new ExpressionList(new LongValue(10L), + new LongValue(20L)); + return new InExpression(column, values); + } + + }; + // 设置到上下文,保证 + DataPermissionDatabaseInterceptor.ContextHolder.init(Arrays.asList(tenantRule, deptRule)); + } + + @Test + void delete() { + assertSql("delete from entity where id = ?", + "DELETE FROM entity WHERE id = ? AND entity.tenant_id = 1"); + } + + @Test + void update() { + assertSql("update entity set name = ? where id = ?", + "UPDATE entity SET name = ? WHERE id = ? AND entity.tenant_id = 1"); + } + + @Test + void selectSingle() { + // 单表 + assertSql("select * from entity where id = ?", + "SELECT * FROM entity WHERE id = ? AND entity.tenant_id = 1"); + + assertSql("select * from entity where id = ? or name = ?", + "SELECT * FROM entity WHERE (id = ? OR name = ?) AND entity.tenant_id = 1"); + + assertSql("SELECT * FROM entity WHERE (id = ? OR name = ?)", + "SELECT * FROM entity WHERE (id = ? OR name = ?) AND entity.tenant_id = 1"); + + /* not */ + assertSql("SELECT * FROM entity WHERE not (id = ? OR name = ?)", + "SELECT * FROM entity WHERE NOT (id = ? OR name = ?) AND entity.tenant_id = 1"); + } + + @Test + void selectSubSelectIn() { + /* in */ + assertSql("SELECT * FROM entity e WHERE e.id IN (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE e.id IN (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + // 在最前 + assertSql("SELECT * FROM entity e WHERE e.id IN " + + "(select e1.id from entity1 e1 where e1.id = ?) and e.id = ?", + "SELECT * FROM entity e WHERE e.id IN " + + "(SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.id = ? AND e.tenant_id = 1"); + // 在最后 + assertSql("SELECT * FROM entity e WHERE e.id = ? and e.id IN " + + "(select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE e.id = ? AND e.id IN " + + "(SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + // 在中间 + assertSql("SELECT * FROM entity e WHERE e.id = ? and e.id IN " + + "(select e1.id from entity1 e1 where e1.id = ?) and e.id = ?", + "SELECT * FROM entity e WHERE e.id = ? AND e.id IN " + + "(SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.id = ? AND e.tenant_id = 1"); + } + + @Test + void selectSubSelectEq() { + /* = */ + assertSql("SELECT * FROM entity e WHERE e.id = (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + } + + @Test + void selectSubSelectInnerNotEq() { + /* inner not = */ + assertSql("SELECT * FROM entity e WHERE not (e.id = (select e1.id from entity1 e1 where e1.id = ?))", + "SELECT * FROM entity e WHERE NOT (e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1)) AND e.tenant_id = 1"); + + assertSql("SELECT * FROM entity e WHERE not (e.id = (select e1.id from entity1 e1 where e1.id = ?) and e.id = ?)", + "SELECT * FROM entity e WHERE NOT (e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.id = ?) AND e.tenant_id = 1"); + } + + @Test + void selectSubSelectExists() { + /* EXISTS */ + assertSql("SELECT * FROM entity e WHERE EXISTS (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE EXISTS (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + + + /* NOT EXISTS */ + assertSql("SELECT * FROM entity e WHERE NOT EXISTS (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE NOT EXISTS (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + } + + @Test + void selectSubSelect() { + /* >= */ + assertSql("SELECT * FROM entity e WHERE e.id >= (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE e.id >= (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + + + /* <= */ + assertSql("SELECT * FROM entity e WHERE e.id <= (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE e.id <= (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + + + /* <> */ + assertSql("SELECT * FROM entity e WHERE e.id <> (select e1.id from entity1 e1 where e1.id = ?)", + "SELECT * FROM entity e WHERE e.id <> (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1"); + } + + @Test + void selectFromSelect() { + assertSql("SELECT * FROM (select e.id from entity e WHERE e.id = (select e1.id from entity1 e1 where e1.id = ?))", + "SELECT * FROM (SELECT e.id FROM entity e WHERE e.id = (SELECT e1.id FROM entity1 e1 WHERE e1.id = ? AND e1.tenant_id = 1) AND e.tenant_id = 1)"); + } + + @Test + void selectBodySubSelect() { + assertSql("select t1.col1,(select t2.col2 from t2 t2 where t1.col1=t2.col1) from t1 t1", + "SELECT t1.col1, (SELECT t2.col2 FROM t2 t2 WHERE t1.col1 = t2.col1 AND t2.tenant_id = 1) FROM t1 t1 WHERE t1.tenant_id = 1"); + } + + @Test + void selectLeftJoin() { + // left join + assertSql("SELECT * FROM entity e " + + "left join entity1 e1 on e1.id = e.id " + + "WHERE e.id = ? OR e.name = ?", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "left join entity1 e1 on e1.id = e.id " + + "WHERE (e.id = ? OR e.name = ?)", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "left join entity1 e1 on e1.id = e.id " + + "left join entity2 e2 on e1.id = e2.id", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1 " + + "WHERE e.tenant_id = 1"); + } + + @Test + void selectRightJoin() { + // right join + assertSql("SELECT * FROM entity e " + + "right join entity1 e1 on e1.id = e.id", + "SELECT * FROM entity e " + + "RIGHT JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 " + + "WHERE e1.tenant_id = 1"); + + assertSql("SELECT * FROM with_as_1 e " + + "right join entity1 e1 on e1.id = e.id", + "SELECT * FROM with_as_1 e " + + "RIGHT JOIN entity1 e1 ON e1.id = e.id " + + "WHERE e1.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "right join entity1 e1 on e1.id = e.id " + + "WHERE e.id = ? OR e.name = ?", + "SELECT * FROM entity e " + + "RIGHT JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?) AND e1.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "right join entity1 e1 on e1.id = e.id " + + "right join entity2 e2 on e1.id = e2.id ", + "SELECT * FROM entity e " + + "RIGHT JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 " + + "RIGHT JOIN entity2 e2 ON e1.id = e2.id AND e1.tenant_id = 1 " + + "WHERE e2.tenant_id = 1"); + } + + @Test + void selectMixJoin() { + assertSql("SELECT * FROM entity e " + + "right join entity1 e1 on e1.id = e.id " + + "left join entity2 e2 on e1.id = e2.id", + "SELECT * FROM entity e " + + "RIGHT JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 " + + "LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1 " + + "WHERE e1.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "left join entity1 e1 on e1.id = e.id " + + "right join entity2 e2 on e1.id = e2.id", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "RIGHT JOIN entity2 e2 ON e1.id = e2.id AND e1.tenant_id = 1 " + + "WHERE e2.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "left join entity1 e1 on e1.id = e.id " + + "inner join entity2 e2 on e1.id = e2.id", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "INNER JOIN entity2 e2 ON e1.id = e2.id AND e.tenant_id = 1 AND e2.tenant_id = 1"); + } + + + @Test + void selectJoinSubSelect() { + assertSql("select * from (select * from entity) e1 " + + "left join entity2 e2 on e1.id = e2.id", + "SELECT * FROM (SELECT * FROM entity WHERE entity.tenant_id = 1) e1 " + + "LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1"); + + assertSql("select * from entity1 e1 " + + "left join (select * from entity2) e2 " + + "on e1.id = e2.id", + "SELECT * FROM entity1 e1 " + + "LEFT JOIN (SELECT * FROM entity2 WHERE entity2.tenant_id = 1) e2 " + + "ON e1.id = e2.id " + + "WHERE e1.tenant_id = 1"); + } + + @Test + void selectSubJoin() { + + assertSql("select * FROM " + + "(entity1 e1 right JOIN entity2 e2 ON e1.id = e2.id)", + "SELECT * FROM " + + "(entity1 e1 RIGHT JOIN entity2 e2 ON e1.id = e2.id AND e1.tenant_id = 1) " + + "WHERE e2.tenant_id = 1"); + + assertSql("select * FROM " + + "(entity1 e1 LEFT JOIN entity2 e2 ON e1.id = e2.id)", + "SELECT * FROM " + + "(entity1 e1 LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1) " + + "WHERE e1.tenant_id = 1"); + + + assertSql("select * FROM " + + "(entity1 e1 LEFT JOIN entity2 e2 ON e1.id = e2.id) " + + "right join entity3 e3 on e1.id = e3.id", + "SELECT * FROM " + + "(entity1 e1 LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1) " + + "RIGHT JOIN entity3 e3 ON e1.id = e3.id AND e1.tenant_id = 1 " + + "WHERE e3.tenant_id = 1"); + + + assertSql("select * FROM entity e " + + "LEFT JOIN (entity1 e1 right join entity2 e2 ON e1.id = e2.id) " + + "on e.id = e2.id", + "SELECT * FROM entity e " + + "LEFT JOIN (entity1 e1 RIGHT JOIN entity2 e2 ON e1.id = e2.id AND e1.tenant_id = 1) " + + "ON e.id = e2.id AND e2.tenant_id = 1 " + + "WHERE e.tenant_id = 1"); + + assertSql("select * FROM entity e " + + "LEFT JOIN (entity1 e1 left join entity2 e2 ON e1.id = e2.id) " + + "on e.id = e2.id", + "SELECT * FROM entity e " + + "LEFT JOIN (entity1 e1 LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1) " + + "ON e.id = e2.id AND e1.tenant_id = 1 " + + "WHERE e.tenant_id = 1"); + + assertSql("select * FROM entity e " + + "RIGHT JOIN (entity1 e1 left join entity2 e2 ON e1.id = e2.id) " + + "on e.id = e2.id", + "SELECT * FROM entity e " + + "RIGHT JOIN (entity1 e1 LEFT JOIN entity2 e2 ON e1.id = e2.id AND e2.tenant_id = 1) " + + "ON e.id = e2.id AND e.tenant_id = 1 " + + "WHERE e1.tenant_id = 1"); + } + + + @Test + void selectLeftJoinMultipleTrailingOn() { + // 多个 on 尾缀的 + assertSql("SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 " + + "LEFT JOIN entity2 e2 ON e2.id = e1.id " + + "ON e1.id = e.id " + + "WHERE (e.id = ? OR e.NAME = ?)", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 " + + "LEFT JOIN entity2 e2 ON e2.id = e1.id AND e2.tenant_id = 1 " + + "ON e1.id = e.id AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.NAME = ?) AND e.tenant_id = 1"); + + assertSql("SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 " + + "LEFT JOIN with_as_A e2 ON e2.id = e1.id " + + "ON e1.id = e.id " + + "WHERE (e.id = ? OR e.NAME = ?)", + "SELECT * FROM entity e " + + "LEFT JOIN entity1 e1 " + + "LEFT JOIN with_as_A e2 ON e2.id = e1.id " + + "ON e1.id = e.id AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.NAME = ?) AND e.tenant_id = 1"); + } + + @Test + void selectInnerJoin() { + // inner join + assertSql("SELECT * FROM entity e " + + "inner join entity1 e1 on e1.id = e.id " + + "WHERE e.id = ? OR e.name = ?", + "SELECT * FROM entity e " + + "INNER JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 AND e1.tenant_id = 1 " + + "WHERE e.id = ? OR e.name = ?"); + + assertSql("SELECT * FROM entity e " + + "inner join entity1 e1 on e1.id = e.id " + + "WHERE (e.id = ? OR e.name = ?)", + "SELECT * FROM entity e " + + "INNER JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?)"); + + // 隐式内连接 + assertSql("SELECT * FROM entity,entity1 " + + "WHERE entity.id = entity1.id", + "SELECT * FROM entity, entity1 " + + "WHERE entity.id = entity1.id AND entity.tenant_id = 1 AND entity1.tenant_id = 1"); + + // 隐式内连接 + assertSql("SELECT * FROM entity a, with_as_entity1 b " + + "WHERE a.id = b.id", + "SELECT * FROM entity a, with_as_entity1 b " + + "WHERE a.id = b.id AND a.tenant_id = 1"); + + assertSql("SELECT * FROM with_as_entity a, with_as_entity1 b " + + "WHERE a.id = b.id", + "SELECT * FROM with_as_entity a, with_as_entity1 b " + + "WHERE a.id = b.id"); + + // SubJoin with 隐式内连接 + assertSql("SELECT * FROM (entity,entity1) " + + "WHERE entity.id = entity1.id", + "SELECT * FROM (entity, entity1) " + + "WHERE entity.id = entity1.id " + + "AND entity.tenant_id = 1 AND entity1.tenant_id = 1"); + + assertSql("SELECT * FROM ((entity,entity1),entity2) " + + "WHERE entity.id = entity1.id and entity.id = entity2.id", + "SELECT * FROM ((entity, entity1), entity2) " + + "WHERE entity.id = entity1.id AND entity.id = entity2.id " + + "AND entity.tenant_id = 1 AND entity1.tenant_id = 1 AND entity2.tenant_id = 1"); + + assertSql("SELECT * FROM (entity,(entity1,entity2)) " + + "WHERE entity.id = entity1.id and entity.id = entity2.id", + "SELECT * FROM (entity, (entity1, entity2)) " + + "WHERE entity.id = entity1.id AND entity.id = entity2.id " + + "AND entity.tenant_id = 1 AND entity1.tenant_id = 1 AND entity2.tenant_id = 1"); + + // 沙雕的括号写法 + assertSql("SELECT * FROM (((entity,entity1))) " + + "WHERE entity.id = entity1.id", + "SELECT * FROM (((entity, entity1))) " + + "WHERE entity.id = entity1.id " + + "AND entity.tenant_id = 1 AND entity1.tenant_id = 1"); + + } + + + @Test + void selectWithAs() { + assertSql("with with_as_A as (select * from entity) select * from with_as_A", + "WITH with_as_A AS (SELECT * FROM entity WHERE entity.tenant_id = 1) SELECT * FROM with_as_A"); + } + + + @Test + void selectIgnoreTable() { + assertSql(" SELECT dict.dict_code, item.item_text AS \"text\", item.item_value AS \"value\" FROM sys_dict_item item INNER JOIN sys_dict dict ON dict.id = item.dict_id WHERE dict.dict_code IN (1, 2, 3) AND item.item_value IN (1, 2, 3)", + "SELECT dict.dict_code, item.item_text AS \"text\", item.item_value AS \"value\" FROM sys_dict_item item INNER JOIN sys_dict dict ON dict.id = item.dict_id AND item.tenant_id = 1 WHERE dict.dict_code IN (1, 2, 3) AND item.item_value IN (1, 2, 3)"); + } + + private void assertSql(String sql, String targetSql) { + assertEquals(targetSql, interceptor.parserSingle(sql, null)); + } + + + // ========== 额外的测试 ========== + + @Test + public void testSelectSingle() { + // 单表 + assertSql("select * from t_user where id = ?", + "SELECT * FROM t_user WHERE id = ? AND t_user.tenant_id = 1 AND t_user.dept_id IN (10, 20)"); + + assertSql("select * from t_user where id = ? or name = ?", + "SELECT * FROM t_user WHERE (id = ? OR name = ?) AND t_user.tenant_id = 1 AND t_user.dept_id IN (10, 20)"); + + assertSql("SELECT * FROM t_user WHERE (id = ? OR name = ?)", + "SELECT * FROM t_user WHERE (id = ? OR name = ?) AND t_user.tenant_id = 1 AND t_user.dept_id IN (10, 20)"); + + /* not */ + assertSql("SELECT * FROM t_user WHERE not (id = ? OR name = ?)", + "SELECT * FROM t_user WHERE NOT (id = ? OR name = ?) AND t_user.tenant_id = 1 AND t_user.dept_id IN (10, 20)"); + } + + @Test + public void testSelectLeftJoin() { + // left join + assertSql("SELECT * FROM t_user e " + + "left join t_role e1 on e1.id = e.id " + + "WHERE e.id = ? OR e.name = ?", + "SELECT * FROM t_user e " + + "LEFT JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)"); + + // 条件 e.id = ? OR e.name = ? 带括号 + assertSql("SELECT * FROM t_user e " + + "left join t_role e1 on e1.id = e.id " + + "WHERE (e.id = ? OR e.name = ?)", + "SELECT * FROM t_user e " + + "LEFT JOIN t_role e1 ON e1.id = e.id AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?) AND e.tenant_id = 1 AND e.dept_id IN (10, 20)"); + } + + @Test + public void testSelectRightJoin() { + // right join + assertSql("SELECT * FROM t_user e " + + "right join t_role e1 on e1.id = e.id " + + "WHERE e.id = ? OR e.name = ?", + "SELECT * FROM t_user e " + + "RIGHT JOIN t_role e1 ON e1.id = e.id AND e.tenant_id = 1 AND e.dept_id IN (10, 20) " + + "WHERE (e.id = ? OR e.name = ?) AND e1.tenant_id = 1"); + + // 条件 e.id = ? OR e.name = ? 带括号 + assertSql("SELECT * FROM t_user e " + + "right join t_role e1 on e1.id = e.id " + + "WHERE (e.id = ? OR e.name = ?)", + "SELECT * FROM t_user e " + + "RIGHT JOIN t_role e1 ON e1.id = e.id AND e.tenant_id = 1 AND e.dept_id IN (10, 20) " + + "WHERE (e.id = ? OR e.name = ?) AND e1.tenant_id = 1"); + } + + @Test + public void testSelectInnerJoin() { + // inner join + assertSql("SELECT * FROM t_user e " + + "inner join entity1 e1 on e1.id = e.id " + + "WHERE e.id = ? OR e.name = ?", + "SELECT * FROM t_user e " + + "INNER JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 AND e.dept_id IN (10, 20) AND e1.tenant_id = 1 " + + "WHERE e.id = ? OR e.name = ?"); + + // 条件 e.id = ? OR e.name = ? 带括号 + assertSql("SELECT * FROM t_user e " + + "inner join entity1 e1 on e1.id = e.id " + + "WHERE (e.id = ? OR e.name = ?)", + "SELECT * FROM t_user e " + + "INNER JOIN entity1 e1 ON e1.id = e.id AND e.tenant_id = 1 AND e.dept_id IN (10, 20) AND e1.tenant_id = 1 " + + "WHERE (e.id = ? OR e.name = ?)"); + + // 没有 On 的 inner join + assertSql("SELECT * FROM entity,entity1 " + + "WHERE entity.id = entity1.id", + "SELECT * FROM entity, entity1 " + + "WHERE entity.id = entity1.id AND entity.tenant_id = 1 AND entity1.tenant_id = 1"); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java new file mode 100644 index 000000000..a3f89426c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java @@ -0,0 +1,145 @@ +package com.cf.imes.framework.datapermission.core.rule; + +import com.cf.imes.framework.datapermission.core.annotation.DataPermission; +import com.cf.imes.framework.datapermission.core.aop.DataPermissionContextHolder; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Spy; +import org.springframework.core.annotation.AnnotationUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import static com.cf.imes.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DataPermissionRuleFactoryImpl} 单元测试 + * + * @author 晨丰科技 + */ +class DataPermissionRuleFactoryImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private DataPermissionRuleFactoryImpl dataPermissionRuleFactory; + + @Spy + private List rules = Arrays.asList(new DataPermissionRule01(), + new DataPermissionRule02()); + + @BeforeEach + public void setUp() { + DataPermissionContextHolder.clear(); + } + + @Test + public void testGetDataPermissionRule_02() { + // 准备参数 + String mappedStatementId = randomString(); + + // 调用 + List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId); + // 断言 + assertSame(rules, result); + } + + @Test + public void testGetDataPermissionRule_03() { + // 准备参数 + String mappedStatementId = randomString(); + // mock 方法 + DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass03.class, DataPermission.class)); + + // 调用 + List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId); + // 断言 + assertTrue(result.isEmpty()); + } + + @Test + public void testGetDataPermissionRule_04() { + // 准备参数 + String mappedStatementId = randomString(); + // mock 方法 + DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass04.class, DataPermission.class)); + + // 调用 + List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId); + // 断言 + assertEquals(1, result.size()); + assertEquals(DataPermissionRule01.class, result.get(0).getClass()); + } + + @Test + public void testGetDataPermissionRule_05() { + // 准备参数 + String mappedStatementId = randomString(); + // mock 方法 + DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass05.class, DataPermission.class)); + + // 调用 + List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId); + // 断言 + assertEquals(1, result.size()); + assertEquals(DataPermissionRule02.class, result.get(0).getClass()); + } + + @Test + public void testGetDataPermissionRule_06() { + // 准备参数 + String mappedStatementId = randomString(); + // mock 方法 + DataPermissionContextHolder.add(AnnotationUtils.findAnnotation(TestClass06.class, DataPermission.class)); + + // 调用 + List result = dataPermissionRuleFactory.getDataPermissionRule(mappedStatementId); + // 断言 + assertSame(rules, result); + } + + @DataPermission(enable = false) + static class TestClass03 {} + + @DataPermission(includeRules = DataPermissionRule01.class) + static class TestClass04 {} + + @DataPermission(excludeRules = DataPermissionRule01.class) + static class TestClass05 {} + + @DataPermission + static class TestClass06 {} + + static class DataPermissionRule01 implements DataPermissionRule { + + @Override + public Set getTableNames() { + return null; + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + return null; + } + + } + + static class DataPermissionRule02 implements DataPermissionRule { + + @Override + public Set getTableNames() { + return null; + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + return null; + } + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java new file mode 100644 index 000000000..cc8ad86ff --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java @@ -0,0 +1,239 @@ +package com.cf.imes.framework.datapermission.core.rule.dept; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.common.enums.UserTypeEnum; +import com.cf.imes.framework.common.util.collection.SetUtils; +import com.cf.imes.framework.security.core.LoginUser; +import com.cf.imes.framework.security.core.util.SecurityFrameworkUtils; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import com.cf.imes.module.system.api.permission.PermissionApi; +import com.cf.imes.module.system.api.permission.dto.DeptDataPermissionRespDTO; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.Map; + +import static com.cf.imes.framework.common.pojo.CommonResult.success; +import static com.cf.imes.framework.datapermission.core.rule.dept.DeptDataPermissionRule.EXPRESSION_NULL; +import static com.cf.imes.framework.test.core.util.RandomUtils.randomPojo; +import static com.cf.imes.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * {@link DeptDataPermissionRule} 的单元测试 + * + * @author 晨丰科技 + */ +class DeptDataPermissionRuleTest extends BaseMockitoUnitTest { + + @InjectMocks + private DeptDataPermissionRule rule; + + @Mock + private PermissionApi permissionApi; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + // 清空 rule + rule.getTableNames().clear(); + ((Map) ReflectUtil.getFieldValue(rule, "deptColumns")).clear(); + ((Map) ReflectUtil.getFieldValue(rule, "deptColumns")).clear(); + } + + @Test // 无 LoginUser + public void testGetExpression_noLoginUser() { + // 准备参数 + String tableName = randomString(); + Alias tableAlias = new Alias(randomString()); + // mock 方法 + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertNull(expression); + } + + @Test // 无数据权限时 + public void testGetExpression_noDeptDataPermission() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法 + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(permissionApi 返回 null) + when(permissionApi.getDeptDataPermission(eq(loginUser.getId()))).thenReturn(success(null)); + + // 调用 + NullPointerException exception = assertThrows(NullPointerException.class, + () -> rule.getExpression(tableName, tableAlias)); + // 断言 + assertEquals("LoginUser(1) Table(t_user/u) 未返回数据权限", exception.getMessage()); + } + } + + @Test // 全部数据权限 + public void testGetExpression_allDeptDataPermission() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法(LoginUser) + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(DeptDataPermissionRespDTO) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO().setAll(true); + when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission)); + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertNull(expression); + assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); + } + } + + @Test // 即不能查看部门,又不能查看自己,则说明 100% 无权限 + public void testGetExpression_noDept_noSelf() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法(LoginUser) + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(DeptDataPermissionRespDTO) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission)); + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertEquals("null = null", expression.toString()); + assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); + } + } + + @Test // 拼接 Dept 和 User 的条件(字段都不符合) + public void testGetExpression_noDeptColumn_noSelfColumn() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法(LoginUser) + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(DeptDataPermissionRespDTO) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO() + .setDeptIds(SetUtils.asSet(10L, 20L)).setSelf(true); + when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission)); + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertSame(EXPRESSION_NULL, expression); + assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); + } + } + + @Test // 拼接 Dept 和 User 的条件(self 符合) + public void testGetExpression_noDeptColumn_yesSelfColumn() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法(LoginUser) + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(DeptDataPermissionRespDTO) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO() + .setSelf(true); + when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission)); + // 添加 user 字段配置 + rule.addUserColumn("t_user", "id"); + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertEquals("u.id = 1", expression.toString()); + assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); + } + } + + @Test // 拼接 Dept 和 User 的条件(dept 符合) + public void testGetExpression_yesDeptColumn_noSelfColumn() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法(LoginUser) + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(DeptDataPermissionRespDTO) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO() + .setDeptIds(CollUtil.newLinkedHashSet(10L, 20L)); + when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission)); + // 添加 dept 字段配置 + rule.addDeptColumn("t_user", "dept_id"); + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertEquals("u.dept_id IN (10, 20)", expression.toString()); + assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); + } + } + + @Test // 拼接 Dept 和 User 的条件(dept + self 符合) + public void testGetExpression_yesDeptColumn_yesSelfColumn() { + try (MockedStatic securityFrameworkUtilsMock + = mockStatic(SecurityFrameworkUtils.class)) { + // 准备参数 + String tableName = "t_user"; + Alias tableAlias = new Alias("u"); + // mock 方法(LoginUser) + LoginUser loginUser = randomPojo(LoginUser.class, o -> o.setId(1L) + .setUserType(UserTypeEnum.ADMIN.getValue())); + securityFrameworkUtilsMock.when(SecurityFrameworkUtils::getLoginUser).thenReturn(loginUser); + // mock 方法(DeptDataPermissionRespDTO) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO() + .setDeptIds(CollUtil.newLinkedHashSet(10L, 20L)).setSelf(true); + when(permissionApi.getDeptDataPermission(same(1L))).thenReturn(success(deptDataPermission)); + // 添加 user 字段配置 + rule.addUserColumn("t_user", "id"); + // 添加 dept 字段配置 + rule.addDeptColumn("t_user", "dept_id"); + + // 调用 + Expression expression = rule.getExpression(tableName, tableAlias); + // 断言 + assertEquals("(u.dept_id IN (10, 20) OR u.id = 1)", expression.toString()); + assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/util/DataPermissionUtilsTest.java b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/util/DataPermissionUtilsTest.java new file mode 100644 index 000000000..dd443120b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-data-permission/src/test/java/com/cf/imes/framework/datapermission/core/util/DataPermissionUtilsTest.java @@ -0,0 +1,15 @@ +package com.cf.imes.framework.datapermission.core.util; + +import com.cf.imes.framework.datapermission.core.aop.DataPermissionContextHolder; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class DataPermissionUtilsTest { + + @Test + public void testExecuteIgnore() { + DataPermissionUtils.executeIgnore(() -> assertFalse(DataPermissionContextHolder.get().enable())); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/pom.xml b/cf-framework/cf-spring-boot-starter-biz-dict/pom.xml new file mode 100644 index 000000000..480e0d3e6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/pom.xml @@ -0,0 +1,56 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-dict + jar + + ${project.artifactId} + 字典类型、数据 + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter + + + + + com.cf.imes + cf-spring-boot-starter-rpc + true + + + + + com.cf.imes + cf-module-system-api + ${revision} + + + + + com.google.guava + guava + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/config/ChenfengDictAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/config/ChenfengDictAutoConfiguration.java new file mode 100644 index 000000000..c61135f2d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/config/ChenfengDictAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.cf.imes.framework.dict.config; + +import com.cf.imes.framework.dict.core.util.DictFrameworkUtils; +import com.cf.imes.module.system.api.dict.DictDataApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class ChenfengDictAutoConfiguration { + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public DictFrameworkUtils dictUtils(DictDataApi dictDataApi) { + DictFrameworkUtils.init(dictDataApi); + return new DictFrameworkUtils(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/config/ChenfengDictRpcAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/config/ChenfengDictRpcAutoConfiguration.java new file mode 100644 index 000000000..f75d1187e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/config/ChenfengDictRpcAutoConfiguration.java @@ -0,0 +1,15 @@ +package com.cf.imes.framework.dict.config; + +import com.cf.imes.module.system.api.dict.DictDataApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 字典用到 Feign 的配置项 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@EnableFeignClients(clients = DictDataApi.class) // 主要是引入相关的 API 服务 +public class ChenfengDictRpcAutoConfiguration { +} diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/core/package-info.java b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/core/package-info.java new file mode 100644 index 000000000..3389c72fe --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位 + */ +package com.cf.imes.framework.dict.core; diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/core/util/DictFrameworkUtils.java b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/core/util/DictFrameworkUtils.java new file mode 100644 index 000000000..53988e89b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/core/util/DictFrameworkUtils.java @@ -0,0 +1,77 @@ +package com.cf.imes.framework.dict.core.util; + +import cn.hutool.core.util.ObjectUtil; +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.common.util.cache.CacheUtils; +import com.cf.imes.module.system.api.dict.DictDataApi; +import com.cf.imes.module.system.api.dict.dto.DictDataRespDTO; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; + +/** + * 字典工具类 + * + * @author 晨丰科技 + */ +@Slf4j +public class DictFrameworkUtils { + + private static DictDataApi dictDataApi; + + private static final DictDataRespDTO DICT_DATA_NULL = new DictDataRespDTO(); + + /** + * 针对 {@link #getDictDataLabel(String, String)} 的缓存 + */ + private static final LoadingCache, DictDataRespDTO> GET_DICT_DATA_CACHE = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader, DictDataRespDTO>() { + + @Override + public DictDataRespDTO load(KeyValue key) { + return ObjectUtil.defaultIfNull(dictDataApi.getDictData(key.getKey(), key.getValue()).getCheckedData(), + DICT_DATA_NULL); + } + + }); + + /** + * 针对 {@link #parseDictDataValue(String, String)} 的缓存 + */ + private static final LoadingCache, DictDataRespDTO> PARSE_DICT_DATA_CACHE = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader, DictDataRespDTO>() { + + @Override + public DictDataRespDTO load(KeyValue key) { + return ObjectUtil.defaultIfNull(dictDataApi.parseDictData(key.getKey(), key.getValue()).getCheckedData(), + DICT_DATA_NULL); + } + + }); + + public static void init(DictDataApi dictDataApi) { + DictFrameworkUtils.dictDataApi = dictDataApi; + log.info("[init][初始化 DictFrameworkUtils 成功]"); + } + + @SneakyThrows + public static String getDictDataLabel(String dictType, Integer value) { + return GET_DICT_DATA_CACHE.get(new KeyValue<>(dictType, String.valueOf(value))).getLabel(); + } + + @SneakyThrows + public static String getDictDataLabel(String dictType, String value) { + return GET_DICT_DATA_CACHE.get(new KeyValue<>(dictType, value)).getLabel(); + } + + @SneakyThrows + public static String parseDictDataValue(String dictType, String label) { + return PARSE_DICT_DATA_CACHE.get(new KeyValue<>(dictType, label)).getValue(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/package-info.java b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/package-info.java new file mode 100644 index 000000000..e343fd9b4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/java/com/cf/imes/framework/dict/package-info.java @@ -0,0 +1,6 @@ +/** + * 字典数据模块,提供 {@link com.cf.imes.framework.dict.core.util.DictFrameworkUtils} 工具类 + * + * 通过将字典缓存在内存中,保证性能 + */ +package com.cf.imes.framework.dict; diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..0549de6e6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.dict.config.ChenfengDictRpcAutoConfiguration +com.cf.imes.framework.dict.config.ChenfengDictAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-dict/src/test/java/com/cf/imes/framework/dict/core/util/DictFrameworkUtilsTest.java b/cf-framework/cf-spring-boot-starter-biz-dict/src/test/java/com/cf/imes/framework/dict/core/util/DictFrameworkUtilsTest.java new file mode 100644 index 000000000..e89a86763 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-dict/src/test/java/com/cf/imes/framework/dict/core/util/DictFrameworkUtilsTest.java @@ -0,0 +1,49 @@ +package com.cf.imes.framework.dict.core.util; + +import com.cf.imes.framework.common.enums.CommonStatusEnum; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import com.cf.imes.module.system.api.dict.DictDataApi; +import com.cf.imes.module.system.api.dict.dto.DictDataRespDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import static com.cf.imes.framework.common.pojo.CommonResult.success; +import static com.cf.imes.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * {@link DictFrameworkUtils} 的单元测试 + */ +public class DictFrameworkUtilsTest extends BaseMockitoUnitTest { + + @Mock + private DictDataApi dictDataApi; + + @BeforeEach + public void setUp() { + DictFrameworkUtils.init(dictDataApi); + } + + @Test + public void testGetDictDataLabel() { + // mock 数据 + DictDataRespDTO dataRespDTO = randomPojo(DictDataRespDTO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + // mock 方法 + when(dictDataApi.getDictData(dataRespDTO.getDictType(), dataRespDTO.getValue())).thenReturn(success(dataRespDTO)); + // 断言返回值 + assertEquals(dataRespDTO.getLabel(), DictFrameworkUtils.getDictDataLabel(dataRespDTO.getDictType(), dataRespDTO.getValue())); + } + + @Test + public void testParseDictDataValue() { + // mock 数据 + DictDataRespDTO resp = randomPojo(DictDataRespDTO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + // mock 方法 + when(dictDataApi.parseDictData(resp.getDictType(), resp.getLabel())).thenReturn(success(resp)); + // 断言返回值 + assertEquals(resp.getValue(), DictFrameworkUtils.parseDictDataValue(resp.getDictType(), resp.getLabel())); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/pom.xml b/cf-framework/cf-spring-boot-starter-biz-error-code/pom.xml new file mode 100644 index 000000000..3ac2c4519 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/pom.xml @@ -0,0 +1,55 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-error-code + jar + + ${project.artifactId} + + 错误码 ErrorCode 的自动配置功能,提供如下功能: + 1. 远程读取:项目启动时,从 system-server 服务,读取数据库中的 ErrorCode 错误码,实现错误码的提示可配置; + 2. 自动更新:管理员在管理后台修数据库中的 ErrorCode 错误码时,项目自动从 system-server 服务加载最新的 ErrorCode 错误码; + 3. 自动写入:项目启动时,将项目本地的错误码写到 system-server 服务中,方便管理员在管理后台编辑; + + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter + + + + + com.cf.imes + cf-spring-boot-starter-rpc + true + + + + + com.cf.imes + cf-module-system-api + ${revision} + + + + jakarta.validation + jakarta.validation-api + provided + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ChenfengErrorCodeAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ChenfengErrorCodeAutoConfiguration.java new file mode 100644 index 000000000..66899c4e6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ChenfengErrorCodeAutoConfiguration.java @@ -0,0 +1,39 @@ +package com.cf.imes.framework.errorcode.config; + +import com.cf.imes.framework.errorcode.core.generator.ErrorCodeAutoGenerator; +import com.cf.imes.framework.errorcode.core.generator.ErrorCodeAutoGeneratorImpl; +import com.cf.imes.framework.errorcode.core.loader.ErrorCodeLoader; +import com.cf.imes.framework.errorcode.core.loader.ErrorCodeLoaderImpl; +import com.cf.imes.module.system.api.errorcode.ErrorCodeApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 错误码配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "chenfeng.error-code", value = "enable", matchIfMissing = true) // 允许使用 chenfeng.error-code.enable=false 禁用访问日志 +@EnableConfigurationProperties(ErrorCodeProperties.class) +@EnableScheduling // 开启调度任务的功能,因为 ErrorCodeRemoteLoader 通过定时刷新错误码 +public class ChenfengErrorCodeAutoConfiguration { + + @Bean + public ErrorCodeAutoGenerator errorCodeAutoGenerator(@Value("${spring.application.name}") String applicationName, + ErrorCodeProperties errorCodeProperties, + ErrorCodeApi errorCodeApi) { + return new ErrorCodeAutoGeneratorImpl(applicationName, errorCodeProperties.getConstantsClassList(), errorCodeApi); + } + + @Bean + public ErrorCodeLoader errorCodeLoader(@Value("${spring.application.name}") String applicationName, + ErrorCodeApi errorCodeApi) { + return new ErrorCodeLoaderImpl(applicationName, errorCodeApi); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ChenfengErrorCodeRpcAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ChenfengErrorCodeRpcAutoConfiguration.java new file mode 100644 index 000000000..8ada61546 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ChenfengErrorCodeRpcAutoConfiguration.java @@ -0,0 +1,15 @@ +package com.cf.imes.framework.errorcode.config; + +import com.cf.imes.module.system.api.errorcode.ErrorCodeApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 错误码用到 Feign 的配置项 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@EnableFeignClients(clients = ErrorCodeApi.class) // 主要是引入相关的 API 服务 +public class ChenfengErrorCodeRpcAutoConfiguration { +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ErrorCodeProperties.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ErrorCodeProperties.java new file mode 100644 index 000000000..a3b3fb8e8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/config/ErrorCodeProperties.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.errorcode.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 错误码的配置属性类 + * + * @author dlyan + */ +@ConfigurationProperties("chenfeng.error-code") +@Data +@Validated +public class ErrorCodeProperties { + + /** + * 是否开启 + */ + private Boolean enable = true; + /** + * 错误码枚举类 + */ + @NotNull(message = "错误码枚举类不能为空") + private List constantsClassList; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/generator/ErrorCodeAutoGenerator.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/generator/ErrorCodeAutoGenerator.java new file mode 100644 index 000000000..bea143668 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/generator/ErrorCodeAutoGenerator.java @@ -0,0 +1,15 @@ +package com.cf.imes.framework.errorcode.core.generator; + +/** + * 错误码的自动生成器 + * + * @author dylan + */ +public interface ErrorCodeAutoGenerator { + + /** + * 将配置类到错误码写入数据库 + */ + void execute(); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/generator/ErrorCodeAutoGeneratorImpl.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/generator/ErrorCodeAutoGeneratorImpl.java new file mode 100644 index 000000000..536208061 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/generator/ErrorCodeAutoGeneratorImpl.java @@ -0,0 +1,108 @@ +package com.cf.imes.framework.errorcode.core.generator; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.ClassUtil; +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.common.exception.ErrorCode; +import com.cf.imes.module.system.api.errorcode.ErrorCodeApi; +import com.cf.imes.module.system.api.errorcode.dto.ErrorCodeAutoGenerateReqDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * ErrorCodeAutoGenerator 的实现类 + * 目的是,扫描指定的 {@link #constantsClassList} 类,写入到 system 服务中 + * + * @author dylan + */ +@RequiredArgsConstructor +@Slf4j +public class ErrorCodeAutoGeneratorImpl implements ErrorCodeAutoGenerator { + + /** + * 应用分组 + */ + private final String applicationName; + /** + * 错误码枚举类 + */ + private final List constantsClassList; + /** + * 错误码 Api + */ + private final ErrorCodeApi errorCodeApi; + + @Override + @EventListener(ApplicationReadyEvent.class) + @Async // 异步,保证项目的启动过程,毕竟非关键流程 + public void execute() { + // 第一步,解析错误码 + List autoGenerateDTOs = parseErrorCode(); + log.info("[execute][解析到错误码数量为 ({}) 个]", autoGenerateDTOs.size()); + + // 第二步,写入到 system 服务 + try { + errorCodeApi.autoGenerateErrorCodeList(autoGenerateDTOs).checkError(); + log.info("[execute][写入到 system 组件完成]"); + } catch (Exception ex) { + log.error("[execute][写入到 system 组件失败({})]", ExceptionUtil.getRootCauseMessage(ex)); + } + } + + /** + * 解析 constantsClassList 变量,转换成错误码数组 + * + * @return 错误码数组 + */ + private List parseErrorCode() { + // 校验 errorCodeConstantsClass 参数 + if (CollUtil.isEmpty(constantsClassList)) { + log.info("[execute][未配置 chenfeng.error-code.constants-class-list 配置项,不进行自动写入到 system 服务中]"); + return new ArrayList<>(); + } + + // 解析错误码 + List autoGenerateDTOs = new ArrayList<>(); + constantsClassList.forEach(constantsClass -> { + try { + // 解析错误码枚举类 + Class errorCodeConstantsClazz = ClassUtil.loadClass(constantsClass); + // 解析错误码 + autoGenerateDTOs.addAll(parseErrorCode(errorCodeConstantsClazz)); + } catch (Exception ex) { + log.warn("[parseErrorCode][constantsClass({}) 加载失败({})]", constantsClass, + ExceptionUtil.getRootCauseMessage(ex)); + } + }); + return autoGenerateDTOs; + } + + /** + * 解析错误码类,获得错误码数组 + * + * @return 错误码数组 + */ + private List parseErrorCode(Class constantsClass) { + List autoGenerateDTOs = new ArrayList<>(); + Arrays.stream(constantsClass.getFields()).forEach(field -> { + if (field.getType() != ErrorCode.class) { + return; + } + // 转换成 ErrorCodeAutoGenerateReqDTO 对象 + ErrorCode errorCode = (ErrorCode) ReflectUtil.getFieldValue(constantsClass, field); + autoGenerateDTOs.add(new ErrorCodeAutoGenerateReqDTO().setApplicationName(applicationName) + .setCode(errorCode.getCode()).setMessage(errorCode.getMsg())); + }); + return autoGenerateDTOs; + } + +} + diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/loader/ErrorCodeLoader.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/loader/ErrorCodeLoader.java new file mode 100644 index 000000000..025a4285e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/loader/ErrorCodeLoader.java @@ -0,0 +1,34 @@ +package com.cf.imes.framework.errorcode.core.loader; + +import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil; + +/** + * 错误码加载器 + * + * 注意,错误码最终加载到 {@link ServiceExceptionUtil} 的 MESSAGES 变量中! + * + * @author dlyan + */ +public interface ErrorCodeLoader { + + /** + * 添加错误码 + * + * @param code 错误码的编号 + * @param msg 错误码的提示 + */ + default void putErrorCode(Integer code, String msg) { + ServiceExceptionUtil.put(code, msg); + } + + /** + * 刷新错误码 + */ + void refreshErrorCodes(); + + /** + * 加载错误码 + */ + void loadErrorCodes(); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/loader/ErrorCodeLoaderImpl.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/loader/ErrorCodeLoaderImpl.java new file mode 100644 index 000000000..80bcac482 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/core/loader/ErrorCodeLoaderImpl.java @@ -0,0 +1,82 @@ +package com.cf.imes.framework.errorcode.core.loader; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import com.cf.imes.framework.common.util.date.DateUtils; +import com.cf.imes.module.system.api.errorcode.ErrorCodeApi; +import com.cf.imes.module.system.api.errorcode.dto.ErrorCodeRespDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * ErrorCodeLoader 的实现类,从 infra 的数据库中,加载错误码。 + * + * 考虑到错误码会刷新,所以按照 {@link #REFRESH_ERROR_CODE_PERIOD} 频率,增量加载错误码。 + * + * @author dlyan + */ +@RequiredArgsConstructor +@Slf4j +public class ErrorCodeLoaderImpl implements ErrorCodeLoader { + + /** + * 刷新错误码的频率,单位:毫秒 + */ + private static final int REFRESH_ERROR_CODE_PERIOD = 60 * 1000; + + /** + * 应用分组 + */ + private final String applicationName; + /** + * 错误码 Api + */ + private final ErrorCodeApi errorCodeApi; + + /** + * 缓存错误码的最大更新时间,用于后续的增量轮询,判断是否有更新 + */ + private LocalDateTime maxUpdateTime; + + @Override + @EventListener(ApplicationReadyEvent.class) + @Async // 异步,保证项目的启动过程,毕竟非关键流程 + public void loadErrorCodes() { + loadErrorCodes0(); + } + + @Override + @Scheduled(fixedDelay = REFRESH_ERROR_CODE_PERIOD, initialDelay = REFRESH_ERROR_CODE_PERIOD) + public void refreshErrorCodes() { + loadErrorCodes0(); + } + + private void loadErrorCodes0() { + try { + // 加载错误码 + List errorCodeRespDTOs = errorCodeApi.getErrorCodeList(applicationName, maxUpdateTime).getCheckedData(); + if (CollUtil.isEmpty(errorCodeRespDTOs)) { + return; + } + log.info("[loadErrorCodes0][加载到 ({}) 个错误码]", errorCodeRespDTOs.size()); + + // 刷新错误码的缓存 + errorCodeRespDTOs.forEach(errorCodeRespDTO -> { + // 写入到错误码的缓存 + putErrorCode(errorCodeRespDTO.getCode(), errorCodeRespDTO.getMessage()); + // 记录下更新时间,方便增量更新 + maxUpdateTime = DateUtils.max(maxUpdateTime, errorCodeRespDTO.getUpdateTime()); + }); + } catch (Exception ex) { + log.error("[loadErrorCodes0][加载错误码失败({})]", ExceptionUtil.getRootCauseMessage(ex)); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/package-info.java b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/package-info.java new file mode 100644 index 000000000..bba8f3169 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/java/com/cf/imes/framework/errorcode/package-info.java @@ -0,0 +1,10 @@ +/** + * 错误码 ErrorCode 的自动配置功能,提供如下功能: + * + * 1. 远程读取:项目启动时,从 system-service 服务,读取数据库中的 ErrorCode 错误码,实现错误码的提水可配置; + * 2. 自动更新:管理员在管理后台修数据库中的 ErrorCode 错误码时,项目自动从 system-service 服务加载最新的 ErrorCode 错误码; + * 3. 自动写入:项目启动时,将项目本地的错误码写到 system-server 服务中,方便管理员在管理后台编辑; + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.errorcode; diff --git a/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..173057aba --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-error-code/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.errorcode.config.ChenfengErrorCodeRpcAutoConfiguration +com.cf.imes.framework.errorcode.config.ChenfengErrorCodeAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/pom.xml b/cf-framework/cf-spring-boot-starter-biz-ip/pom.xml new file mode 100644 index 000000000..1d5aaa579 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/pom.xml @@ -0,0 +1,53 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-ip + jar + + ${project.artifactId} + IP 拓展,支持如下功能: + 1. IP 功能:查询 IP 对应的城市信息 + 基于 https://gitee.com/lionsoul/ip2region 实现 + 2. 城市功能:查询城市编码对应的城市信息 + 基于 https://github.com/modood/Administrative-divisions-of-China 实现 + + + + + com.cf.imes + cf-common + + + + + org.lionsoul + ip2region + + + + org.projectlombok + lombok + + + + org.slf4j + slf4j-api + provided + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/Area.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/Area.java new file mode 100644 index 000000000..6ad9cce60 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/Area.java @@ -0,0 +1,55 @@ +package com.cf.imes.framework.ip.core; + +import com.cf.imes.framework.ip.core.enums.AreaTypeEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 区域节点,包括国家、省份、城市、地区等信息 + * + * 数据可见 resources/area.csv 文件 + * + * @author 晨丰科技 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Area { + + /** + * 编号 - 全球,即根目录 + */ + public static final Integer ID_GLOBAL = 0; + /** + * 编号 - 中国 + */ + public static final Integer ID_CHINA = 1; + + /** + * 编号 + */ + private Integer id; + /** + * 名字 + */ + private String name; + /** + * 类型 + * + * 枚举 {@link AreaTypeEnum} + */ + private Integer type; + + /** + * 父节点 + */ + private Area parent; + /** + * 子节点 + */ + private List children; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/enums/AreaTypeEnum.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/enums/AreaTypeEnum.java new file mode 100644 index 000000000..a8efaee81 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/enums/AreaTypeEnum.java @@ -0,0 +1,39 @@ +package com.cf.imes.framework.ip.core.enums; + +import com.cf.imes.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 区域类型枚举 + * + * @author 晨丰科技 + */ +@AllArgsConstructor +@Getter +public enum AreaTypeEnum implements IntArrayValuable { + + COUNTRY(1, "国家"), + PROVINCE(2, "省份"), + CITY(3, "城市"), + DISTRICT(4, "地区"), // 县、镇、区等 + ; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(AreaTypeEnum::getType).toArray(); + + /** + * 类型 + */ + private final Integer type; + /** + * 名字 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/utils/AreaUtils.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/utils/AreaUtils.java new file mode 100644 index 000000000..7ff3a50d5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/utils/AreaUtils.java @@ -0,0 +1,162 @@ +package com.cf.imes.framework.ip.core.utils; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.text.csv.CsvRow; +import cn.hutool.core.text.csv.CsvUtil; +import com.cf.imes.framework.common.util.object.ObjectUtils; +import com.cf.imes.framework.ip.core.Area; +import com.cf.imes.framework.ip.core.enums.AreaTypeEnum; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static com.cf.imes.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 区域工具类 + * + * @author 晨丰科技 + */ +@Slf4j +public class AreaUtils { + + /** + * 初始化 SEARCHER + */ + @SuppressWarnings("InstantiationOfUtilityClass") + private final static AreaUtils INSTANCE = new AreaUtils(); + + /** + * Area 内存缓存,提升访问速度 + */ + private static Map areas; + + private AreaUtils() { + long now = System.currentTimeMillis(); + areas = new HashMap<>(); + areas.put(Area.ID_GLOBAL, new Area(Area.ID_GLOBAL, "全球", 0, + null, new ArrayList<>())); + // 从 csv 中加载数据 + List rows = CsvUtil.getReader().read(ResourceUtil.getUtf8Reader("area.csv")).getRows(); + rows.remove(0); // 删除 header + for (CsvRow row : rows) { + // 创建 Area 对象 + Area area = new Area(Integer.valueOf(row.get(0)), row.get(1), Integer.valueOf(row.get(2)), + null, new ArrayList<>()); + // 添加到 areas 中 + areas.put(area.getId(), area); + } + + // 构建父子关系:因为 Area 中没有 parentId 字段,所以需要重复读取 + for (CsvRow row : rows) { + Area area = areas.get(Integer.valueOf(row.get(0))); // 自己 + Area parent = areas.get(Integer.valueOf(row.get(3))); // 父 + Assert.isTrue(area != parent, "{}:父子节点相同", area.getName()); + area.setParent(parent); + parent.getChildren().add(area); + } + log.info("启动加载 AreaUtils 成功,耗时 ({}) 毫秒", System.currentTimeMillis() - now); + } + + /** + * 获得指定编号对应的区域 + * + * @param id 区域编号 + * @return 区域 + */ + public static Area getArea(Integer id) { + return areas.get(id); + } + + /** + * 格式化区域 + * + * @param id 区域编号 + * @return 格式化后的区域 + */ + public static String format(Integer id) { + return format(id, " "); + } + + /** + * 格式化区域 + * + * 例如说: + * 1. id = “静安区”时:上海 上海市 静安区 + * 2. id = “上海市”时:上海 上海市 + * 3. id = “上海”时:上海 + * 4. id = “美国”时:美国 + * 当区域在中国时,默认不显示中国 + * + * @param id 区域编号 + * @param separator 分隔符 + * @return 格式化后的区域 + */ + public static String format(Integer id, String separator) { + // 获得区域 + Area area = areas.get(id); + if (area == null) { + return null; + } + + // 格式化 + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < AreaTypeEnum.values().length; i++) { // 避免死循环 + sb.insert(0, area.getName()); + // “递归”父节点 + area = area.getParent(); + if (area == null + || ObjectUtils.equalsAny(area.getId(), Area.ID_GLOBAL, Area.ID_CHINA)) { // 跳过父节点为中国的情况 + break; + } + sb.insert(0, separator); + } + return sb.toString(); + } + + /** + * 获取指定类型的区域列表 + * + * @param type 区域类型 + * @param func 转换函数 + * @param 结果类型 + * @return 区域列表 + */ + public static List getByType(AreaTypeEnum type, Function func) { + return convertList(areas.values(), func, area -> type.getType().equals(area.getType())); + } + + /** + * 根据区域编号、上级区域类型,获取上级区域编号 + * + * @param id 区域编号 + * @param type 区域类型 + * @return 上级区域编号 + */ + public static Integer getParentIdByType(Integer id, @NonNull AreaTypeEnum type) { + for (int i = 0; i < Byte.MAX_VALUE; i++) { + Area area = AreaUtils.getArea(id); + if (area == null) { + return null; + } + // 情况一:匹配到,返回它 + if (type.getType().equals(area.getType())) { + return area.getId(); + } + // 情况二:找到根节点,返回空 + if (area.getParent() == null || area.getParent().getId() == null) { + return null; + } + // 其它:继续向上查找 + id = area.getParent().getId(); + } + return null; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/utils/IPUtils.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/utils/IPUtils.java new file mode 100644 index 000000000..476a7aa60 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/core/utils/IPUtils.java @@ -0,0 +1,87 @@ +package com.cf.imes.framework.ip.core.utils; + +import cn.hutool.core.io.resource.ResourceUtil; +import com.cf.imes.framework.ip.core.Area; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.lionsoul.ip2region.xdb.Searcher; + +import java.io.IOException; + +/** + * IP 工具类 + * + * IP 数据源来自 ip2region.xdb 精简版,基于 项目 + * + * @author wanglhup + */ +@Slf4j +public class IPUtils { + + /** + * 初始化 SEARCHER + */ + @SuppressWarnings("InstantiationOfUtilityClass") + private final static IPUtils INSTANCE = new IPUtils(); + + /** + * IP 查询器,启动加载到内存中 + */ + private static Searcher SEARCHER; + + /** + * 私有化构造 + */ + private IPUtils() { + try { + long now = System.currentTimeMillis(); + byte[] bytes = ResourceUtil.readBytes("ip2region.xdb"); + SEARCHER = Searcher.newWithBuffer(bytes); + log.info("启动加载 IPUtils 成功,耗时 ({}) 毫秒", System.currentTimeMillis() - now); + } catch (IOException e) { + log.error("启动加载 IPUtils 失败", e); + } + } + + /** + * 查询 IP 对应的地区编号 + * + * @param ip IP 地址,格式为 127.0.0.1 + * @return 地区id + */ + @SneakyThrows + public static Integer getAreaId(String ip) { + return Integer.parseInt(SEARCHER.search(ip.trim())); + } + + /** + * 查询 IP 对应的地区编号 + * + * @param ip IP 地址的时间戳,格式参考{@link Searcher#checkIP(String)} 的返回 + * @return 地区编号 + */ + @SneakyThrows + public static Integer getAreaId(long ip) { + return Integer.parseInt(SEARCHER.search(ip)); + } + + /** + * 查询 IP 对应的地区 + * + * @param ip IP 地址,格式为 127.0.0.1 + * @return 地区 + */ + public static Area getArea(String ip) { + return AreaUtils.getArea(getAreaId(ip)); + } + + /** + * 查询 IP 对应的地区 + * + * @param ip IP 地址的时间戳,格式参考{@link Searcher#checkIP(String)} 的返回 + * @return 地区 + */ + public static Area getArea(long ip) { + return AreaUtils.getArea(getAreaId(ip)); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/package-info.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/package-info.java new file mode 100644 index 000000000..37f7bbfb7 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/java/com/cf/imes/framework/ip/package-info.java @@ -0,0 +1,11 @@ +/** + * IP 拓展,支持如下功能: + * + * 1. IP 功能:查询 IP 对应的城市信息 + * 基于 https://gitee.com/lionsoul/ip2region 实现 + * 2. 城市功能:查询城市编码对应的城市信息 + * 基于 https://github.com/modood/Administrative-divisions-of-China 实现 + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.ip; diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/resources/area.csv b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/resources/area.csv new file mode 100644 index 000000000..c9a3a0e74 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/resources/area.csv @@ -0,0 +1,3639 @@ +id,name,type,parentId +1,中国,1,0 +2,蒙古,1,0 +3,朝鲜,1,0 +4,韩国,1,0 +5,日本,1,0 +6,菲律宾,1,0 +7,越南,1,0 +8,老挝,1,0 +9,柬埔寨,1,0 +10,缅甸,1,0 +11,泰国,1,0 +12,马来西亚,1,0 +13,文莱,1,0 +14,新加坡,1,0 +15,印度尼西亚,1,0 +16,东帝汶,1,0 +17,尼泊尔,1,0 +18,不丹,1,0 +19,孟加拉国,1,0 +20,印度,1,0 +21,巴基斯坦,1,0 +22,斯里兰卡,1,0 +23,马尔代夫,1,0 +24,哈萨克斯坦,1,0 +25,吉尔吉斯斯坦,1,0 +26,塔吉克斯坦,1,0 +27,乌兹别克斯坦,1,0 +28,土库曼斯坦,1,0 +29,阿富汗,1,0 +30,伊拉克,1,0 +31,伊朗,1,0 +32,叙利亚,1,0 +33,约旦,1,0 +34,黎巴嫩,1,0 +35,以色列,1,0 +36,巴勒斯坦,1,0 +37,沙特阿拉伯,1,0 +38,巴林,1,0 +39,卡塔尔,1,0 +40,科威特,1,0 +41,阿拉伯联合酋长国,1,0 +42,阿曼,1,0 +43,也门,1,0 +44,格鲁吉亚,1,0 +45,亚美尼亚,1,0 +46,阿塞拜疆,1,0 +47,土耳其,1,0 +48,塞浦路斯,1,0 +49,芬兰,1,0 +50,瑞典,1,0 +51,挪威,1,0 +52,冰岛,1,0 +53,丹麦,1,0 +54,爱沙尼亚,1,0 +55,拉脱维亚,1,0 +56,立陶宛,1,0 +57,白俄罗斯,1,0 +58,俄罗斯,1,0 +59,乌克兰,1,0 +60,摩尔多瓦,1,0 +61,波兰,1,0 +62,捷克,1,0 +63,斯洛伐克,1,0 +64,匈牙利,1,0 +65,德国,1,0 +66,奥地利,1,0 +67,瑞士,1,0 +68,列支敦士登,1,0 +69,英国,1,0 +70,爱尔兰,1,0 +71,荷兰,1,0 +72,比利时,1,0 +73,卢森堡,1,0 +74,法国,1,0 +75,摩纳哥,1,0 +76,罗马尼亚,1,0 +77,保加利亚,1,0 +78,塞尔维亚,1,0 +79,马其顿,1,0 +80,阿尔巴尼亚,1,0 +81,希腊,1,0 +82,斯洛文尼亚,1,0 +83,克罗地亚,1,0 +84,波斯尼亚和墨塞哥维那,1,0 +85,意大利,1,0 +86,梵蒂冈,1,0 +87,圣马力诺,1,0 +88,马耳他,1,0 +89,西班牙,1,0 +90,葡萄牙,1,0 +91,安道尔共和国,1,0 +92,埃及,1,0 +93,利比亚,1,0 +94,苏丹,1,0 +95,突尼斯,1,0 +96,阿尔及利亚,1,0 +97,摩洛哥,1,0 +98,亚速尔群岛,1,0 +99,马德拉群岛,1,0 +100,埃塞俄比亚,1,0 +101,厄立特里亚,1,0 +102,索马里,1,0 +103,吉布提,1,0 +104,肯尼亚,1,0 +105,坦桑尼亚,1,0 +106,乌干达,1,0 +107,卢旺达,1,0 +108,布隆迪,1,0 +109,塞舌尔,1,0 +110,圣多美及普林西比,1,0 +111,塞内加尔,1,0 +112,冈比亚,1,0 +113,马里,1,0 +114,布基纳法索,1,0 +115,几内亚,1,0 +116,几内亚比绍,1,0 +117,佛得角,1,0 +118,塞拉利昂,1,0 +119,利比里亚,1,0 +120,科特迪瓦,1,0 +121,加纳,1,0 +122,多哥,1,0 +123,贝宁,1,0 +124,尼日尔,1,0 +125,加那利群岛,1,0 +126,赞比亚,1,0 +127,安哥拉,1,0 +128,津巴布韦,1,0 +129,马拉维,1,0 +130,莫桑比克,1,0 +131,博茨瓦纳,1,0 +132,纳米比亚,1,0 +133,南非,1,0 +134,斯威士兰,1,0 +135,莱索托,1,0 +136,马达加斯加,1,0 +137,科摩罗,1,0 +138,毛里求斯,1,0 +139,留尼旺,1,0 +140,圣赫勒拿,1,0 +141,澳大利亚,1,0 +142,新西兰,1,0 +143,巴布亚新几内亚,1,0 +144,所罗门群岛,1,0 +145,瓦努阿图共和国,1,0 +146,密克罗尼西亚,1,0 +147,马绍尔群岛,1,0 +148,帕劳,1,0 +149,瑙鲁,1,0 +150,基里巴斯,1,0 +151,图瓦卢,1,0 +152,萨摩亚,1,0 +153,斐济,1,0 +154,汤加,1,0 +155,库克群岛,1,0 +156,关岛,1,0 +157,新喀里多尼亚,1,0 +158,法属波利尼西亚,1,0 +159,皮特凯恩岛,1,0 +160,瓦利斯与富图纳,1,0 +161,纽埃,1,0 +162,托克劳,1,0 +163,美属萨摩亚,1,0 +164,北马里亚纳,1,0 +165,加拿大,1,0 +166,美国,1,0 +167,墨西哥,1,0 +168,格陵兰,1,0 +169,危地马拉,1,0 +170,伯利兹,1,0 +171,萨尔瓦多,1,0 +172,洪都拉斯,1,0 +173,尼加拉瓜,1,0 +174,哥斯达黎加,1,0 +175,巴拿马,1,0 +176,巴哈马,1,0 +177,古巴,1,0 +178,牙买加,1,0 +179,海地,1,0 +180,多米尼加共和国,1,0 +181,安提瓜和巴布达,1,0 +182,圣基茨和尼维斯,1,0 +183,多米尼克,1,0 +184,圣卢西亚,1,0 +185,圣文森特和格林纳丁斯,1,0 +186,格林纳达,1,0 +187,巴巴多斯,1,0 +188,特立尼达和多巴哥,1,0 +189,波多黎各,1,0 +190,英属维尔京群岛,1,0 +191,美属维尔京群岛,1,0 +192,安圭拉,1,0 +193,蒙特塞拉特岛,1,0 +194,瓜德罗普,1,0 +195,马提尼克,1,0 +196,荷属安的列斯,1,0 +197,阿鲁巴,1,0 +198,特克斯和凯科斯群岛,1,0 +199,开曼群岛,1,0 +200,百慕大,1,0 +201,哥伦比亚,1,0 +202,委内瑞拉,1,0 +203,圭亚那,1,0 +204,法属圭亚那,1,0 +205,苏里南,1,0 +206,厄瓜多尔,1,0 +207,秘鲁,1,0 +208,玻利维亚,1,0 +209,巴西,1,0 +210,智利,1,0 +211,阿根廷,1,0 +212,乌拉圭,1,0 +213,巴拉圭,1,0 +214,波黑,1,0 +215,直布罗陀,1,0 +216,新喀里多尼亚群岛,1,0 +217,瓦利斯和富图纳群岛,1,0 +218,泽西岛,1,0 +219,黑山,1,0 +220,英属马恩岛,1,0 +221,尼日利亚,1,0 +222,喀麦隆,1,0 +223,加蓬,1,0 +224,乍得,1,0 +225,刚果共和国,1,0 +226,中非共和国,1,0 +227,南苏丹,1,0 +228,赤道几内亚,1,0 +229,毛里塔尼亚,1,0 +230,刚果民主共和国,1,0 +231,留尼汪岛,1,0 +232,格陵兰岛,1,0 +233,法罗群岛,1,0 +234,根西岛,1,0 +235,百慕大群岛,1,0 +236,圣皮埃尔和密克隆群岛,1,0 +237,法属圣马丁,1,0 +238,奥兰群岛,1,0 +239,北马里亚纳群岛,1,0 +240,库拉索,1,0 +241,博内尔岛,1,0 +242,圣马丁岛,1,0 +243,圣巴泰勒米岛,1,0 +244,福克兰群岛,1,0 +245,圣多美和普林西比,1,0 +246,英属印度洋领地,1,0 +247,东萨摩亚,1,0 +248,诺福克岛,1,0 +110000,北京,2,1 +120000,天津,2,1 +130000,河北省,2,1 +140000,山西省,2,1 +150000,内蒙古自治区,2,1 +210000,辽宁省,2,1 +220000,吉林省,2,1 +230000,黑龙江省,2,1 +310000,上海,2,1 +320000,江苏省,2,1 +330000,浙江省,2,1 +340000,安徽省,2,1 +350000,福建省,2,1 +360000,江西省,2,1 +370000,山东省,2,1 +410000,河南省,2,1 +420000,湖北省,2,1 +430000,湖南省,2,1 +440000,广东省,2,1 +450000,广西壮族自治区,2,1 +460000,海南省,2,1 +500000,重庆,2,1 +510000,四川省,2,1 +520000,贵州省,2,1 +530000,云南省,2,1 +540000,西藏自治区,2,1 +610000,陕西省,2,1 +620000,甘肃省,2,1 +630000,青海省,2,1 +640000,宁夏回族自治区,2,1 +650000,新疆维吾尔自治区,2,1 +110100,北京市,3,110000 +120100,天津市,3,120000 +130100,石家庄市,3,130000 +130200,唐山市,3,130000 +130300,秦皇岛市,3,130000 +130400,邯郸市,3,130000 +130500,邢台市,3,130000 +130600,保定市,3,130000 +130700,张家口市,3,130000 +130800,承德市,3,130000 +130900,沧州市,3,130000 +131000,廊坊市,3,130000 +131100,衡水市,3,130000 +140100,太原市,3,140000 +140200,大同市,3,140000 +140300,阳泉市,3,140000 +140400,长治市,3,140000 +140500,晋城市,3,140000 +140600,朔州市,3,140000 +140700,晋中市,3,140000 +140800,运城市,3,140000 +140900,忻州市,3,140000 +141000,临汾市,3,140000 +141100,吕梁市,3,140000 +150100,呼和浩特市,3,150000 +150200,包头市,3,150000 +150300,乌海市,3,150000 +150400,赤峰市,3,150000 +150500,通辽市,3,150000 +150600,鄂尔多斯市,3,150000 +150700,呼伦贝尔市,3,150000 +150800,巴彦淖尔市,3,150000 +150900,乌兰察布市,3,150000 +152200,兴安盟,3,150000 +152500,锡林郭勒盟,3,150000 +152900,阿拉善盟,3,150000 +210100,沈阳市,3,210000 +210200,大连市,3,210000 +210300,鞍山市,3,210000 +210400,抚顺市,3,210000 +210500,本溪市,3,210000 +210600,丹东市,3,210000 +210700,锦州市,3,210000 +210800,营口市,3,210000 +210900,阜新市,3,210000 +211000,辽阳市,3,210000 +211100,盘锦市,3,210000 +211200,铁岭市,3,210000 +211300,朝阳市,3,210000 +211400,葫芦岛市,3,210000 +220100,长春市,3,220000 +220200,吉林市,3,220000 +220300,四平市,3,220000 +220400,辽源市,3,220000 +220500,通化市,3,220000 +220600,白山市,3,220000 +220700,松原市,3,220000 +220800,白城市,3,220000 +222400,延边朝鲜族自治州,3,220000 +230100,哈尔滨市,3,230000 +230200,齐齐哈尔市,3,230000 +230300,鸡西市,3,230000 +230400,鹤岗市,3,230000 +230500,双鸭山市,3,230000 +230600,大庆市,3,230000 +230700,伊春市,3,230000 +230800,佳木斯市,3,230000 +230900,七台河市,3,230000 +231000,牡丹江市,3,230000 +231100,黑河市,3,230000 +231200,绥化市,3,230000 +232700,大兴安岭地区,3,230000 +310100,上海市,3,310000 +320100,南京市,3,320000 +320200,无锡市,3,320000 +320300,徐州市,3,320000 +320400,常州市,3,320000 +320500,苏州市,3,320000 +320600,南通市,3,320000 +320700,连云港市,3,320000 +320800,淮安市,3,320000 +320900,盐城市,3,320000 +321000,扬州市,3,320000 +321100,镇江市,3,320000 +321200,泰州市,3,320000 +321300,宿迁市,3,320000 +330100,杭州市,3,330000 +330200,宁波市,3,330000 +330300,温州市,3,330000 +330400,嘉兴市,3,330000 +330500,湖州市,3,330000 +330600,绍兴市,3,330000 +330700,金华市,3,330000 +330800,衢州市,3,330000 +330900,舟山市,3,330000 +331000,台州市,3,330000 +331100,丽水市,3,330000 +340100,合肥市,3,340000 +340200,芜湖市,3,340000 +340300,蚌埠市,3,340000 +340400,淮南市,3,340000 +340500,马鞍山市,3,340000 +340600,淮北市,3,340000 +340700,铜陵市,3,340000 +340800,安庆市,3,340000 +341000,黄山市,3,340000 +341100,滁州市,3,340000 +341200,阜阳市,3,340000 +341300,宿州市,3,340000 +341500,六安市,3,340000 +341600,亳州市,3,340000 +341700,池州市,3,340000 +341800,宣城市,3,340000 +350100,福州市,3,350000 +350200,厦门市,3,350000 +350300,莆田市,3,350000 +350400,三明市,3,350000 +350500,泉州市,3,350000 +350600,漳州市,3,350000 +350700,南平市,3,350000 +350800,龙岩市,3,350000 +350900,宁德市,3,350000 +360100,南昌市,3,360000 +360200,景德镇市,3,360000 +360300,萍乡市,3,360000 +360400,九江市,3,360000 +360500,新余市,3,360000 +360600,鹰潭市,3,360000 +360700,赣州市,3,360000 +360800,吉安市,3,360000 +360900,宜春市,3,360000 +361000,抚州市,3,360000 +361100,上饶市,3,360000 +370100,济南市,3,370000 +370200,青岛市,3,370000 +370300,淄博市,3,370000 +370400,枣庄市,3,370000 +370500,东营市,3,370000 +370600,烟台市,3,370000 +370700,潍坊市,3,370000 +370800,济宁市,3,370000 +370900,泰安市,3,370000 +371000,威海市,3,370000 +371100,日照市,3,370000 +371300,临沂市,3,370000 +371400,德州市,3,370000 +371500,聊城市,3,370000 +371600,滨州市,3,370000 +371700,菏泽市,3,370000 +410100,郑州市,3,410000 +410200,开封市,3,410000 +410300,洛阳市,3,410000 +410400,平顶山市,3,410000 +410500,安阳市,3,410000 +410600,鹤壁市,3,410000 +410700,新乡市,3,410000 +410800,焦作市,3,410000 +410900,濮阳市,3,410000 +411000,许昌市,3,410000 +411100,漯河市,3,410000 +411200,三门峡市,3,410000 +411300,南阳市,3,410000 +411400,商丘市,3,410000 +411500,信阳市,3,410000 +411600,周口市,3,410000 +411700,驻马店市,3,410000 +419000,省直辖县级行政区划,3,410000 +420100,武汉市,3,420000 +420200,黄石市,3,420000 +420300,十堰市,3,420000 +420500,宜昌市,3,420000 +420600,襄阳市,3,420000 +420700,鄂州市,3,420000 +420800,荆门市,3,420000 +420900,孝感市,3,420000 +421000,荆州市,3,420000 +421100,黄冈市,3,420000 +421200,咸宁市,3,420000 +421300,随州市,3,420000 +422800,恩施土家族苗族自治州,3,420000 +429000,省直辖县级行政区划,3,420000 +430100,长沙市,3,430000 +430200,株洲市,3,430000 +430300,湘潭市,3,430000 +430400,衡阳市,3,430000 +430500,邵阳市,3,430000 +430600,岳阳市,3,430000 +430700,常德市,3,430000 +430800,张家界市,3,430000 +430900,益阳市,3,430000 +431000,郴州市,3,430000 +431100,永州市,3,430000 +431200,怀化市,3,430000 +431300,娄底市,3,430000 +433100,湘西土家族苗族自治州,3,430000 +440100,广州市,3,440000 +440200,韶关市,3,440000 +440300,深圳市,3,440000 +440400,珠海市,3,440000 +440500,汕头市,3,440000 +440600,佛山市,3,440000 +440700,江门市,3,440000 +440800,湛江市,3,440000 +440900,茂名市,3,440000 +441200,肇庆市,3,440000 +441300,惠州市,3,440000 +441400,梅州市,3,440000 +441500,汕尾市,3,440000 +441600,河源市,3,440000 +441700,阳江市,3,440000 +441800,清远市,3,440000 +441900,东莞市,3,440000 +441901,莞城区,4,441900 +441902,南城区,4,441900 +441904,万江区,4,441900 +441905,石碣镇,4,441900 +441906,石龙镇,4,441900 +441907,茶山镇,4,441900 +441908,石排镇,4,441900 +441909,企石镇,4,441900 +441910,横沥镇,4,441900 +441911,桥头镇,4,441900 +441912,谢岗镇,4,441900 +441913,东坑镇,4,441900 +441914,常平镇,4,441900 +441915,寮步镇,4,441900 +441916,大朗镇,4,441900 +441917,麻涌镇,4,441900 +441918,中堂镇,4,441900 +441919,高埗镇,4,441900 +441920,樟木头镇,4,441900 +441921,大岭山镇,4,441900 +441922,望牛墩镇,4,441900 +441923,黄江镇,4,441900 +441924,洪梅镇,4,441900 +441925,清溪镇,4,441900 +441926,沙田镇,4,441900 +441927,道滘镇,4,441900 +441928,塘厦镇,4,441900 +441929,虎门镇,4,441900 +441930,厚街镇,4,441900 +441931,凤岗镇,4,441900 +441932,长安镇,4,441900 +442000,中山市,3,440000 +445100,潮州市,3,440000 +445200,揭阳市,3,440000 +445300,云浮市,3,440000 +450100,南宁市,3,450000 +450200,柳州市,3,450000 +450300,桂林市,3,450000 +450400,梧州市,3,450000 +450500,北海市,3,450000 +450600,防城港市,3,450000 +450700,钦州市,3,450000 +450800,贵港市,3,450000 +450900,玉林市,3,450000 +451000,百色市,3,450000 +451100,贺州市,3,450000 +451200,河池市,3,450000 +451300,来宾市,3,450000 +451400,崇左市,3,450000 +460100,海口市,3,460000 +460200,三亚市,3,460000 +460300,三沙市,3,460000 +460400,儋州市,3,460000 +469000,省直辖县级行政区划,3,460000 +500100,重庆市,3,500000 +510100,成都市,3,510000 +510300,自贡市,3,510000 +510400,攀枝花市,3,510000 +510500,泸州市,3,510000 +510600,德阳市,3,510000 +510700,绵阳市,3,510000 +510800,广元市,3,510000 +510900,遂宁市,3,510000 +511000,内江市,3,510000 +511100,乐山市,3,510000 +511300,南充市,3,510000 +511400,眉山市,3,510000 +511500,宜宾市,3,510000 +511600,广安市,3,510000 +511700,达州市,3,510000 +511800,雅安市,3,510000 +511900,巴中市,3,510000 +512000,资阳市,3,510000 +513200,阿坝藏族羌族自治州,3,510000 +513300,甘孜藏族自治州,3,510000 +513400,凉山彝族自治州,3,510000 +520100,贵阳市,3,520000 +520200,六盘水市,3,520000 +520300,遵义市,3,520000 +520400,安顺市,3,520000 +520500,毕节市,3,520000 +520600,铜仁市,3,520000 +522300,黔西南布依族苗族自治州,3,520000 +522600,黔东南苗族侗族自治州,3,520000 +522700,黔南布依族苗族自治州,3,520000 +530100,昆明市,3,530000 +530300,曲靖市,3,530000 +530400,玉溪市,3,530000 +530500,保山市,3,530000 +530600,昭通市,3,530000 +530700,丽江市,3,530000 +530800,普洱市,3,530000 +530900,临沧市,3,530000 +532300,楚雄彝族自治州,3,530000 +532500,红河哈尼族彝族自治州,3,530000 +532600,文山壮族苗族自治州,3,530000 +532800,西双版纳傣族自治州,3,530000 +532900,大理白族自治州,3,530000 +533100,德宏傣族景颇族自治州,3,530000 +533300,怒江傈僳族自治州,3,530000 +533400,迪庆藏族自治州,3,530000 +540100,拉萨市,3,540000 +540200,日喀则市,3,540000 +540300,昌都市,3,540000 +540400,林芝市,3,540000 +540500,山南市,3,540000 +540600,那曲市,3,540000 +542500,阿里地区,3,540000 +610100,西安市,3,610000 +610200,铜川市,3,610000 +610300,宝鸡市,3,610000 +610400,咸阳市,3,610000 +610500,渭南市,3,610000 +610600,延安市,3,610000 +610700,汉中市,3,610000 +610800,榆林市,3,610000 +610900,安康市,3,610000 +611000,商洛市,3,610000 +620100,兰州市,3,620000 +620200,嘉峪关市,3,620000 +620300,金昌市,3,620000 +620400,白银市,3,620000 +620500,天水市,3,620000 +620600,武威市,3,620000 +620700,张掖市,3,620000 +620800,平凉市,3,620000 +620900,酒泉市,3,620000 +621000,庆阳市,3,620000 +621100,定西市,3,620000 +621200,陇南市,3,620000 +622900,临夏回族自治州,3,620000 +623000,甘南藏族自治州,3,620000 +630100,西宁市,3,630000 +630200,海东市,3,630000 +632200,海北藏族自治州,3,630000 +632300,黄南藏族自治州,3,630000 +632500,海南藏族自治州,3,630000 +632600,果洛藏族自治州,3,630000 +632700,玉树藏族自治州,3,630000 +632800,海西蒙古族藏族自治州,3,630000 +640100,银川市,3,640000 +640200,石嘴山市,3,640000 +640300,吴忠市,3,640000 +640400,固原市,3,640000 +640500,中卫市,3,640000 +650100,乌鲁木齐市,3,650000 +650200,克拉玛依市,3,650000 +650400,吐鲁番市,3,650000 +650500,哈密市,3,650000 +652300,昌吉回族自治州,3,650000 +652700,博尔塔拉蒙古自治州,3,650000 +652800,巴音郭楞蒙古自治州,3,650000 +652900,阿克苏地区,3,650000 +653000,克孜勒苏柯尔克孜自治州,3,650000 +653100,喀什地区,3,650000 +653200,和田地区,3,650000 +654000,伊犁哈萨克自治州,3,650000 +654200,塔城地区,3,650000 +654300,阿勒泰地区,3,650000 +659000,自治区直辖县级行政区划,3,650000 +110101,东城区,4,110100 +110102,西城区,4,110100 +110105,朝阳区,4,110100 +110106,丰台区,4,110100 +110107,石景山区,4,110100 +110108,海淀区,4,110100 +110109,门头沟区,4,110100 +110111,房山区,4,110100 +110112,通州区,4,110100 +110113,顺义区,4,110100 +110114,昌平区,4,110100 +110115,大兴区,4,110100 +110116,怀柔区,4,110100 +110117,平谷区,4,110100 +110118,密云区,4,110100 +110119,延庆区,4,110100 +120101,和平区,4,120100 +120102,河东区,4,120100 +120103,河西区,4,120100 +120104,南开区,4,120100 +120105,河北区,4,120100 +120106,红桥区,4,120100 +120110,东丽区,4,120100 +120111,西青区,4,120100 +120112,津南区,4,120100 +120113,北辰区,4,120100 +120114,武清区,4,120100 +120115,宝坻区,4,120100 +120116,滨海新区,4,120100 +120117,宁河区,4,120100 +120118,静海区,4,120100 +120119,蓟州区,4,120100 +130102,长安区,4,130100 +130104,桥西区,4,130100 +130105,新华区,4,130100 +130107,井陉矿区,4,130100 +130108,裕华区,4,130100 +130109,藁城区,4,130100 +130110,鹿泉区,4,130100 +130111,栾城区,4,130100 +130121,井陉县,4,130100 +130123,正定县,4,130100 +130125,行唐县,4,130100 +130126,灵寿县,4,130100 +130127,高邑县,4,130100 +130128,深泽县,4,130100 +130129,赞皇县,4,130100 +130130,无极县,4,130100 +130131,平山县,4,130100 +130132,元氏县,4,130100 +130133,赵县,4,130100 +130171,石家庄高新技术产业开发区,4,130100 +130172,石家庄循环化工园区,4,130100 +130181,辛集市,4,130100 +130183,晋州市,4,130100 +130184,新乐市,4,130100 +130202,路南区,4,130200 +130203,路北区,4,130200 +130204,古冶区,4,130200 +130205,开平区,4,130200 +130207,丰南区,4,130200 +130208,丰润区,4,130200 +130209,曹妃甸区,4,130200 +130224,滦南县,4,130200 +130225,乐亭县,4,130200 +130227,迁西县,4,130200 +130229,玉田县,4,130200 +130271,河北唐山芦台经济开发区,4,130200 +130272,唐山市汉沽管理区,4,130200 +130273,唐山高新技术产业开发区,4,130200 +130274,河北唐山海港经济开发区,4,130200 +130281,遵化市,4,130200 +130283,迁安市,4,130200 +130284,滦州市,4,130200 +130302,海港区,4,130300 +130303,山海关区,4,130300 +130304,北戴河区,4,130300 +130306,抚宁区,4,130300 +130321,青龙满族自治县,4,130300 +130322,昌黎县,4,130300 +130324,卢龙县,4,130300 +130371,秦皇岛市经济技术开发区,4,130300 +130372,北戴河新区,4,130300 +130402,邯山区,4,130400 +130403,丛台区,4,130400 +130404,复兴区,4,130400 +130406,峰峰矿区,4,130400 +130407,肥乡区,4,130400 +130408,永年区,4,130400 +130423,临漳县,4,130400 +130424,成安县,4,130400 +130425,大名县,4,130400 +130426,涉县,4,130400 +130427,磁县,4,130400 +130430,邱县,4,130400 +130431,鸡泽县,4,130400 +130432,广平县,4,130400 +130433,馆陶县,4,130400 +130434,魏县,4,130400 +130435,曲周县,4,130400 +130471,邯郸经济技术开发区,4,130400 +130473,邯郸冀南新区,4,130400 +130481,武安市,4,130400 +130502,襄都区,4,130500 +130503,信都区,4,130500 +130505,任泽区,4,130500 +130506,南和区,4,130500 +130522,临城县,4,130500 +130523,内丘县,4,130500 +130524,柏乡县,4,130500 +130525,隆尧县,4,130500 +130528,宁晋县,4,130500 +130529,巨鹿县,4,130500 +130530,新河县,4,130500 +130531,广宗县,4,130500 +130532,平乡县,4,130500 +130533,威县,4,130500 +130534,清河县,4,130500 +130535,临西县,4,130500 +130571,河北邢台经济开发区,4,130500 +130581,南宫市,4,130500 +130582,沙河市,4,130500 +130602,竞秀区,4,130600 +130606,莲池区,4,130600 +130607,满城区,4,130600 +130608,清苑区,4,130600 +130609,徐水区,4,130600 +130623,涞水县,4,130600 +130624,阜平县,4,130600 +130626,定兴县,4,130600 +130627,唐县,4,130600 +130628,高阳县,4,130600 +130629,容城县,4,130600 +130630,涞源县,4,130600 +130631,望都县,4,130600 +130632,安新县,4,130600 +130633,易县,4,130600 +130634,曲阳县,4,130600 +130635,蠡县,4,130600 +130636,顺平县,4,130600 +130637,博野县,4,130600 +130638,雄县,4,130600 +130671,保定高新技术产业开发区,4,130600 +130672,保定白沟新城,4,130600 +130681,涿州市,4,130600 +130682,定州市,4,130600 +130683,安国市,4,130600 +130684,高碑店市,4,130600 +130702,桥东区,4,130700 +130703,桥西区,4,130700 +130705,宣化区,4,130700 +130706,下花园区,4,130700 +130708,万全区,4,130700 +130709,崇礼区,4,130700 +130722,张北县,4,130700 +130723,康保县,4,130700 +130724,沽源县,4,130700 +130725,尚义县,4,130700 +130726,蔚县,4,130700 +130727,阳原县,4,130700 +130728,怀安县,4,130700 +130730,怀来县,4,130700 +130731,涿鹿县,4,130700 +130732,赤城县,4,130700 +130771,张家口经济开发区,4,130700 +130772,张家口市察北管理区,4,130700 +130773,张家口市塞北管理区,4,130700 +130802,双桥区,4,130800 +130803,双滦区,4,130800 +130804,鹰手营子矿区,4,130800 +130821,承德县,4,130800 +130822,兴隆县,4,130800 +130824,滦平县,4,130800 +130825,隆化县,4,130800 +130826,丰宁满族自治县,4,130800 +130827,宽城满族自治县,4,130800 +130828,围场满族蒙古族自治县,4,130800 +130871,承德高新技术产业开发区,4,130800 +130881,平泉市,4,130800 +130902,新华区,4,130900 +130903,运河区,4,130900 +130921,沧县,4,130900 +130922,青县,4,130900 +130923,东光县,4,130900 +130924,海兴县,4,130900 +130925,盐山县,4,130900 +130926,肃宁县,4,130900 +130927,南皮县,4,130900 +130928,吴桥县,4,130900 +130929,献县,4,130900 +130930,孟村回族自治县,4,130900 +130971,河北沧州经济开发区,4,130900 +130972,沧州高新技术产业开发区,4,130900 +130973,沧州渤海新区,4,130900 +130981,泊头市,4,130900 +130982,任丘市,4,130900 +130983,黄骅市,4,130900 +130984,河间市,4,130900 +131002,安次区,4,131000 +131003,广阳区,4,131000 +131022,固安县,4,131000 +131023,永清县,4,131000 +131024,香河县,4,131000 +131025,大城县,4,131000 +131026,文安县,4,131000 +131028,大厂回族自治县,4,131000 +131071,廊坊经济技术开发区,4,131000 +131081,霸州市,4,131000 +131082,三河市,4,131000 +131102,桃城区,4,131100 +131103,冀州区,4,131100 +131121,枣强县,4,131100 +131122,武邑县,4,131100 +131123,武强县,4,131100 +131124,饶阳县,4,131100 +131125,安平县,4,131100 +131126,故城县,4,131100 +131127,景县,4,131100 +131128,阜城县,4,131100 +131171,河北衡水高新技术产业开发区,4,131100 +131172,衡水滨湖新区,4,131100 +131182,深州市,4,131100 +140105,小店区,4,140100 +140106,迎泽区,4,140100 +140107,杏花岭区,4,140100 +140108,尖草坪区,4,140100 +140109,万柏林区,4,140100 +140110,晋源区,4,140100 +140121,清徐县,4,140100 +140122,阳曲县,4,140100 +140123,娄烦县,4,140100 +140171,山西转型综合改革示范区,4,140100 +140181,古交市,4,140100 +140212,新荣区,4,140200 +140213,平城区,4,140200 +140214,云冈区,4,140200 +140215,云州区,4,140200 +140221,阳高县,4,140200 +140222,天镇县,4,140200 +140223,广灵县,4,140200 +140224,灵丘县,4,140200 +140225,浑源县,4,140200 +140226,左云县,4,140200 +140271,山西大同经济开发区,4,140200 +140302,城区,4,140300 +140303,矿区,4,140300 +140311,郊区,4,140300 +140321,平定县,4,140300 +140322,盂县,4,140300 +140403,潞州区,4,140400 +140404,上党区,4,140400 +140405,屯留区,4,140400 +140406,潞城区,4,140400 +140423,襄垣县,4,140400 +140425,平顺县,4,140400 +140426,黎城县,4,140400 +140427,壶关县,4,140400 +140428,长子县,4,140400 +140429,武乡县,4,140400 +140430,沁县,4,140400 +140431,沁源县,4,140400 +140471,山西长治高新技术产业园区,4,140400 +140502,城区,4,140500 +140521,沁水县,4,140500 +140522,阳城县,4,140500 +140524,陵川县,4,140500 +140525,泽州县,4,140500 +140581,高平市,4,140500 +140602,朔城区,4,140600 +140603,平鲁区,4,140600 +140621,山阴县,4,140600 +140622,应县,4,140600 +140623,右玉县,4,140600 +140671,山西朔州经济开发区,4,140600 +140681,怀仁市,4,140600 +140702,榆次区,4,140700 +140703,太谷区,4,140700 +140721,榆社县,4,140700 +140722,左权县,4,140700 +140723,和顺县,4,140700 +140724,昔阳县,4,140700 +140725,寿阳县,4,140700 +140727,祁县,4,140700 +140728,平遥县,4,140700 +140729,灵石县,4,140700 +140781,介休市,4,140700 +140802,盐湖区,4,140800 +140821,临猗县,4,140800 +140822,万荣县,4,140800 +140823,闻喜县,4,140800 +140824,稷山县,4,140800 +140825,新绛县,4,140800 +140826,绛县,4,140800 +140827,垣曲县,4,140800 +140828,夏县,4,140800 +140829,平陆县,4,140800 +140830,芮城县,4,140800 +140881,永济市,4,140800 +140882,河津市,4,140800 +140902,忻府区,4,140900 +140921,定襄县,4,140900 +140922,五台县,4,140900 +140923,代县,4,140900 +140924,繁峙县,4,140900 +140925,宁武县,4,140900 +140926,静乐县,4,140900 +140927,神池县,4,140900 +140928,五寨县,4,140900 +140929,岢岚县,4,140900 +140930,河曲县,4,140900 +140931,保德县,4,140900 +140932,偏关县,4,140900 +140971,五台山风景名胜区,4,140900 +140981,原平市,4,140900 +141002,尧都区,4,141000 +141021,曲沃县,4,141000 +141022,翼城县,4,141000 +141023,襄汾县,4,141000 +141024,洪洞县,4,141000 +141025,古县,4,141000 +141026,安泽县,4,141000 +141027,浮山县,4,141000 +141028,吉县,4,141000 +141029,乡宁县,4,141000 +141030,大宁县,4,141000 +141031,隰县,4,141000 +141032,永和县,4,141000 +141033,蒲县,4,141000 +141034,汾西县,4,141000 +141081,侯马市,4,141000 +141082,霍州市,4,141000 +141102,离石区,4,141100 +141121,文水县,4,141100 +141122,交城县,4,141100 +141123,兴县,4,141100 +141124,临县,4,141100 +141125,柳林县,4,141100 +141126,石楼县,4,141100 +141127,岚县,4,141100 +141128,方山县,4,141100 +141129,中阳县,4,141100 +141130,交口县,4,141100 +141181,孝义市,4,141100 +141182,汾阳市,4,141100 +150102,新城区,4,150100 +150103,回民区,4,150100 +150104,玉泉区,4,150100 +150105,赛罕区,4,150100 +150121,土默特左旗,4,150100 +150122,托克托县,4,150100 +150123,和林格尔县,4,150100 +150124,清水河县,4,150100 +150125,武川县,4,150100 +150172,呼和浩特经济技术开发区,4,150100 +150202,东河区,4,150200 +150203,昆都仑区,4,150200 +150204,青山区,4,150200 +150205,石拐区,4,150200 +150206,白云鄂博矿区,4,150200 +150207,九原区,4,150200 +150221,土默特右旗,4,150200 +150222,固阳县,4,150200 +150223,达尔罕茂明安联合旗,4,150200 +150271,包头稀土高新技术产业开发区,4,150200 +150302,海勃湾区,4,150300 +150303,海南区,4,150300 +150304,乌达区,4,150300 +150402,红山区,4,150400 +150403,元宝山区,4,150400 +150404,松山区,4,150400 +150421,阿鲁科尔沁旗,4,150400 +150422,巴林左旗,4,150400 +150423,巴林右旗,4,150400 +150424,林西县,4,150400 +150425,克什克腾旗,4,150400 +150426,翁牛特旗,4,150400 +150428,喀喇沁旗,4,150400 +150429,宁城县,4,150400 +150430,敖汉旗,4,150400 +150502,科尔沁区,4,150500 +150521,科尔沁左翼中旗,4,150500 +150522,科尔沁左翼后旗,4,150500 +150523,开鲁县,4,150500 +150524,库伦旗,4,150500 +150525,奈曼旗,4,150500 +150526,扎鲁特旗,4,150500 +150571,通辽经济技术开发区,4,150500 +150581,霍林郭勒市,4,150500 +150602,东胜区,4,150600 +150603,康巴什区,4,150600 +150621,达拉特旗,4,150600 +150622,准格尔旗,4,150600 +150623,鄂托克前旗,4,150600 +150624,鄂托克旗,4,150600 +150625,杭锦旗,4,150600 +150626,乌审旗,4,150600 +150627,伊金霍洛旗,4,150600 +150702,海拉尔区,4,150700 +150703,扎赉诺尔区,4,150700 +150721,阿荣旗,4,150700 +150722,莫力达瓦达斡尔族自治旗,4,150700 +150723,鄂伦春自治旗,4,150700 +150724,鄂温克族自治旗,4,150700 +150725,陈巴尔虎旗,4,150700 +150726,新巴尔虎左旗,4,150700 +150727,新巴尔虎右旗,4,150700 +150781,满洲里市,4,150700 +150782,牙克石市,4,150700 +150783,扎兰屯市,4,150700 +150784,额尔古纳市,4,150700 +150785,根河市,4,150700 +150802,临河区,4,150800 +150821,五原县,4,150800 +150822,磴口县,4,150800 +150823,乌拉特前旗,4,150800 +150824,乌拉特中旗,4,150800 +150825,乌拉特后旗,4,150800 +150826,杭锦后旗,4,150800 +150902,集宁区,4,150900 +150921,卓资县,4,150900 +150922,化德县,4,150900 +150923,商都县,4,150900 +150924,兴和县,4,150900 +150925,凉城县,4,150900 +150926,察哈尔右翼前旗,4,150900 +150927,察哈尔右翼中旗,4,150900 +150928,察哈尔右翼后旗,4,150900 +150929,四子王旗,4,150900 +150981,丰镇市,4,150900 +152201,乌兰浩特市,4,152200 +152202,阿尔山市,4,152200 +152221,科尔沁右翼前旗,4,152200 +152222,科尔沁右翼中旗,4,152200 +152223,扎赉特旗,4,152200 +152224,突泉县,4,152200 +152501,二连浩特市,4,152500 +152502,锡林浩特市,4,152500 +152522,阿巴嘎旗,4,152500 +152523,苏尼特左旗,4,152500 +152524,苏尼特右旗,4,152500 +152525,东乌珠穆沁旗,4,152500 +152526,西乌珠穆沁旗,4,152500 +152527,太仆寺旗,4,152500 +152528,镶黄旗,4,152500 +152529,正镶白旗,4,152500 +152530,正蓝旗,4,152500 +152531,多伦县,4,152500 +152571,乌拉盖管委会,4,152500 +152921,阿拉善左旗,4,152900 +152922,阿拉善右旗,4,152900 +152923,额济纳旗,4,152900 +152971,内蒙古阿拉善高新技术产业开发区,4,152900 +210102,和平区,4,210100 +210103,沈河区,4,210100 +210104,大东区,4,210100 +210105,皇姑区,4,210100 +210106,铁西区,4,210100 +210111,苏家屯区,4,210100 +210112,浑南区,4,210100 +210113,沈北新区,4,210100 +210114,于洪区,4,210100 +210115,辽中区,4,210100 +210123,康平县,4,210100 +210124,法库县,4,210100 +210181,新民市,4,210100 +210202,中山区,4,210200 +210203,西岗区,4,210200 +210204,沙河口区,4,210200 +210211,甘井子区,4,210200 +210212,旅顺口区,4,210200 +210213,金州区,4,210200 +210214,普兰店区,4,210200 +210224,长海县,4,210200 +210281,瓦房店市,4,210200 +210283,庄河市,4,210200 +210302,铁东区,4,210300 +210303,铁西区,4,210300 +210304,立山区,4,210300 +210311,千山区,4,210300 +210321,台安县,4,210300 +210323,岫岩满族自治县,4,210300 +210381,海城市,4,210300 +210402,新抚区,4,210400 +210403,东洲区,4,210400 +210404,望花区,4,210400 +210411,顺城区,4,210400 +210421,抚顺县,4,210400 +210422,新宾满族自治县,4,210400 +210423,清原满族自治县,4,210400 +210502,平山区,4,210500 +210503,溪湖区,4,210500 +210504,明山区,4,210500 +210505,南芬区,4,210500 +210521,本溪满族自治县,4,210500 +210522,桓仁满族自治县,4,210500 +210602,元宝区,4,210600 +210603,振兴区,4,210600 +210604,振安区,4,210600 +210624,宽甸满族自治县,4,210600 +210681,东港市,4,210600 +210682,凤城市,4,210600 +210702,古塔区,4,210700 +210703,凌河区,4,210700 +210711,太和区,4,210700 +210726,黑山县,4,210700 +210727,义县,4,210700 +210781,凌海市,4,210700 +210782,北镇市,4,210700 +210802,站前区,4,210800 +210803,西市区,4,210800 +210804,鲅鱼圈区,4,210800 +210811,老边区,4,210800 +210881,盖州市,4,210800 +210882,大石桥市,4,210800 +210902,海州区,4,210900 +210903,新邱区,4,210900 +210904,太平区,4,210900 +210905,清河门区,4,210900 +210911,细河区,4,210900 +210921,阜新蒙古族自治县,4,210900 +210922,彰武县,4,210900 +211002,白塔区,4,211000 +211003,文圣区,4,211000 +211004,宏伟区,4,211000 +211005,弓长岭区,4,211000 +211011,太子河区,4,211000 +211021,辽阳县,4,211000 +211081,灯塔市,4,211000 +211102,双台子区,4,211100 +211103,兴隆台区,4,211100 +211104,大洼区,4,211100 +211122,盘山县,4,211100 +211202,银州区,4,211200 +211204,清河区,4,211200 +211221,铁岭县,4,211200 +211223,西丰县,4,211200 +211224,昌图县,4,211200 +211281,调兵山市,4,211200 +211282,开原市,4,211200 +211302,双塔区,4,211300 +211303,龙城区,4,211300 +211321,朝阳县,4,211300 +211322,建平县,4,211300 +211324,喀喇沁左翼蒙古族自治县,4,211300 +211381,北票市,4,211300 +211382,凌源市,4,211300 +211402,连山区,4,211400 +211403,龙港区,4,211400 +211404,南票区,4,211400 +211421,绥中县,4,211400 +211422,建昌县,4,211400 +211481,兴城市,4,211400 +220102,南关区,4,220100 +220103,宽城区,4,220100 +220104,朝阳区,4,220100 +220105,二道区,4,220100 +220106,绿园区,4,220100 +220112,双阳区,4,220100 +220113,九台区,4,220100 +220122,农安县,4,220100 +220171,长春经济技术开发区,4,220100 +220172,长春净月高新技术产业开发区,4,220100 +220173,长春高新技术产业开发区,4,220100 +220174,长春汽车经济技术开发区,4,220100 +220182,榆树市,4,220100 +220183,德惠市,4,220100 +220184,公主岭市,4,220100 +220202,昌邑区,4,220200 +220203,龙潭区,4,220200 +220204,船营区,4,220200 +220211,丰满区,4,220200 +220221,永吉县,4,220200 +220271,吉林经济开发区,4,220200 +220272,吉林高新技术产业开发区,4,220200 +220273,吉林中国新加坡食品区,4,220200 +220281,蛟河市,4,220200 +220282,桦甸市,4,220200 +220283,舒兰市,4,220200 +220284,磐石市,4,220200 +220302,铁西区,4,220300 +220303,铁东区,4,220300 +220322,梨树县,4,220300 +220323,伊通满族自治县,4,220300 +220382,双辽市,4,220300 +220402,龙山区,4,220400 +220403,西安区,4,220400 +220421,东丰县,4,220400 +220422,东辽县,4,220400 +220502,东昌区,4,220500 +220503,二道江区,4,220500 +220521,通化县,4,220500 +220523,辉南县,4,220500 +220524,柳河县,4,220500 +220581,梅河口市,4,220500 +220582,集安市,4,220500 +220602,浑江区,4,220600 +220605,江源区,4,220600 +220621,抚松县,4,220600 +220622,靖宇县,4,220600 +220623,长白朝鲜族自治县,4,220600 +220681,临江市,4,220600 +220702,宁江区,4,220700 +220721,前郭尔罗斯蒙古族自治县,4,220700 +220722,长岭县,4,220700 +220723,乾安县,4,220700 +220771,吉林松原经济开发区,4,220700 +220781,扶余市,4,220700 +220802,洮北区,4,220800 +220821,镇赉县,4,220800 +220822,通榆县,4,220800 +220871,吉林白城经济开发区,4,220800 +220881,洮南市,4,220800 +220882,大安市,4,220800 +222401,延吉市,4,222400 +222402,图们市,4,222400 +222403,敦化市,4,222400 +222404,珲春市,4,222400 +222405,龙井市,4,222400 +222406,和龙市,4,222400 +222424,汪清县,4,222400 +222426,安图县,4,222400 +230102,道里区,4,230100 +230103,南岗区,4,230100 +230104,道外区,4,230100 +230108,平房区,4,230100 +230109,松北区,4,230100 +230110,香坊区,4,230100 +230111,呼兰区,4,230100 +230112,阿城区,4,230100 +230113,双城区,4,230100 +230123,依兰县,4,230100 +230124,方正县,4,230100 +230125,宾县,4,230100 +230126,巴彦县,4,230100 +230127,木兰县,4,230100 +230128,通河县,4,230100 +230129,延寿县,4,230100 +230183,尚志市,4,230100 +230184,五常市,4,230100 +230202,龙沙区,4,230200 +230203,建华区,4,230200 +230204,铁锋区,4,230200 +230205,昂昂溪区,4,230200 +230206,富拉尔基区,4,230200 +230207,碾子山区,4,230200 +230208,梅里斯达斡尔族区,4,230200 +230221,龙江县,4,230200 +230223,依安县,4,230200 +230224,泰来县,4,230200 +230225,甘南县,4,230200 +230227,富裕县,4,230200 +230229,克山县,4,230200 +230230,克东县,4,230200 +230231,拜泉县,4,230200 +230281,讷河市,4,230200 +230302,鸡冠区,4,230300 +230303,恒山区,4,230300 +230304,滴道区,4,230300 +230305,梨树区,4,230300 +230306,城子河区,4,230300 +230307,麻山区,4,230300 +230321,鸡东县,4,230300 +230381,虎林市,4,230300 +230382,密山市,4,230300 +230402,向阳区,4,230400 +230403,工农区,4,230400 +230404,南山区,4,230400 +230405,兴安区,4,230400 +230406,东山区,4,230400 +230407,兴山区,4,230400 +230421,萝北县,4,230400 +230422,绥滨县,4,230400 +230502,尖山区,4,230500 +230503,岭东区,4,230500 +230505,四方台区,4,230500 +230506,宝山区,4,230500 +230521,集贤县,4,230500 +230522,友谊县,4,230500 +230523,宝清县,4,230500 +230524,饶河县,4,230500 +230602,萨尔图区,4,230600 +230603,龙凤区,4,230600 +230604,让胡路区,4,230600 +230605,红岗区,4,230600 +230606,大同区,4,230600 +230621,肇州县,4,230600 +230622,肇源县,4,230600 +230623,林甸县,4,230600 +230624,杜尔伯特蒙古族自治县,4,230600 +230671,大庆高新技术产业开发区,4,230600 +230717,伊美区,4,230700 +230718,乌翠区,4,230700 +230719,友好区,4,230700 +230722,嘉荫县,4,230700 +230723,汤旺县,4,230700 +230724,丰林县,4,230700 +230725,大箐山县,4,230700 +230726,南岔县,4,230700 +230751,金林区,4,230700 +230781,铁力市,4,230700 +230803,向阳区,4,230800 +230804,前进区,4,230800 +230805,东风区,4,230800 +230811,郊区,4,230800 +230822,桦南县,4,230800 +230826,桦川县,4,230800 +230828,汤原县,4,230800 +230881,同江市,4,230800 +230882,富锦市,4,230800 +230883,抚远市,4,230800 +230902,新兴区,4,230900 +230903,桃山区,4,230900 +230904,茄子河区,4,230900 +230921,勃利县,4,230900 +231002,东安区,4,231000 +231003,阳明区,4,231000 +231004,爱民区,4,231000 +231005,西安区,4,231000 +231025,林口县,4,231000 +231071,牡丹江经济技术开发区,4,231000 +231081,绥芬河市,4,231000 +231083,海林市,4,231000 +231084,宁安市,4,231000 +231085,穆棱市,4,231000 +231086,东宁市,4,231000 +231102,爱辉区,4,231100 +231123,逊克县,4,231100 +231124,孙吴县,4,231100 +231181,北安市,4,231100 +231182,五大连池市,4,231100 +231183,嫩江市,4,231100 +231202,北林区,4,231200 +231221,望奎县,4,231200 +231222,兰西县,4,231200 +231223,青冈县,4,231200 +231224,庆安县,4,231200 +231225,明水县,4,231200 +231226,绥棱县,4,231200 +231281,安达市,4,231200 +231282,肇东市,4,231200 +231283,海伦市,4,231200 +232701,漠河市,4,232700 +232721,呼玛县,4,232700 +232722,塔河县,4,232700 +232761,加格达奇区,4,232700 +232762,松岭区,4,232700 +232763,新林区,4,232700 +232764,呼中区,4,232700 +310101,黄浦区,4,310100 +310104,徐汇区,4,310100 +310105,长宁区,4,310100 +310106,静安区,4,310100 +310107,普陀区,4,310100 +310109,虹口区,4,310100 +310110,杨浦区,4,310100 +310112,闵行区,4,310100 +310113,宝山区,4,310100 +310114,嘉定区,4,310100 +310115,浦东新区,4,310100 +310116,金山区,4,310100 +310117,松江区,4,310100 +310118,青浦区,4,310100 +310120,奉贤区,4,310100 +310151,崇明区,4,310100 +320102,玄武区,4,320100 +320104,秦淮区,4,320100 +320105,建邺区,4,320100 +320106,鼓楼区,4,320100 +320111,浦口区,4,320100 +320113,栖霞区,4,320100 +320114,雨花台区,4,320100 +320115,江宁区,4,320100 +320116,六合区,4,320100 +320117,溧水区,4,320100 +320118,高淳区,4,320100 +320205,锡山区,4,320200 +320206,惠山区,4,320200 +320211,滨湖区,4,320200 +320213,梁溪区,4,320200 +320214,新吴区,4,320200 +320281,江阴市,4,320200 +320282,宜兴市,4,320200 +320302,鼓楼区,4,320300 +320303,云龙区,4,320300 +320305,贾汪区,4,320300 +320311,泉山区,4,320300 +320312,铜山区,4,320300 +320321,丰县,4,320300 +320322,沛县,4,320300 +320324,睢宁县,4,320300 +320371,徐州经济技术开发区,4,320300 +320381,新沂市,4,320300 +320382,邳州市,4,320300 +320402,天宁区,4,320400 +320404,钟楼区,4,320400 +320411,新北区,4,320400 +320412,武进区,4,320400 +320413,金坛区,4,320400 +320481,溧阳市,4,320400 +320505,虎丘区,4,320500 +320506,吴中区,4,320500 +320507,相城区,4,320500 +320508,姑苏区,4,320500 +320509,吴江区,4,320500 +320571,苏州工业园区,4,320500 +320581,常熟市,4,320500 +320582,张家港市,4,320500 +320583,昆山市,4,320500 +320585,太仓市,4,320500 +320612,通州区,4,320600 +320613,崇川区,4,320600 +320614,海门区,4,320600 +320623,如东县,4,320600 +320671,南通经济技术开发区,4,320600 +320681,启东市,4,320600 +320682,如皋市,4,320600 +320685,海安市,4,320600 +320703,连云区,4,320700 +320706,海州区,4,320700 +320707,赣榆区,4,320700 +320722,东海县,4,320700 +320723,灌云县,4,320700 +320724,灌南县,4,320700 +320771,连云港经济技术开发区,4,320700 +320772,连云港高新技术产业开发区,4,320700 +320803,淮安区,4,320800 +320804,淮阴区,4,320800 +320812,清江浦区,4,320800 +320813,洪泽区,4,320800 +320826,涟水县,4,320800 +320830,盱眙县,4,320800 +320831,金湖县,4,320800 +320871,淮安经济技术开发区,4,320800 +320902,亭湖区,4,320900 +320903,盐都区,4,320900 +320904,大丰区,4,320900 +320921,响水县,4,320900 +320922,滨海县,4,320900 +320923,阜宁县,4,320900 +320924,射阳县,4,320900 +320925,建湖县,4,320900 +320971,盐城经济技术开发区,4,320900 +320981,东台市,4,320900 +321002,广陵区,4,321000 +321003,邗江区,4,321000 +321012,江都区,4,321000 +321023,宝应县,4,321000 +321071,扬州经济技术开发区,4,321000 +321081,仪征市,4,321000 +321084,高邮市,4,321000 +321102,京口区,4,321100 +321111,润州区,4,321100 +321112,丹徒区,4,321100 +321171,镇江新区,4,321100 +321181,丹阳市,4,321100 +321182,扬中市,4,321100 +321183,句容市,4,321100 +321202,海陵区,4,321200 +321203,高港区,4,321200 +321204,姜堰区,4,321200 +321271,泰州医药高新技术产业开发区,4,321200 +321281,兴化市,4,321200 +321282,靖江市,4,321200 +321283,泰兴市,4,321200 +321302,宿城区,4,321300 +321311,宿豫区,4,321300 +321322,沭阳县,4,321300 +321323,泗阳县,4,321300 +321324,泗洪县,4,321300 +321371,宿迁经济技术开发区,4,321300 +330102,上城区,4,330100 +330105,拱墅区,4,330100 +330106,西湖区,4,330100 +330108,滨江区,4,330100 +330109,萧山区,4,330100 +330110,余杭区,4,330100 +330111,富阳区,4,330100 +330112,临安区,4,330100 +330113,临平区,4,330100 +330114,钱塘区,4,330100 +330122,桐庐县,4,330100 +330127,淳安县,4,330100 +330182,建德市,4,330100 +330203,海曙区,4,330200 +330205,江北区,4,330200 +330206,北仑区,4,330200 +330211,镇海区,4,330200 +330212,鄞州区,4,330200 +330213,奉化区,4,330200 +330225,象山县,4,330200 +330226,宁海县,4,330200 +330281,余姚市,4,330200 +330282,慈溪市,4,330200 +330302,鹿城区,4,330300 +330303,龙湾区,4,330300 +330304,瓯海区,4,330300 +330305,洞头区,4,330300 +330324,永嘉县,4,330300 +330326,平阳县,4,330300 +330327,苍南县,4,330300 +330328,文成县,4,330300 +330329,泰顺县,4,330300 +330371,温州经济技术开发区,4,330300 +330381,瑞安市,4,330300 +330382,乐清市,4,330300 +330383,龙港市,4,330300 +330402,南湖区,4,330400 +330411,秀洲区,4,330400 +330421,嘉善县,4,330400 +330424,海盐县,4,330400 +330481,海宁市,4,330400 +330482,平湖市,4,330400 +330483,桐乡市,4,330400 +330502,吴兴区,4,330500 +330503,南浔区,4,330500 +330521,德清县,4,330500 +330522,长兴县,4,330500 +330523,安吉县,4,330500 +330602,越城区,4,330600 +330603,柯桥区,4,330600 +330604,上虞区,4,330600 +330624,新昌县,4,330600 +330681,诸暨市,4,330600 +330683,嵊州市,4,330600 +330702,婺城区,4,330700 +330703,金东区,4,330700 +330723,武义县,4,330700 +330726,浦江县,4,330700 +330727,磐安县,4,330700 +330781,兰溪市,4,330700 +330782,义乌市,4,330700 +330783,东阳市,4,330700 +330784,永康市,4,330700 +330802,柯城区,4,330800 +330803,衢江区,4,330800 +330822,常山县,4,330800 +330824,开化县,4,330800 +330825,龙游县,4,330800 +330881,江山市,4,330800 +330902,定海区,4,330900 +330903,普陀区,4,330900 +330921,岱山县,4,330900 +330922,嵊泗县,4,330900 +331002,椒江区,4,331000 +331003,黄岩区,4,331000 +331004,路桥区,4,331000 +331022,三门县,4,331000 +331023,天台县,4,331000 +331024,仙居县,4,331000 +331081,温岭市,4,331000 +331082,临海市,4,331000 +331083,玉环市,4,331000 +331102,莲都区,4,331100 +331121,青田县,4,331100 +331122,缙云县,4,331100 +331123,遂昌县,4,331100 +331124,松阳县,4,331100 +331125,云和县,4,331100 +331126,庆元县,4,331100 +331127,景宁畲族自治县,4,331100 +331181,龙泉市,4,331100 +340102,瑶海区,4,340100 +340103,庐阳区,4,340100 +340104,蜀山区,4,340100 +340111,包河区,4,340100 +340121,长丰县,4,340100 +340122,肥东县,4,340100 +340123,肥西县,4,340100 +340124,庐江县,4,340100 +340171,合肥高新技术产业开发区,4,340100 +340172,合肥经济技术开发区,4,340100 +340173,合肥新站高新技术产业开发区,4,340100 +340181,巢湖市,4,340100 +340202,镜湖区,4,340200 +340207,鸠江区,4,340200 +340209,弋江区,4,340200 +340210,湾沚区,4,340200 +340212,繁昌区,4,340200 +340223,南陵县,4,340200 +340271,芜湖经济技术开发区,4,340200 +340272,安徽芜湖三山经济开发区,4,340200 +340281,无为市,4,340200 +340302,龙子湖区,4,340300 +340303,蚌山区,4,340300 +340304,禹会区,4,340300 +340311,淮上区,4,340300 +340321,怀远县,4,340300 +340322,五河县,4,340300 +340323,固镇县,4,340300 +340371,蚌埠市高新技术开发区,4,340300 +340372,蚌埠市经济开发区,4,340300 +340402,大通区,4,340400 +340403,田家庵区,4,340400 +340404,谢家集区,4,340400 +340405,八公山区,4,340400 +340406,潘集区,4,340400 +340421,凤台县,4,340400 +340422,寿县,4,340400 +340503,花山区,4,340500 +340504,雨山区,4,340500 +340506,博望区,4,340500 +340521,当涂县,4,340500 +340522,含山县,4,340500 +340523,和县,4,340500 +340602,杜集区,4,340600 +340603,相山区,4,340600 +340604,烈山区,4,340600 +340621,濉溪县,4,340600 +340705,铜官区,4,340700 +340706,义安区,4,340700 +340711,郊区,4,340700 +340722,枞阳县,4,340700 +340802,迎江区,4,340800 +340803,大观区,4,340800 +340811,宜秀区,4,340800 +340822,怀宁县,4,340800 +340825,太湖县,4,340800 +340826,宿松县,4,340800 +340827,望江县,4,340800 +340828,岳西县,4,340800 +340871,安徽安庆经济开发区,4,340800 +340881,桐城市,4,340800 +340882,潜山市,4,340800 +341002,屯溪区,4,341000 +341003,黄山区,4,341000 +341004,徽州区,4,341000 +341021,歙县,4,341000 +341022,休宁县,4,341000 +341023,黟县,4,341000 +341024,祁门县,4,341000 +341102,琅琊区,4,341100 +341103,南谯区,4,341100 +341122,来安县,4,341100 +341124,全椒县,4,341100 +341125,定远县,4,341100 +341126,凤阳县,4,341100 +341171,中新苏滁高新技术产业开发区,4,341100 +341172,滁州经济技术开发区,4,341100 +341181,天长市,4,341100 +341182,明光市,4,341100 +341202,颍州区,4,341200 +341203,颍东区,4,341200 +341204,颍泉区,4,341200 +341221,临泉县,4,341200 +341222,太和县,4,341200 +341225,阜南县,4,341200 +341226,颍上县,4,341200 +341271,阜阳合肥现代产业园区,4,341200 +341272,阜阳经济技术开发区,4,341200 +341282,界首市,4,341200 +341302,埇桥区,4,341300 +341321,砀山县,4,341300 +341322,萧县,4,341300 +341323,灵璧县,4,341300 +341324,泗县,4,341300 +341371,宿州马鞍山现代产业园区,4,341300 +341372,宿州经济技术开发区,4,341300 +341502,金安区,4,341500 +341503,裕安区,4,341500 +341504,叶集区,4,341500 +341522,霍邱县,4,341500 +341523,舒城县,4,341500 +341524,金寨县,4,341500 +341525,霍山县,4,341500 +341602,谯城区,4,341600 +341621,涡阳县,4,341600 +341622,蒙城县,4,341600 +341623,利辛县,4,341600 +341702,贵池区,4,341700 +341721,东至县,4,341700 +341722,石台县,4,341700 +341723,青阳县,4,341700 +341802,宣州区,4,341800 +341821,郎溪县,4,341800 +341823,泾县,4,341800 +341824,绩溪县,4,341800 +341825,旌德县,4,341800 +341871,宣城市经济开发区,4,341800 +341881,宁国市,4,341800 +341882,广德市,4,341800 +350102,鼓楼区,4,350100 +350103,台江区,4,350100 +350104,仓山区,4,350100 +350105,马尾区,4,350100 +350111,晋安区,4,350100 +350112,长乐区,4,350100 +350121,闽侯县,4,350100 +350122,连江县,4,350100 +350123,罗源县,4,350100 +350124,闽清县,4,350100 +350125,永泰县,4,350100 +350128,平潭县,4,350100 +350181,福清市,4,350100 +350203,思明区,4,350200 +350205,海沧区,4,350200 +350206,湖里区,4,350200 +350211,集美区,4,350200 +350212,同安区,4,350200 +350213,翔安区,4,350200 +350302,城厢区,4,350300 +350303,涵江区,4,350300 +350304,荔城区,4,350300 +350305,秀屿区,4,350300 +350322,仙游县,4,350300 +350404,三元区,4,350400 +350405,沙县区,4,350400 +350421,明溪县,4,350400 +350423,清流县,4,350400 +350424,宁化县,4,350400 +350425,大田县,4,350400 +350426,尤溪县,4,350400 +350428,将乐县,4,350400 +350429,泰宁县,4,350400 +350430,建宁县,4,350400 +350481,永安市,4,350400 +350502,鲤城区,4,350500 +350503,丰泽区,4,350500 +350504,洛江区,4,350500 +350505,泉港区,4,350500 +350521,惠安县,4,350500 +350524,安溪县,4,350500 +350525,永春县,4,350500 +350526,德化县,4,350500 +350527,金门县,4,350500 +350581,石狮市,4,350500 +350582,晋江市,4,350500 +350583,南安市,4,350500 +350602,芗城区,4,350600 +350603,龙文区,4,350600 +350604,龙海区,4,350600 +350605,长泰区,4,350600 +350622,云霄县,4,350600 +350623,漳浦县,4,350600 +350624,诏安县,4,350600 +350626,东山县,4,350600 +350627,南靖县,4,350600 +350628,平和县,4,350600 +350629,华安县,4,350600 +350702,延平区,4,350700 +350703,建阳区,4,350700 +350721,顺昌县,4,350700 +350722,浦城县,4,350700 +350723,光泽县,4,350700 +350724,松溪县,4,350700 +350725,政和县,4,350700 +350781,邵武市,4,350700 +350782,武夷山市,4,350700 +350783,建瓯市,4,350700 +350802,新罗区,4,350800 +350803,永定区,4,350800 +350821,长汀县,4,350800 +350823,上杭县,4,350800 +350824,武平县,4,350800 +350825,连城县,4,350800 +350881,漳平市,4,350800 +350902,蕉城区,4,350900 +350921,霞浦县,4,350900 +350922,古田县,4,350900 +350923,屏南县,4,350900 +350924,寿宁县,4,350900 +350925,周宁县,4,350900 +350926,柘荣县,4,350900 +350981,福安市,4,350900 +350982,福鼎市,4,350900 +360102,东湖区,4,360100 +360103,西湖区,4,360100 +360104,青云谱区,4,360100 +360111,青山湖区,4,360100 +360112,新建区,4,360100 +360113,红谷滩区,4,360100 +360121,南昌县,4,360100 +360123,安义县,4,360100 +360124,进贤县,4,360100 +360202,昌江区,4,360200 +360203,珠山区,4,360200 +360222,浮梁县,4,360200 +360281,乐平市,4,360200 +360302,安源区,4,360300 +360313,湘东区,4,360300 +360321,莲花县,4,360300 +360322,上栗县,4,360300 +360323,芦溪县,4,360300 +360402,濂溪区,4,360400 +360403,浔阳区,4,360400 +360404,柴桑区,4,360400 +360423,武宁县,4,360400 +360424,修水县,4,360400 +360425,永修县,4,360400 +360426,德安县,4,360400 +360428,都昌县,4,360400 +360429,湖口县,4,360400 +360430,彭泽县,4,360400 +360481,瑞昌市,4,360400 +360482,共青城市,4,360400 +360483,庐山市,4,360400 +360502,渝水区,4,360500 +360521,分宜县,4,360500 +360602,月湖区,4,360600 +360603,余江区,4,360600 +360681,贵溪市,4,360600 +360702,章贡区,4,360700 +360703,南康区,4,360700 +360704,赣县区,4,360700 +360722,信丰县,4,360700 +360723,大余县,4,360700 +360724,上犹县,4,360700 +360725,崇义县,4,360700 +360726,安远县,4,360700 +360728,定南县,4,360700 +360729,全南县,4,360700 +360730,宁都县,4,360700 +360731,于都县,4,360700 +360732,兴国县,4,360700 +360733,会昌县,4,360700 +360734,寻乌县,4,360700 +360735,石城县,4,360700 +360781,瑞金市,4,360700 +360783,龙南市,4,360700 +360802,吉州区,4,360800 +360803,青原区,4,360800 +360821,吉安县,4,360800 +360822,吉水县,4,360800 +360823,峡江县,4,360800 +360824,新干县,4,360800 +360825,永丰县,4,360800 +360826,泰和县,4,360800 +360827,遂川县,4,360800 +360828,万安县,4,360800 +360829,安福县,4,360800 +360830,永新县,4,360800 +360881,井冈山市,4,360800 +360902,袁州区,4,360900 +360921,奉新县,4,360900 +360922,万载县,4,360900 +360923,上高县,4,360900 +360924,宜丰县,4,360900 +360925,靖安县,4,360900 +360926,铜鼓县,4,360900 +360981,丰城市,4,360900 +360982,樟树市,4,360900 +360983,高安市,4,360900 +361002,临川区,4,361000 +361003,东乡区,4,361000 +361021,南城县,4,361000 +361022,黎川县,4,361000 +361023,南丰县,4,361000 +361024,崇仁县,4,361000 +361025,乐安县,4,361000 +361026,宜黄县,4,361000 +361027,金溪县,4,361000 +361028,资溪县,4,361000 +361030,广昌县,4,361000 +361102,信州区,4,361100 +361103,广丰区,4,361100 +361104,广信区,4,361100 +361123,玉山县,4,361100 +361124,铅山县,4,361100 +361125,横峰县,4,361100 +361126,弋阳县,4,361100 +361127,余干县,4,361100 +361128,鄱阳县,4,361100 +361129,万年县,4,361100 +361130,婺源县,4,361100 +361181,德兴市,4,361100 +370102,历下区,4,370100 +370103,市中区,4,370100 +370104,槐荫区,4,370100 +370105,天桥区,4,370100 +370112,历城区,4,370100 +370113,长清区,4,370100 +370114,章丘区,4,370100 +370115,济阳区,4,370100 +370116,莱芜区,4,370100 +370117,钢城区,4,370100 +370124,平阴县,4,370100 +370126,商河县,4,370100 +370171,济南高新技术产业开发区,4,370100 +370202,市南区,4,370200 +370203,市北区,4,370200 +370211,黄岛区,4,370200 +370212,崂山区,4,370200 +370213,李沧区,4,370200 +370214,城阳区,4,370200 +370215,即墨区,4,370200 +370271,青岛高新技术产业开发区,4,370200 +370281,胶州市,4,370200 +370283,平度市,4,370200 +370285,莱西市,4,370200 +370302,淄川区,4,370300 +370303,张店区,4,370300 +370304,博山区,4,370300 +370305,临淄区,4,370300 +370306,周村区,4,370300 +370321,桓台县,4,370300 +370322,高青县,4,370300 +370323,沂源县,4,370300 +370402,市中区,4,370400 +370403,薛城区,4,370400 +370404,峄城区,4,370400 +370405,台儿庄区,4,370400 +370406,山亭区,4,370400 +370481,滕州市,4,370400 +370502,东营区,4,370500 +370503,河口区,4,370500 +370505,垦利区,4,370500 +370522,利津县,4,370500 +370523,广饶县,4,370500 +370571,东营经济技术开发区,4,370500 +370572,东营港经济开发区,4,370500 +370602,芝罘区,4,370600 +370611,福山区,4,370600 +370612,牟平区,4,370600 +370613,莱山区,4,370600 +370614,蓬莱区,4,370600 +370671,烟台高新技术产业开发区,4,370600 +370672,烟台经济技术开发区,4,370600 +370681,龙口市,4,370600 +370682,莱阳市,4,370600 +370683,莱州市,4,370600 +370685,招远市,4,370600 +370686,栖霞市,4,370600 +370687,海阳市,4,370600 +370702,潍城区,4,370700 +370703,寒亭区,4,370700 +370704,坊子区,4,370700 +370705,奎文区,4,370700 +370724,临朐县,4,370700 +370725,昌乐县,4,370700 +370772,潍坊滨海经济技术开发区,4,370700 +370781,青州市,4,370700 +370782,诸城市,4,370700 +370783,寿光市,4,370700 +370784,安丘市,4,370700 +370785,高密市,4,370700 +370786,昌邑市,4,370700 +370811,任城区,4,370800 +370812,兖州区,4,370800 +370826,微山县,4,370800 +370827,鱼台县,4,370800 +370828,金乡县,4,370800 +370829,嘉祥县,4,370800 +370830,汶上县,4,370800 +370831,泗水县,4,370800 +370832,梁山县,4,370800 +370871,济宁高新技术产业开发区,4,370800 +370881,曲阜市,4,370800 +370883,邹城市,4,370800 +370902,泰山区,4,370900 +370911,岱岳区,4,370900 +370921,宁阳县,4,370900 +370923,东平县,4,370900 +370982,新泰市,4,370900 +370983,肥城市,4,370900 +371002,环翠区,4,371000 +371003,文登区,4,371000 +371071,威海火炬高技术产业开发区,4,371000 +371072,威海经济技术开发区,4,371000 +371073,威海临港经济技术开发区,4,371000 +371082,荣成市,4,371000 +371083,乳山市,4,371000 +371102,东港区,4,371100 +371103,岚山区,4,371100 +371121,五莲县,4,371100 +371122,莒县,4,371100 +371171,日照经济技术开发区,4,371100 +371302,兰山区,4,371300 +371311,罗庄区,4,371300 +371312,河东区,4,371300 +371321,沂南县,4,371300 +371322,郯城县,4,371300 +371323,沂水县,4,371300 +371324,兰陵县,4,371300 +371325,费县,4,371300 +371326,平邑县,4,371300 +371327,莒南县,4,371300 +371328,蒙阴县,4,371300 +371329,临沭县,4,371300 +371371,临沂高新技术产业开发区,4,371300 +371402,德城区,4,371400 +371403,陵城区,4,371400 +371422,宁津县,4,371400 +371423,庆云县,4,371400 +371424,临邑县,4,371400 +371425,齐河县,4,371400 +371426,平原县,4,371400 +371427,夏津县,4,371400 +371428,武城县,4,371400 +371471,德州经济技术开发区,4,371400 +371472,德州运河经济开发区,4,371400 +371481,乐陵市,4,371400 +371482,禹城市,4,371400 +371502,东昌府区,4,371500 +371503,茌平区,4,371500 +371521,阳谷县,4,371500 +371522,莘县,4,371500 +371524,东阿县,4,371500 +371525,冠县,4,371500 +371526,高唐县,4,371500 +371581,临清市,4,371500 +371602,滨城区,4,371600 +371603,沾化区,4,371600 +371621,惠民县,4,371600 +371622,阳信县,4,371600 +371623,无棣县,4,371600 +371625,博兴县,4,371600 +371681,邹平市,4,371600 +371702,牡丹区,4,371700 +371703,定陶区,4,371700 +371721,曹县,4,371700 +371722,单县,4,371700 +371723,成武县,4,371700 +371724,巨野县,4,371700 +371725,郓城县,4,371700 +371726,鄄城县,4,371700 +371728,东明县,4,371700 +371771,菏泽经济技术开发区,4,371700 +371772,菏泽高新技术开发区,4,371700 +410102,中原区,4,410100 +410103,二七区,4,410100 +410104,管城回族区,4,410100 +410105,金水区,4,410100 +410106,上街区,4,410100 +410108,惠济区,4,410100 +410122,中牟县,4,410100 +410171,郑州经济技术开发区,4,410100 +410172,郑州高新技术产业开发区,4,410100 +410173,郑州航空港经济综合实验区,4,410100 +410181,巩义市,4,410100 +410182,荥阳市,4,410100 +410183,新密市,4,410100 +410184,新郑市,4,410100 +410185,登封市,4,410100 +410202,龙亭区,4,410200 +410203,顺河回族区,4,410200 +410204,鼓楼区,4,410200 +410205,禹王台区,4,410200 +410212,祥符区,4,410200 +410221,杞县,4,410200 +410222,通许县,4,410200 +410223,尉氏县,4,410200 +410225,兰考县,4,410200 +410302,老城区,4,410300 +410303,西工区,4,410300 +410304,瀍河回族区,4,410300 +410305,涧西区,4,410300 +410307,偃师区,4,410300 +410308,孟津区,4,410300 +410311,洛龙区,4,410300 +410323,新安县,4,410300 +410324,栾川县,4,410300 +410325,嵩县,4,410300 +410326,汝阳县,4,410300 +410327,宜阳县,4,410300 +410328,洛宁县,4,410300 +410329,伊川县,4,410300 +410371,洛阳高新技术产业开发区,4,410300 +410402,新华区,4,410400 +410403,卫东区,4,410400 +410404,石龙区,4,410400 +410411,湛河区,4,410400 +410421,宝丰县,4,410400 +410422,叶县,4,410400 +410423,鲁山县,4,410400 +410425,郏县,4,410400 +410471,平顶山高新技术产业开发区,4,410400 +410472,平顶山市城乡一体化示范区,4,410400 +410481,舞钢市,4,410400 +410482,汝州市,4,410400 +410502,文峰区,4,410500 +410503,北关区,4,410500 +410505,殷都区,4,410500 +410506,龙安区,4,410500 +410522,安阳县,4,410500 +410523,汤阴县,4,410500 +410526,滑县,4,410500 +410527,内黄县,4,410500 +410571,安阳高新技术产业开发区,4,410500 +410581,林州市,4,410500 +410602,鹤山区,4,410600 +410603,山城区,4,410600 +410611,淇滨区,4,410600 +410621,浚县,4,410600 +410622,淇县,4,410600 +410671,鹤壁经济技术开发区,4,410600 +410702,红旗区,4,410700 +410703,卫滨区,4,410700 +410704,凤泉区,4,410700 +410711,牧野区,4,410700 +410721,新乡县,4,410700 +410724,获嘉县,4,410700 +410725,原阳县,4,410700 +410726,延津县,4,410700 +410727,封丘县,4,410700 +410771,新乡高新技术产业开发区,4,410700 +410772,新乡经济技术开发区,4,410700 +410773,新乡市平原城乡一体化示范区,4,410700 +410781,卫辉市,4,410700 +410782,辉县市,4,410700 +410783,长垣市,4,410700 +410802,解放区,4,410800 +410803,中站区,4,410800 +410804,马村区,4,410800 +410811,山阳区,4,410800 +410821,修武县,4,410800 +410822,博爱县,4,410800 +410823,武陟县,4,410800 +410825,温县,4,410800 +410871,焦作城乡一体化示范区,4,410800 +410882,沁阳市,4,410800 +410883,孟州市,4,410800 +410902,华龙区,4,410900 +410922,清丰县,4,410900 +410923,南乐县,4,410900 +410926,范县,4,410900 +410927,台前县,4,410900 +410928,濮阳县,4,410900 +410971,河南濮阳工业园区,4,410900 +410972,濮阳经济技术开发区,4,410900 +411002,魏都区,4,411000 +411003,建安区,4,411000 +411024,鄢陵县,4,411000 +411025,襄城县,4,411000 +411071,许昌经济技术开发区,4,411000 +411081,禹州市,4,411000 +411082,长葛市,4,411000 +411102,源汇区,4,411100 +411103,郾城区,4,411100 +411104,召陵区,4,411100 +411121,舞阳县,4,411100 +411122,临颍县,4,411100 +411171,漯河经济技术开发区,4,411100 +411202,湖滨区,4,411200 +411203,陕州区,4,411200 +411221,渑池县,4,411200 +411224,卢氏县,4,411200 +411271,河南三门峡经济开发区,4,411200 +411281,义马市,4,411200 +411282,灵宝市,4,411200 +411302,宛城区,4,411300 +411303,卧龙区,4,411300 +411321,南召县,4,411300 +411322,方城县,4,411300 +411323,西峡县,4,411300 +411324,镇平县,4,411300 +411325,内乡县,4,411300 +411326,淅川县,4,411300 +411327,社旗县,4,411300 +411328,唐河县,4,411300 +411329,新野县,4,411300 +411330,桐柏县,4,411300 +411371,南阳高新技术产业开发区,4,411300 +411372,南阳市城乡一体化示范区,4,411300 +411381,邓州市,4,411300 +411402,梁园区,4,411400 +411403,睢阳区,4,411400 +411421,民权县,4,411400 +411422,睢县,4,411400 +411423,宁陵县,4,411400 +411424,柘城县,4,411400 +411425,虞城县,4,411400 +411426,夏邑县,4,411400 +411471,豫东综合物流产业聚集区,4,411400 +411472,河南商丘经济开发区,4,411400 +411481,永城市,4,411400 +411502,浉河区,4,411500 +411503,平桥区,4,411500 +411521,罗山县,4,411500 +411522,光山县,4,411500 +411523,新县,4,411500 +411524,商城县,4,411500 +411525,固始县,4,411500 +411526,潢川县,4,411500 +411527,淮滨县,4,411500 +411528,息县,4,411500 +411571,信阳高新技术产业开发区,4,411500 +411602,川汇区,4,411600 +411603,淮阳区,4,411600 +411621,扶沟县,4,411600 +411622,西华县,4,411600 +411623,商水县,4,411600 +411624,沈丘县,4,411600 +411625,郸城县,4,411600 +411627,太康县,4,411600 +411628,鹿邑县,4,411600 +411671,河南周口经济开发区,4,411600 +411681,项城市,4,411600 +411702,驿城区,4,411700 +411721,西平县,4,411700 +411722,上蔡县,4,411700 +411723,平舆县,4,411700 +411724,正阳县,4,411700 +411725,确山县,4,411700 +411726,泌阳县,4,411700 +411727,汝南县,4,411700 +411728,遂平县,4,411700 +411729,新蔡县,4,411700 +411771,河南驻马店经济开发区,4,411700 +419001,济源市,4,419000 +420102,江岸区,4,420100 +420103,江汉区,4,420100 +420104,硚口区,4,420100 +420105,汉阳区,4,420100 +420106,武昌区,4,420100 +420107,青山区,4,420100 +420111,洪山区,4,420100 +420112,东西湖区,4,420100 +420113,汉南区,4,420100 +420114,蔡甸区,4,420100 +420115,江夏区,4,420100 +420116,黄陂区,4,420100 +420117,新洲区,4,420100 +420202,黄石港区,4,420200 +420203,西塞山区,4,420200 +420204,下陆区,4,420200 +420205,铁山区,4,420200 +420222,阳新县,4,420200 +420281,大冶市,4,420200 +420302,茅箭区,4,420300 +420303,张湾区,4,420300 +420304,郧阳区,4,420300 +420322,郧西县,4,420300 +420323,竹山县,4,420300 +420324,竹溪县,4,420300 +420325,房县,4,420300 +420381,丹江口市,4,420300 +420502,西陵区,4,420500 +420503,伍家岗区,4,420500 +420504,点军区,4,420500 +420505,猇亭区,4,420500 +420506,夷陵区,4,420500 +420525,远安县,4,420500 +420526,兴山县,4,420500 +420527,秭归县,4,420500 +420528,长阳土家族自治县,4,420500 +420529,五峰土家族自治县,4,420500 +420581,宜都市,4,420500 +420582,当阳市,4,420500 +420583,枝江市,4,420500 +420602,襄城区,4,420600 +420606,樊城区,4,420600 +420607,襄州区,4,420600 +420624,南漳县,4,420600 +420625,谷城县,4,420600 +420626,保康县,4,420600 +420682,老河口市,4,420600 +420683,枣阳市,4,420600 +420684,宜城市,4,420600 +420702,梁子湖区,4,420700 +420703,华容区,4,420700 +420704,鄂城区,4,420700 +420802,东宝区,4,420800 +420804,掇刀区,4,420800 +420822,沙洋县,4,420800 +420881,钟祥市,4,420800 +420882,京山市,4,420800 +420902,孝南区,4,420900 +420921,孝昌县,4,420900 +420922,大悟县,4,420900 +420923,云梦县,4,420900 +420981,应城市,4,420900 +420982,安陆市,4,420900 +420984,汉川市,4,420900 +421002,沙市区,4,421000 +421003,荆州区,4,421000 +421022,公安县,4,421000 +421024,江陵县,4,421000 +421071,荆州经济技术开发区,4,421000 +421081,石首市,4,421000 +421083,洪湖市,4,421000 +421087,松滋市,4,421000 +421088,监利市,4,421000 +421102,黄州区,4,421100 +421121,团风县,4,421100 +421122,红安县,4,421100 +421123,罗田县,4,421100 +421124,英山县,4,421100 +421125,浠水县,4,421100 +421126,蕲春县,4,421100 +421127,黄梅县,4,421100 +421171,龙感湖管理区,4,421100 +421181,麻城市,4,421100 +421182,武穴市,4,421100 +421202,咸安区,4,421200 +421221,嘉鱼县,4,421200 +421222,通城县,4,421200 +421223,崇阳县,4,421200 +421224,通山县,4,421200 +421281,赤壁市,4,421200 +421303,曾都区,4,421300 +421321,随县,4,421300 +421381,广水市,4,421300 +422801,恩施市,4,422800 +422802,利川市,4,422800 +422822,建始县,4,422800 +422823,巴东县,4,422800 +422825,宣恩县,4,422800 +422826,咸丰县,4,422800 +422827,来凤县,4,422800 +422828,鹤峰县,4,422800 +429004,仙桃市,4,429000 +429005,潜江市,4,429000 +429006,天门市,4,429000 +429021,神农架林区,4,429000 +430102,芙蓉区,4,430100 +430103,天心区,4,430100 +430104,岳麓区,4,430100 +430105,开福区,4,430100 +430111,雨花区,4,430100 +430112,望城区,4,430100 +430121,长沙县,4,430100 +430181,浏阳市,4,430100 +430182,宁乡市,4,430100 +430202,荷塘区,4,430200 +430203,芦淞区,4,430200 +430204,石峰区,4,430200 +430211,天元区,4,430200 +430212,渌口区,4,430200 +430223,攸县,4,430200 +430224,茶陵县,4,430200 +430225,炎陵县,4,430200 +430271,云龙示范区,4,430200 +430281,醴陵市,4,430200 +430302,雨湖区,4,430300 +430304,岳塘区,4,430300 +430321,湘潭县,4,430300 +430371,湖南湘潭高新技术产业园区,4,430300 +430372,湘潭昭山示范区,4,430300 +430373,湘潭九华示范区,4,430300 +430381,湘乡市,4,430300 +430382,韶山市,4,430300 +430405,珠晖区,4,430400 +430406,雁峰区,4,430400 +430407,石鼓区,4,430400 +430408,蒸湘区,4,430400 +430412,南岳区,4,430400 +430421,衡阳县,4,430400 +430422,衡南县,4,430400 +430423,衡山县,4,430400 +430424,衡东县,4,430400 +430426,祁东县,4,430400 +430471,衡阳综合保税区,4,430400 +430472,湖南衡阳高新技术产业园区,4,430400 +430473,湖南衡阳松木经济开发区,4,430400 +430481,耒阳市,4,430400 +430482,常宁市,4,430400 +430502,双清区,4,430500 +430503,大祥区,4,430500 +430511,北塔区,4,430500 +430522,新邵县,4,430500 +430523,邵阳县,4,430500 +430524,隆回县,4,430500 +430525,洞口县,4,430500 +430527,绥宁县,4,430500 +430528,新宁县,4,430500 +430529,城步苗族自治县,4,430500 +430581,武冈市,4,430500 +430582,邵东市,4,430500 +430602,岳阳楼区,4,430600 +430603,云溪区,4,430600 +430611,君山区,4,430600 +430621,岳阳县,4,430600 +430623,华容县,4,430600 +430624,湘阴县,4,430600 +430626,平江县,4,430600 +430671,岳阳市屈原管理区,4,430600 +430681,汨罗市,4,430600 +430682,临湘市,4,430600 +430702,武陵区,4,430700 +430703,鼎城区,4,430700 +430721,安乡县,4,430700 +430722,汉寿县,4,430700 +430723,澧县,4,430700 +430724,临澧县,4,430700 +430725,桃源县,4,430700 +430726,石门县,4,430700 +430771,常德市西洞庭管理区,4,430700 +430781,津市市,4,430700 +430802,永定区,4,430800 +430811,武陵源区,4,430800 +430821,慈利县,4,430800 +430822,桑植县,4,430800 +430902,资阳区,4,430900 +430903,赫山区,4,430900 +430921,南县,4,430900 +430922,桃江县,4,430900 +430923,安化县,4,430900 +430971,益阳市大通湖管理区,4,430900 +430972,湖南益阳高新技术产业园区,4,430900 +430981,沅江市,4,430900 +431002,北湖区,4,431000 +431003,苏仙区,4,431000 +431021,桂阳县,4,431000 +431022,宜章县,4,431000 +431023,永兴县,4,431000 +431024,嘉禾县,4,431000 +431025,临武县,4,431000 +431026,汝城县,4,431000 +431027,桂东县,4,431000 +431028,安仁县,4,431000 +431081,资兴市,4,431000 +431102,零陵区,4,431100 +431103,冷水滩区,4,431100 +431122,东安县,4,431100 +431123,双牌县,4,431100 +431124,道县,4,431100 +431125,江永县,4,431100 +431126,宁远县,4,431100 +431127,蓝山县,4,431100 +431128,新田县,4,431100 +431129,江华瑶族自治县,4,431100 +431171,永州经济技术开发区,4,431100 +431173,永州市回龙圩管理区,4,431100 +431181,祁阳市,4,431100 +431202,鹤城区,4,431200 +431221,中方县,4,431200 +431222,沅陵县,4,431200 +431223,辰溪县,4,431200 +431224,溆浦县,4,431200 +431225,会同县,4,431200 +431226,麻阳苗族自治县,4,431200 +431227,新晃侗族自治县,4,431200 +431228,芷江侗族自治县,4,431200 +431229,靖州苗族侗族自治县,4,431200 +431230,通道侗族自治县,4,431200 +431271,怀化市洪江管理区,4,431200 +431281,洪江市,4,431200 +431302,娄星区,4,431300 +431321,双峰县,4,431300 +431322,新化县,4,431300 +431381,冷水江市,4,431300 +431382,涟源市,4,431300 +433101,吉首市,4,433100 +433122,泸溪县,4,433100 +433123,凤凰县,4,433100 +433124,花垣县,4,433100 +433125,保靖县,4,433100 +433126,古丈县,4,433100 +433127,永顺县,4,433100 +433130,龙山县,4,433100 +440103,荔湾区,4,440100 +440104,越秀区,4,440100 +440105,海珠区,4,440100 +440106,天河区,4,440100 +440111,白云区,4,440100 +440112,黄埔区,4,440100 +440113,番禺区,4,440100 +440114,花都区,4,440100 +440115,南沙区,4,440100 +440117,从化区,4,440100 +440118,增城区,4,440100 +440203,武江区,4,440200 +440204,浈江区,4,440200 +440205,曲江区,4,440200 +440222,始兴县,4,440200 +440224,仁化县,4,440200 +440229,翁源县,4,440200 +440232,乳源瑶族自治县,4,440200 +440233,新丰县,4,440200 +440281,乐昌市,4,440200 +440282,南雄市,4,440200 +440303,罗湖区,4,440300 +440304,福田区,4,440300 +440305,南山区,4,440300 +440306,宝安区,4,440300 +440307,龙岗区,4,440300 +440308,盐田区,4,440300 +440309,龙华区,4,440300 +440310,坪山区,4,440300 +440311,光明区,4,440300 +440402,香洲区,4,440400 +440403,斗门区,4,440400 +440404,金湾区,4,440400 +440507,龙湖区,4,440500 +440511,金平区,4,440500 +440512,濠江区,4,440500 +440513,潮阳区,4,440500 +440514,潮南区,4,440500 +440515,澄海区,4,440500 +440523,南澳县,4,440500 +440604,禅城区,4,440600 +440605,南海区,4,440600 +440606,顺德区,4,440600 +440607,三水区,4,440600 +440608,高明区,4,440600 +440703,蓬江区,4,440700 +440704,江海区,4,440700 +440705,新会区,4,440700 +440781,台山市,4,440700 +440783,开平市,4,440700 +440784,鹤山市,4,440700 +440785,恩平市,4,440700 +440802,赤坎区,4,440800 +440803,霞山区,4,440800 +440804,坡头区,4,440800 +440811,麻章区,4,440800 +440823,遂溪县,4,440800 +440825,徐闻县,4,440800 +440881,廉江市,4,440800 +440882,雷州市,4,440800 +440883,吴川市,4,440800 +440902,茂南区,4,440900 +440904,电白区,4,440900 +440981,高州市,4,440900 +440982,化州市,4,440900 +440983,信宜市,4,440900 +441202,端州区,4,441200 +441203,鼎湖区,4,441200 +441204,高要区,4,441200 +441223,广宁县,4,441200 +441224,怀集县,4,441200 +441225,封开县,4,441200 +441226,德庆县,4,441200 +441284,四会市,4,441200 +441302,惠城区,4,441300 +441303,惠阳区,4,441300 +441322,博罗县,4,441300 +441323,惠东县,4,441300 +441324,龙门县,4,441300 +441402,梅江区,4,441400 +441403,梅县区,4,441400 +441422,大埔县,4,441400 +441423,丰顺县,4,441400 +441424,五华县,4,441400 +441426,平远县,4,441400 +441427,蕉岭县,4,441400 +441481,兴宁市,4,441400 +441502,城区,4,441500 +441521,海丰县,4,441500 +441523,陆河县,4,441500 +441581,陆丰市,4,441500 +441602,源城区,4,441600 +441621,紫金县,4,441600 +441622,龙川县,4,441600 +441623,连平县,4,441600 +441624,和平县,4,441600 +441625,东源县,4,441600 +441702,江城区,4,441700 +441704,阳东区,4,441700 +441721,阳西县,4,441700 +441781,阳春市,4,441700 +441802,清城区,4,441800 +441803,清新区,4,441800 +441821,佛冈县,4,441800 +441823,阳山县,4,441800 +441825,连山壮族瑶族自治县,4,441800 +441826,连南瑶族自治县,4,441800 +441881,英德市,4,441800 +441882,连州市,4,441800 +445102,湘桥区,4,445100 +445103,潮安区,4,445100 +445122,饶平县,4,445100 +445202,榕城区,4,445200 +445203,揭东区,4,445200 +445222,揭西县,4,445200 +445224,惠来县,4,445200 +445281,普宁市,4,445200 +445302,云城区,4,445300 +445303,云安区,4,445300 +445321,新兴县,4,445300 +445322,郁南县,4,445300 +445381,罗定市,4,445300 +450102,兴宁区,4,450100 +450103,青秀区,4,450100 +450105,江南区,4,450100 +450107,西乡塘区,4,450100 +450108,良庆区,4,450100 +450109,邕宁区,4,450100 +450110,武鸣区,4,450100 +450123,隆安县,4,450100 +450124,马山县,4,450100 +450125,上林县,4,450100 +450126,宾阳县,4,450100 +450181,横州市,4,450100 +450202,城中区,4,450200 +450203,鱼峰区,4,450200 +450204,柳南区,4,450200 +450205,柳北区,4,450200 +450206,柳江区,4,450200 +450222,柳城县,4,450200 +450223,鹿寨县,4,450200 +450224,融安县,4,450200 +450225,融水苗族自治县,4,450200 +450226,三江侗族自治县,4,450200 +450302,秀峰区,4,450300 +450303,叠彩区,4,450300 +450304,象山区,4,450300 +450305,七星区,4,450300 +450311,雁山区,4,450300 +450312,临桂区,4,450300 +450321,阳朔县,4,450300 +450323,灵川县,4,450300 +450324,全州县,4,450300 +450325,兴安县,4,450300 +450326,永福县,4,450300 +450327,灌阳县,4,450300 +450328,龙胜各族自治县,4,450300 +450329,资源县,4,450300 +450330,平乐县,4,450300 +450332,恭城瑶族自治县,4,450300 +450381,荔浦市,4,450300 +450403,万秀区,4,450400 +450405,长洲区,4,450400 +450406,龙圩区,4,450400 +450421,苍梧县,4,450400 +450422,藤县,4,450400 +450423,蒙山县,4,450400 +450481,岑溪市,4,450400 +450502,海城区,4,450500 +450503,银海区,4,450500 +450512,铁山港区,4,450500 +450521,合浦县,4,450500 +450602,港口区,4,450600 +450603,防城区,4,450600 +450621,上思县,4,450600 +450681,东兴市,4,450600 +450702,钦南区,4,450700 +450703,钦北区,4,450700 +450721,灵山县,4,450700 +450722,浦北县,4,450700 +450802,港北区,4,450800 +450803,港南区,4,450800 +450804,覃塘区,4,450800 +450821,平南县,4,450800 +450881,桂平市,4,450800 +450902,玉州区,4,450900 +450903,福绵区,4,450900 +450921,容县,4,450900 +450922,陆川县,4,450900 +450923,博白县,4,450900 +450924,兴业县,4,450900 +450981,北流市,4,450900 +451002,右江区,4,451000 +451003,田阳区,4,451000 +451022,田东县,4,451000 +451024,德保县,4,451000 +451026,那坡县,4,451000 +451027,凌云县,4,451000 +451028,乐业县,4,451000 +451029,田林县,4,451000 +451030,西林县,4,451000 +451031,隆林各族自治县,4,451000 +451081,靖西市,4,451000 +451082,平果市,4,451000 +451102,八步区,4,451100 +451103,平桂区,4,451100 +451121,昭平县,4,451100 +451122,钟山县,4,451100 +451123,富川瑶族自治县,4,451100 +451202,金城江区,4,451200 +451203,宜州区,4,451200 +451221,南丹县,4,451200 +451222,天峨县,4,451200 +451223,凤山县,4,451200 +451224,东兰县,4,451200 +451225,罗城仫佬族自治县,4,451200 +451226,环江毛南族自治县,4,451200 +451227,巴马瑶族自治县,4,451200 +451228,都安瑶族自治县,4,451200 +451229,大化瑶族自治县,4,451200 +451302,兴宾区,4,451300 +451321,忻城县,4,451300 +451322,象州县,4,451300 +451323,武宣县,4,451300 +451324,金秀瑶族自治县,4,451300 +451381,合山市,4,451300 +451402,江州区,4,451400 +451421,扶绥县,4,451400 +451422,宁明县,4,451400 +451423,龙州县,4,451400 +451424,大新县,4,451400 +451425,天等县,4,451400 +451481,凭祥市,4,451400 +460105,秀英区,4,460100 +460106,龙华区,4,460100 +460107,琼山区,4,460100 +460108,美兰区,4,460100 +460202,海棠区,4,460200 +460203,吉阳区,4,460200 +460204,天涯区,4,460200 +460205,崖州区,4,460200 +460321,西沙群岛,4,460300 +460322,南沙群岛,4,460300 +460323,中沙群岛的岛礁及其海域,4,460300 +469001,五指山市,4,469000 +469002,琼海市,4,469000 +469005,文昌市,4,469000 +469006,万宁市,4,469000 +469007,东方市,4,469000 +469021,定安县,4,469000 +469022,屯昌县,4,469000 +469023,澄迈县,4,469000 +469024,临高县,4,469000 +469025,白沙黎族自治县,4,469000 +469026,昌江黎族自治县,4,469000 +469027,乐东黎族自治县,4,469000 +469028,陵水黎族自治县,4,469000 +469029,保亭黎族苗族自治县,4,469000 +469030,琼中黎族苗族自治县,4,469000 +500101,万州区,4,500100 +500102,涪陵区,4,500100 +500103,渝中区,4,500100 +500104,大渡口区,4,500100 +500105,江北区,4,500100 +500106,沙坪坝区,4,500100 +500107,九龙坡区,4,500100 +500108,南岸区,4,500100 +500109,北碚区,4,500100 +500110,綦江区,4,500100 +500111,大足区,4,500100 +500112,渝北区,4,500100 +500113,巴南区,4,500100 +500114,黔江区,4,500100 +500115,长寿区,4,500100 +500116,江津区,4,500100 +500117,合川区,4,500100 +500118,永川区,4,500100 +500119,南川区,4,500100 +500120,璧山区,4,500100 +500151,铜梁区,4,500100 +500152,潼南区,4,500100 +500153,荣昌区,4,500100 +500154,开州区,4,500100 +500155,梁平区,4,500100 +500156,武隆区,4,500100 +500229,城口县,4,500100 +500230,丰都县,4,500100 +500231,垫江县,4,500100 +500233,忠县,4,500100 +500235,云阳县,4,500100 +500236,奉节县,4,500100 +500237,巫山县,4,500100 +500238,巫溪县,4,500100 +500240,石柱土家族自治县,4,500100 +500241,秀山土家族苗族自治县,4,500100 +500242,酉阳土家族苗族自治县,4,500100 +500243,彭水苗族土家族自治县,4,500100 +510104,锦江区,4,510100 +510105,青羊区,4,510100 +510106,金牛区,4,510100 +510107,武侯区,4,510100 +510108,成华区,4,510100 +510112,龙泉驿区,4,510100 +510113,青白江区,4,510100 +510114,新都区,4,510100 +510115,温江区,4,510100 +510116,双流区,4,510100 +510117,郫都区,4,510100 +510118,新津区,4,510100 +510121,金堂县,4,510100 +510129,大邑县,4,510100 +510131,蒲江县,4,510100 +510181,都江堰市,4,510100 +510182,彭州市,4,510100 +510183,邛崃市,4,510100 +510184,崇州市,4,510100 +510185,简阳市,4,510100 +510302,自流井区,4,510300 +510303,贡井区,4,510300 +510304,大安区,4,510300 +510311,沿滩区,4,510300 +510321,荣县,4,510300 +510322,富顺县,4,510300 +510402,东区,4,510400 +510403,西区,4,510400 +510411,仁和区,4,510400 +510421,米易县,4,510400 +510422,盐边县,4,510400 +510502,江阳区,4,510500 +510503,纳溪区,4,510500 +510504,龙马潭区,4,510500 +510521,泸县,4,510500 +510522,合江县,4,510500 +510524,叙永县,4,510500 +510525,古蔺县,4,510500 +510603,旌阳区,4,510600 +510604,罗江区,4,510600 +510623,中江县,4,510600 +510681,广汉市,4,510600 +510682,什邡市,4,510600 +510683,绵竹市,4,510600 +510703,涪城区,4,510700 +510704,游仙区,4,510700 +510705,安州区,4,510700 +510722,三台县,4,510700 +510723,盐亭县,4,510700 +510725,梓潼县,4,510700 +510726,北川羌族自治县,4,510700 +510727,平武县,4,510700 +510781,江油市,4,510700 +510802,利州区,4,510800 +510811,昭化区,4,510800 +510812,朝天区,4,510800 +510821,旺苍县,4,510800 +510822,青川县,4,510800 +510823,剑阁县,4,510800 +510824,苍溪县,4,510800 +510903,船山区,4,510900 +510904,安居区,4,510900 +510921,蓬溪县,4,510900 +510923,大英县,4,510900 +510981,射洪市,4,510900 +511002,市中区,4,511000 +511011,东兴区,4,511000 +511024,威远县,4,511000 +511025,资中县,4,511000 +511071,内江经济开发区,4,511000 +511083,隆昌市,4,511000 +511102,市中区,4,511100 +511111,沙湾区,4,511100 +511112,五通桥区,4,511100 +511113,金口河区,4,511100 +511123,犍为县,4,511100 +511124,井研县,4,511100 +511126,夹江县,4,511100 +511129,沐川县,4,511100 +511132,峨边彝族自治县,4,511100 +511133,马边彝族自治县,4,511100 +511181,峨眉山市,4,511100 +511302,顺庆区,4,511300 +511303,高坪区,4,511300 +511304,嘉陵区,4,511300 +511321,南部县,4,511300 +511322,营山县,4,511300 +511323,蓬安县,4,511300 +511324,仪陇县,4,511300 +511325,西充县,4,511300 +511381,阆中市,4,511300 +511402,东坡区,4,511400 +511403,彭山区,4,511400 +511421,仁寿县,4,511400 +511423,洪雅县,4,511400 +511424,丹棱县,4,511400 +511425,青神县,4,511400 +511502,翠屏区,4,511500 +511503,南溪区,4,511500 +511504,叙州区,4,511500 +511523,江安县,4,511500 +511524,长宁县,4,511500 +511525,高县,4,511500 +511526,珙县,4,511500 +511527,筠连县,4,511500 +511528,兴文县,4,511500 +511529,屏山县,4,511500 +511602,广安区,4,511600 +511603,前锋区,4,511600 +511621,岳池县,4,511600 +511622,武胜县,4,511600 +511623,邻水县,4,511600 +511681,华蓥市,4,511600 +511702,通川区,4,511700 +511703,达川区,4,511700 +511722,宣汉县,4,511700 +511723,开江县,4,511700 +511724,大竹县,4,511700 +511725,渠县,4,511700 +511771,达州经济开发区,4,511700 +511781,万源市,4,511700 +511802,雨城区,4,511800 +511803,名山区,4,511800 +511822,荥经县,4,511800 +511823,汉源县,4,511800 +511824,石棉县,4,511800 +511825,天全县,4,511800 +511826,芦山县,4,511800 +511827,宝兴县,4,511800 +511902,巴州区,4,511900 +511903,恩阳区,4,511900 +511921,通江县,4,511900 +511922,南江县,4,511900 +511923,平昌县,4,511900 +511971,巴中经济开发区,4,511900 +512002,雁江区,4,512000 +512021,安岳县,4,512000 +512022,乐至县,4,512000 +513201,马尔康市,4,513200 +513221,汶川县,4,513200 +513222,理县,4,513200 +513223,茂县,4,513200 +513224,松潘县,4,513200 +513225,九寨沟县,4,513200 +513226,金川县,4,513200 +513227,小金县,4,513200 +513228,黑水县,4,513200 +513230,壤塘县,4,513200 +513231,阿坝县,4,513200 +513232,若尔盖县,4,513200 +513233,红原县,4,513200 +513301,康定市,4,513300 +513322,泸定县,4,513300 +513323,丹巴县,4,513300 +513324,九龙县,4,513300 +513325,雅江县,4,513300 +513326,道孚县,4,513300 +513327,炉霍县,4,513300 +513328,甘孜县,4,513300 +513329,新龙县,4,513300 +513330,德格县,4,513300 +513331,白玉县,4,513300 +513332,石渠县,4,513300 +513333,色达县,4,513300 +513334,理塘县,4,513300 +513335,巴塘县,4,513300 +513336,乡城县,4,513300 +513337,稻城县,4,513300 +513338,得荣县,4,513300 +513401,西昌市,4,513400 +513402,会理市,4,513400 +513422,木里藏族自治县,4,513400 +513423,盐源县,4,513400 +513424,德昌县,4,513400 +513426,会东县,4,513400 +513427,宁南县,4,513400 +513428,普格县,4,513400 +513429,布拖县,4,513400 +513430,金阳县,4,513400 +513431,昭觉县,4,513400 +513432,喜德县,4,513400 +513433,冕宁县,4,513400 +513434,越西县,4,513400 +513435,甘洛县,4,513400 +513436,美姑县,4,513400 +513437,雷波县,4,513400 +520102,南明区,4,520100 +520103,云岩区,4,520100 +520111,花溪区,4,520100 +520112,乌当区,4,520100 +520113,白云区,4,520100 +520115,观山湖区,4,520100 +520121,开阳县,4,520100 +520122,息烽县,4,520100 +520123,修文县,4,520100 +520181,清镇市,4,520100 +520201,钟山区,4,520200 +520203,六枝特区,4,520200 +520204,水城区,4,520200 +520281,盘州市,4,520200 +520302,红花岗区,4,520300 +520303,汇川区,4,520300 +520304,播州区,4,520300 +520322,桐梓县,4,520300 +520323,绥阳县,4,520300 +520324,正安县,4,520300 +520325,道真仡佬族苗族自治县,4,520300 +520326,务川仡佬族苗族自治县,4,520300 +520327,凤冈县,4,520300 +520328,湄潭县,4,520300 +520329,余庆县,4,520300 +520330,习水县,4,520300 +520381,赤水市,4,520300 +520382,仁怀市,4,520300 +520402,西秀区,4,520400 +520403,平坝区,4,520400 +520422,普定县,4,520400 +520423,镇宁布依族苗族自治县,4,520400 +520424,关岭布依族苗族自治县,4,520400 +520425,紫云苗族布依族自治县,4,520400 +520502,七星关区,4,520500 +520521,大方县,4,520500 +520523,金沙县,4,520500 +520524,织金县,4,520500 +520525,纳雍县,4,520500 +520526,威宁彝族回族苗族自治县,4,520500 +520527,赫章县,4,520500 +520581,黔西市,4,520500 +520602,碧江区,4,520600 +520603,万山区,4,520600 +520621,江口县,4,520600 +520622,玉屏侗族自治县,4,520600 +520623,石阡县,4,520600 +520624,思南县,4,520600 +520625,印江土家族苗族自治县,4,520600 +520626,德江县,4,520600 +520627,沿河土家族自治县,4,520600 +520628,松桃苗族自治县,4,520600 +522301,兴义市,4,522300 +522302,兴仁市,4,522300 +522323,普安县,4,522300 +522324,晴隆县,4,522300 +522325,贞丰县,4,522300 +522326,望谟县,4,522300 +522327,册亨县,4,522300 +522328,安龙县,4,522300 +522601,凯里市,4,522600 +522622,黄平县,4,522600 +522623,施秉县,4,522600 +522624,三穗县,4,522600 +522625,镇远县,4,522600 +522626,岑巩县,4,522600 +522627,天柱县,4,522600 +522628,锦屏县,4,522600 +522629,剑河县,4,522600 +522630,台江县,4,522600 +522631,黎平县,4,522600 +522632,榕江县,4,522600 +522633,从江县,4,522600 +522634,雷山县,4,522600 +522635,麻江县,4,522600 +522636,丹寨县,4,522600 +522701,都匀市,4,522700 +522702,福泉市,4,522700 +522722,荔波县,4,522700 +522723,贵定县,4,522700 +522725,瓮安县,4,522700 +522726,独山县,4,522700 +522727,平塘县,4,522700 +522728,罗甸县,4,522700 +522729,长顺县,4,522700 +522730,龙里县,4,522700 +522731,惠水县,4,522700 +522732,三都水族自治县,4,522700 +530102,五华区,4,530100 +530103,盘龙区,4,530100 +530111,官渡区,4,530100 +530112,西山区,4,530100 +530113,东川区,4,530100 +530114,呈贡区,4,530100 +530115,晋宁区,4,530100 +530124,富民县,4,530100 +530125,宜良县,4,530100 +530126,石林彝族自治县,4,530100 +530127,嵩明县,4,530100 +530128,禄劝彝族苗族自治县,4,530100 +530129,寻甸回族彝族自治县,4,530100 +530181,安宁市,4,530100 +530302,麒麟区,4,530300 +530303,沾益区,4,530300 +530304,马龙区,4,530300 +530322,陆良县,4,530300 +530323,师宗县,4,530300 +530324,罗平县,4,530300 +530325,富源县,4,530300 +530326,会泽县,4,530300 +530381,宣威市,4,530300 +530402,红塔区,4,530400 +530403,江川区,4,530400 +530423,通海县,4,530400 +530424,华宁县,4,530400 +530425,易门县,4,530400 +530426,峨山彝族自治县,4,530400 +530427,新平彝族傣族自治县,4,530400 +530428,元江哈尼族彝族傣族自治县,4,530400 +530481,澄江市,4,530400 +530502,隆阳区,4,530500 +530521,施甸县,4,530500 +530523,龙陵县,4,530500 +530524,昌宁县,4,530500 +530581,腾冲市,4,530500 +530602,昭阳区,4,530600 +530621,鲁甸县,4,530600 +530622,巧家县,4,530600 +530623,盐津县,4,530600 +530624,大关县,4,530600 +530625,永善县,4,530600 +530626,绥江县,4,530600 +530627,镇雄县,4,530600 +530628,彝良县,4,530600 +530629,威信县,4,530600 +530681,水富市,4,530600 +530702,古城区,4,530700 +530721,玉龙纳西族自治县,4,530700 +530722,永胜县,4,530700 +530723,华坪县,4,530700 +530724,宁蒗彝族自治县,4,530700 +530802,思茅区,4,530800 +530821,宁洱哈尼族彝族自治县,4,530800 +530822,墨江哈尼族自治县,4,530800 +530823,景东彝族自治县,4,530800 +530824,景谷傣族彝族自治县,4,530800 +530825,镇沅彝族哈尼族拉祜族自治县,4,530800 +530826,江城哈尼族彝族自治县,4,530800 +530827,孟连傣族拉祜族佤族自治县,4,530800 +530828,澜沧拉祜族自治县,4,530800 +530829,西盟佤族自治县,4,530800 +530902,临翔区,4,530900 +530921,凤庆县,4,530900 +530922,云县,4,530900 +530923,永德县,4,530900 +530924,镇康县,4,530900 +530925,双江拉祜族佤族布朗族傣族自治县,4,530900 +530926,耿马傣族佤族自治县,4,530900 +530927,沧源佤族自治县,4,530900 +532301,楚雄市,4,532300 +532302,禄丰市,4,532300 +532322,双柏县,4,532300 +532323,牟定县,4,532300 +532324,南华县,4,532300 +532325,姚安县,4,532300 +532326,大姚县,4,532300 +532327,永仁县,4,532300 +532328,元谋县,4,532300 +532329,武定县,4,532300 +532501,个旧市,4,532500 +532502,开远市,4,532500 +532503,蒙自市,4,532500 +532504,弥勒市,4,532500 +532523,屏边苗族自治县,4,532500 +532524,建水县,4,532500 +532525,石屏县,4,532500 +532527,泸西县,4,532500 +532528,元阳县,4,532500 +532529,红河县,4,532500 +532530,金平苗族瑶族傣族自治县,4,532500 +532531,绿春县,4,532500 +532532,河口瑶族自治县,4,532500 +532601,文山市,4,532600 +532622,砚山县,4,532600 +532623,西畴县,4,532600 +532624,麻栗坡县,4,532600 +532625,马关县,4,532600 +532626,丘北县,4,532600 +532627,广南县,4,532600 +532628,富宁县,4,532600 +532801,景洪市,4,532800 +532822,勐海县,4,532800 +532823,勐腊县,4,532800 +532901,大理市,4,532900 +532922,漾濞彝族自治县,4,532900 +532923,祥云县,4,532900 +532924,宾川县,4,532900 +532925,弥渡县,4,532900 +532926,南涧彝族自治县,4,532900 +532927,巍山彝族回族自治县,4,532900 +532928,永平县,4,532900 +532929,云龙县,4,532900 +532930,洱源县,4,532900 +532931,剑川县,4,532900 +532932,鹤庆县,4,532900 +533102,瑞丽市,4,533100 +533103,芒市,4,533100 +533122,梁河县,4,533100 +533123,盈江县,4,533100 +533124,陇川县,4,533100 +533301,泸水市,4,533300 +533323,福贡县,4,533300 +533324,贡山独龙族怒族自治县,4,533300 +533325,兰坪白族普米族自治县,4,533300 +533401,香格里拉市,4,533400 +533422,德钦县,4,533400 +533423,维西傈僳族自治县,4,533400 +540102,城关区,4,540100 +540103,堆龙德庆区,4,540100 +540104,达孜区,4,540100 +540121,林周县,4,540100 +540122,当雄县,4,540100 +540123,尼木县,4,540100 +540124,曲水县,4,540100 +540127,墨竹工卡县,4,540100 +540171,格尔木藏青工业园区,4,540100 +540172,拉萨经济技术开发区,4,540100 +540173,西藏文化旅游创意园区,4,540100 +540174,达孜工业园区,4,540100 +540202,桑珠孜区,4,540200 +540221,南木林县,4,540200 +540222,江孜县,4,540200 +540223,定日县,4,540200 +540224,萨迦县,4,540200 +540225,拉孜县,4,540200 +540226,昂仁县,4,540200 +540227,谢通门县,4,540200 +540228,白朗县,4,540200 +540229,仁布县,4,540200 +540230,康马县,4,540200 +540231,定结县,4,540200 +540232,仲巴县,4,540200 +540233,亚东县,4,540200 +540234,吉隆县,4,540200 +540235,聂拉木县,4,540200 +540236,萨嘎县,4,540200 +540237,岗巴县,4,540200 +540302,卡若区,4,540300 +540321,江达县,4,540300 +540322,贡觉县,4,540300 +540323,类乌齐县,4,540300 +540324,丁青县,4,540300 +540325,察雅县,4,540300 +540326,八宿县,4,540300 +540327,左贡县,4,540300 +540328,芒康县,4,540300 +540329,洛隆县,4,540300 +540330,边坝县,4,540300 +540402,巴宜区,4,540400 +540421,工布江达县,4,540400 +540422,米林县,4,540400 +540423,墨脱县,4,540400 +540424,波密县,4,540400 +540425,察隅县,4,540400 +540426,朗县,4,540400 +540502,乃东区,4,540500 +540521,扎囊县,4,540500 +540522,贡嘎县,4,540500 +540523,桑日县,4,540500 +540524,琼结县,4,540500 +540525,曲松县,4,540500 +540526,措美县,4,540500 +540527,洛扎县,4,540500 +540528,加查县,4,540500 +540529,隆子县,4,540500 +540530,错那县,4,540500 +540531,浪卡子县,4,540500 +540602,色尼区,4,540600 +540621,嘉黎县,4,540600 +540622,比如县,4,540600 +540623,聂荣县,4,540600 +540624,安多县,4,540600 +540625,申扎县,4,540600 +540626,索县,4,540600 +540627,班戈县,4,540600 +540628,巴青县,4,540600 +540629,尼玛县,4,540600 +540630,双湖县,4,540600 +542521,普兰县,4,542500 +542522,札达县,4,542500 +542523,噶尔县,4,542500 +542524,日土县,4,542500 +542525,革吉县,4,542500 +542526,改则县,4,542500 +542527,措勤县,4,542500 +610102,新城区,4,610100 +610103,碑林区,4,610100 +610104,莲湖区,4,610100 +610111,灞桥区,4,610100 +610112,未央区,4,610100 +610113,雁塔区,4,610100 +610114,阎良区,4,610100 +610115,临潼区,4,610100 +610116,长安区,4,610100 +610117,高陵区,4,610100 +610118,鄠邑区,4,610100 +610122,蓝田县,4,610100 +610124,周至县,4,610100 +610202,王益区,4,610200 +610203,印台区,4,610200 +610204,耀州区,4,610200 +610222,宜君县,4,610200 +610302,渭滨区,4,610300 +610303,金台区,4,610300 +610304,陈仓区,4,610300 +610305,凤翔区,4,610300 +610323,岐山县,4,610300 +610324,扶风县,4,610300 +610326,眉县,4,610300 +610327,陇县,4,610300 +610328,千阳县,4,610300 +610329,麟游县,4,610300 +610330,凤县,4,610300 +610331,太白县,4,610300 +610402,秦都区,4,610400 +610403,杨陵区,4,610400 +610404,渭城区,4,610400 +610422,三原县,4,610400 +610423,泾阳县,4,610400 +610424,乾县,4,610400 +610425,礼泉县,4,610400 +610426,永寿县,4,610400 +610428,长武县,4,610400 +610429,旬邑县,4,610400 +610430,淳化县,4,610400 +610431,武功县,4,610400 +610481,兴平市,4,610400 +610482,彬州市,4,610400 +610502,临渭区,4,610500 +610503,华州区,4,610500 +610522,潼关县,4,610500 +610523,大荔县,4,610500 +610524,合阳县,4,610500 +610525,澄城县,4,610500 +610526,蒲城县,4,610500 +610527,白水县,4,610500 +610528,富平县,4,610500 +610581,韩城市,4,610500 +610582,华阴市,4,610500 +610602,宝塔区,4,610600 +610603,安塞区,4,610600 +610621,延长县,4,610600 +610622,延川县,4,610600 +610625,志丹县,4,610600 +610626,吴起县,4,610600 +610627,甘泉县,4,610600 +610628,富县,4,610600 +610629,洛川县,4,610600 +610630,宜川县,4,610600 +610631,黄龙县,4,610600 +610632,黄陵县,4,610600 +610681,子长市,4,610600 +610702,汉台区,4,610700 +610703,南郑区,4,610700 +610722,城固县,4,610700 +610723,洋县,4,610700 +610724,西乡县,4,610700 +610725,勉县,4,610700 +610726,宁强县,4,610700 +610727,略阳县,4,610700 +610728,镇巴县,4,610700 +610729,留坝县,4,610700 +610730,佛坪县,4,610700 +610802,榆阳区,4,610800 +610803,横山区,4,610800 +610822,府谷县,4,610800 +610824,靖边县,4,610800 +610825,定边县,4,610800 +610826,绥德县,4,610800 +610827,米脂县,4,610800 +610828,佳县,4,610800 +610829,吴堡县,4,610800 +610830,清涧县,4,610800 +610831,子洲县,4,610800 +610881,神木市,4,610800 +610902,汉滨区,4,610900 +610921,汉阴县,4,610900 +610922,石泉县,4,610900 +610923,宁陕县,4,610900 +610924,紫阳县,4,610900 +610925,岚皋县,4,610900 +610926,平利县,4,610900 +610927,镇坪县,4,610900 +610929,白河县,4,610900 +610981,旬阳市,4,610900 +611002,商州区,4,611000 +611021,洛南县,4,611000 +611022,丹凤县,4,611000 +611023,商南县,4,611000 +611024,山阳县,4,611000 +611025,镇安县,4,611000 +611026,柞水县,4,611000 +620102,城关区,4,620100 +620103,七里河区,4,620100 +620104,西固区,4,620100 +620105,安宁区,4,620100 +620111,红古区,4,620100 +620121,永登县,4,620100 +620122,皋兰县,4,620100 +620123,榆中县,4,620100 +620171,兰州新区,4,620100 +620201,嘉峪关市,4,620200 +620302,金川区,4,620300 +620321,永昌县,4,620300 +620402,白银区,4,620400 +620403,平川区,4,620400 +620421,靖远县,4,620400 +620422,会宁县,4,620400 +620423,景泰县,4,620400 +620502,秦州区,4,620500 +620503,麦积区,4,620500 +620521,清水县,4,620500 +620522,秦安县,4,620500 +620523,甘谷县,4,620500 +620524,武山县,4,620500 +620525,张家川回族自治县,4,620500 +620602,凉州区,4,620600 +620621,民勤县,4,620600 +620622,古浪县,4,620600 +620623,天祝藏族自治县,4,620600 +620702,甘州区,4,620700 +620721,肃南裕固族自治县,4,620700 +620722,民乐县,4,620700 +620723,临泽县,4,620700 +620724,高台县,4,620700 +620725,山丹县,4,620700 +620802,崆峒区,4,620800 +620821,泾川县,4,620800 +620822,灵台县,4,620800 +620823,崇信县,4,620800 +620825,庄浪县,4,620800 +620826,静宁县,4,620800 +620881,华亭市,4,620800 +620902,肃州区,4,620900 +620921,金塔县,4,620900 +620922,瓜州县,4,620900 +620923,肃北蒙古族自治县,4,620900 +620924,阿克塞哈萨克族自治县,4,620900 +620981,玉门市,4,620900 +620982,敦煌市,4,620900 +621002,西峰区,4,621000 +621021,庆城县,4,621000 +621022,环县,4,621000 +621023,华池县,4,621000 +621024,合水县,4,621000 +621025,正宁县,4,621000 +621026,宁县,4,621000 +621027,镇原县,4,621000 +621102,安定区,4,621100 +621121,通渭县,4,621100 +621122,陇西县,4,621100 +621123,渭源县,4,621100 +621124,临洮县,4,621100 +621125,漳县,4,621100 +621126,岷县,4,621100 +621202,武都区,4,621200 +621221,成县,4,621200 +621222,文县,4,621200 +621223,宕昌县,4,621200 +621224,康县,4,621200 +621225,西和县,4,621200 +621226,礼县,4,621200 +621227,徽县,4,621200 +621228,两当县,4,621200 +622901,临夏市,4,622900 +622921,临夏县,4,622900 +622922,康乐县,4,622900 +622923,永靖县,4,622900 +622924,广河县,4,622900 +622925,和政县,4,622900 +622926,东乡族自治县,4,622900 +622927,积石山保安族东乡族撒拉族自治县,4,622900 +623001,合作市,4,623000 +623021,临潭县,4,623000 +623022,卓尼县,4,623000 +623023,舟曲县,4,623000 +623024,迭部县,4,623000 +623025,玛曲县,4,623000 +623026,碌曲县,4,623000 +623027,夏河县,4,623000 +630102,城东区,4,630100 +630103,城中区,4,630100 +630104,城西区,4,630100 +630105,城北区,4,630100 +630106,湟中区,4,630100 +630121,大通回族土族自治县,4,630100 +630123,湟源县,4,630100 +630202,乐都区,4,630200 +630203,平安区,4,630200 +630222,民和回族土族自治县,4,630200 +630223,互助土族自治县,4,630200 +630224,化隆回族自治县,4,630200 +630225,循化撒拉族自治县,4,630200 +632221,门源回族自治县,4,632200 +632222,祁连县,4,632200 +632223,海晏县,4,632200 +632224,刚察县,4,632200 +632301,同仁市,4,632300 +632322,尖扎县,4,632300 +632323,泽库县,4,632300 +632324,河南蒙古族自治县,4,632300 +632521,共和县,4,632500 +632522,同德县,4,632500 +632523,贵德县,4,632500 +632524,兴海县,4,632500 +632525,贵南县,4,632500 +632621,玛沁县,4,632600 +632622,班玛县,4,632600 +632623,甘德县,4,632600 +632624,达日县,4,632600 +632625,久治县,4,632600 +632626,玛多县,4,632600 +632701,玉树市,4,632700 +632722,杂多县,4,632700 +632723,称多县,4,632700 +632724,治多县,4,632700 +632725,囊谦县,4,632700 +632726,曲麻莱县,4,632700 +632801,格尔木市,4,632800 +632802,德令哈市,4,632800 +632803,茫崖市,4,632800 +632821,乌兰县,4,632800 +632822,都兰县,4,632800 +632823,天峻县,4,632800 +632857,大柴旦行政委员会,4,632800 +640104,兴庆区,4,640100 +640105,西夏区,4,640100 +640106,金凤区,4,640100 +640121,永宁县,4,640100 +640122,贺兰县,4,640100 +640181,灵武市,4,640100 +640202,大武口区,4,640200 +640205,惠农区,4,640200 +640221,平罗县,4,640200 +640302,利通区,4,640300 +640303,红寺堡区,4,640300 +640323,盐池县,4,640300 +640324,同心县,4,640300 +640381,青铜峡市,4,640300 +640402,原州区,4,640400 +640422,西吉县,4,640400 +640423,隆德县,4,640400 +640424,泾源县,4,640400 +640425,彭阳县,4,640400 +640502,沙坡头区,4,640500 +640521,中宁县,4,640500 +640522,海原县,4,640500 +650102,天山区,4,650100 +650103,沙依巴克区,4,650100 +650104,新市区,4,650100 +650105,水磨沟区,4,650100 +650106,头屯河区,4,650100 +650107,达坂城区,4,650100 +650109,米东区,4,650100 +650121,乌鲁木齐县,4,650100 +650202,独山子区,4,650200 +650203,克拉玛依区,4,650200 +650204,白碱滩区,4,650200 +650205,乌尔禾区,4,650200 +650402,高昌区,4,650400 +650421,鄯善县,4,650400 +650422,托克逊县,4,650400 +650502,伊州区,4,650500 +650521,巴里坤哈萨克自治县,4,650500 +650522,伊吾县,4,650500 +652301,昌吉市,4,652300 +652302,阜康市,4,652300 +652323,呼图壁县,4,652300 +652324,玛纳斯县,4,652300 +652325,奇台县,4,652300 +652327,吉木萨尔县,4,652300 +652328,木垒哈萨克自治县,4,652300 +652701,博乐市,4,652700 +652702,阿拉山口市,4,652700 +652722,精河县,4,652700 +652723,温泉县,4,652700 +652801,库尔勒市,4,652800 +652822,轮台县,4,652800 +652823,尉犁县,4,652800 +652824,若羌县,4,652800 +652825,且末县,4,652800 +652826,焉耆回族自治县,4,652800 +652827,和静县,4,652800 +652828,和硕县,4,652800 +652829,博湖县,4,652800 +652871,库尔勒经济技术开发区,4,652800 +652901,阿克苏市,4,652900 +652902,库车市,4,652900 +652922,温宿县,4,652900 +652924,沙雅县,4,652900 +652925,新和县,4,652900 +652926,拜城县,4,652900 +652927,乌什县,4,652900 +652928,阿瓦提县,4,652900 +652929,柯坪县,4,652900 +653001,阿图什市,4,653000 +653022,阿克陶县,4,653000 +653023,阿合奇县,4,653000 +653024,乌恰县,4,653000 +653101,喀什市,4,653100 +653121,疏附县,4,653100 +653122,疏勒县,4,653100 +653123,英吉沙县,4,653100 +653124,泽普县,4,653100 +653125,莎车县,4,653100 +653126,叶城县,4,653100 +653127,麦盖提县,4,653100 +653128,岳普湖县,4,653100 +653129,伽师县,4,653100 +653130,巴楚县,4,653100 +653131,塔什库尔干塔吉克自治县,4,653100 +653201,和田市,4,653200 +653221,和田县,4,653200 +653222,墨玉县,4,653200 +653223,皮山县,4,653200 +653224,洛浦县,4,653200 +653225,策勒县,4,653200 +653226,于田县,4,653200 +653227,民丰县,4,653200 +654002,伊宁市,4,654000 +654003,奎屯市,4,654000 +654004,霍尔果斯市,4,654000 +654021,伊宁县,4,654000 +654022,察布查尔锡伯自治县,4,654000 +654023,霍城县,4,654000 +654024,巩留县,4,654000 +654025,新源县,4,654000 +654026,昭苏县,4,654000 +654027,特克斯县,4,654000 +654028,尼勒克县,4,654000 +654201,塔城市,4,654200 +654202,乌苏市,4,654200 +654203,沙湾市,4,654200 +654221,额敏县,4,654200 +654224,托里县,4,654200 +654225,裕民县,4,654200 +654226,和布克赛尔蒙古自治县,4,654200 +654301,阿勒泰市,4,654300 +654321,布尔津县,4,654300 +654322,富蕴县,4,654300 +654323,福海县,4,654300 +654324,哈巴河县,4,654300 +654325,青河县,4,654300 +654326,吉木乃县,4,654300 +659001,石河子市,4,659000 +659002,阿拉尔市,4,659000 +659003,图木舒克市,4,659000 +659004,五家渠市,4,659000 +659005,北屯市,4,659000 +659006,铁门关市,4,659000 +659007,双河市,4,659000 +659008,可克达拉市,4,659000 +659009,昆玉市,4,659000 +659010,胡杨河市,4,659000 +659011,新星市,4,659000 \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/main/resources/ip2region.xdb b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/resources/ip2region.xdb new file mode 100644 index 000000000..58596a591 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-biz-ip/src/main/resources/ip2region.xdb differ diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/test/java/com/cf/imes/framework/ip/core/utils/AreaUtilsTest.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/test/java/com/cf/imes/framework/ip/core/utils/AreaUtilsTest.java new file mode 100644 index 000000000..267316747 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/test/java/com/cf/imes/framework/ip/core/utils/AreaUtilsTest.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.ip.core.utils; + + +import com.cf.imes.framework.ip.core.Area; +import com.cf.imes.framework.ip.core.enums.AreaTypeEnum; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link AreaUtils} 的单元测试 + * + * @author 晨丰科技 + */ +public class AreaUtilsTest { + + @Test + public void testGetArea() { + // 调用:北京 + Area area = AreaUtils.getArea(110100); + // 断言 + assertEquals(area.getId(), 110100); + assertEquals(area.getName(), "北京市"); + assertEquals(area.getType(), AreaTypeEnum.CITY.getType()); + assertEquals(area.getParent().getId(), 110000); + assertEquals(area.getChildren().size(), 16); + } + + @Test + public void testFormat() { + assertEquals(AreaUtils.format(110105), "北京 北京市 朝阳区"); + assertEquals(AreaUtils.format(1), "中国"); + assertEquals(AreaUtils.format(2), "蒙古"); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-ip/src/test/java/com/cf/imes/framework/ip/core/utils/IPUtilsTest.java b/cf-framework/cf-spring-boot-starter-biz-ip/src/test/java/com/cf/imes/framework/ip/core/utils/IPUtilsTest.java new file mode 100644 index 000000000..682ce218b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-ip/src/test/java/com/cf/imes/framework/ip/core/utils/IPUtilsTest.java @@ -0,0 +1,47 @@ +package com.cf.imes.framework.ip.core.utils; + +import com.cf.imes.framework.ip.core.Area; +import org.junit.jupiter.api.Test; +import org.lionsoul.ip2region.xdb.Searcher; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link IPUtils} 的单元测试 + * + * @author wanglhup + */ +public class IPUtilsTest { + + @Test + public void testGetAreaId_string() { + // 120.202.4.0|120.202.4.255|420600 + Integer areaId = IPUtils.getAreaId("120.202.4.50"); + assertEquals(420600, areaId); + } + + @Test + public void testGetAreaId_long() throws Exception { + // 120.203.123.0|120.203.133.255|360900 + long ip = Searcher.checkIP("120.203.123.250"); + Integer areaId = IPUtils.getAreaId(ip); + assertEquals(360900, areaId); + } + + @Test + public void testGetArea_string() { + // 120.202.4.0|120.202.4.255|420600 + Area area = IPUtils.getArea("120.202.4.50"); + assertEquals("襄阳市", area.getName()); + } + + @Test + public void testGetArea_long() throws Exception { + // 120.203.123.0|120.203.133.255|360900 + long ip = Searcher.checkIP("120.203.123.252"); + Area area = IPUtils.getArea(ip); + assertEquals("宜春市", area.getName()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/pom.xml b/cf-framework/cf-spring-boot-starter-biz-operatelog/pom.xml new file mode 100644 index 000000000..4441dc9ed --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/pom.xml @@ -0,0 +1,57 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-operatelog + jar + + ${project.artifactId} + 操作日志 + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + com.cf.imes + cf-spring-boot-starter-web + provided + + + + + com.cf.imes + cf-spring-boot-starter-rpc + true + + + + + com.cf.imes + cf-module-system-api + ${revision} + + + + + com.google.guava + guava + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/config/ChenfengOperateLogAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/config/ChenfengOperateLogAutoConfiguration.java new file mode 100644 index 000000000..bde5ccfb7 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/config/ChenfengOperateLogAutoConfiguration.java @@ -0,0 +1,23 @@ +package com.cf.imes.framework.operatelog.config; + +import com.cf.imes.framework.operatelog.core.aop.OperateLogAspect; +import com.cf.imes.framework.operatelog.core.service.OperateLogFrameworkService; +import com.cf.imes.framework.operatelog.core.service.OperateLogFrameworkServiceImpl; +import com.cf.imes.module.system.api.logger.OperateLogApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class ChenfengOperateLogAutoConfiguration { + + @Bean + public OperateLogAspect operateLogAspect() { + return new OperateLogAspect(); + } + + @Bean + public OperateLogFrameworkService operateLogFrameworkService(OperateLogApi operateLogApi) { + return new OperateLogFrameworkServiceImpl(operateLogApi); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/config/ChenfengOperateLogRpcAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/config/ChenfengOperateLogRpcAutoConfiguration.java new file mode 100644 index 000000000..f5bf6709a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/config/ChenfengOperateLogRpcAutoConfiguration.java @@ -0,0 +1,16 @@ +package com.cf.imes.framework.operatelog.config; + +import com.cf.imes.module.system.api.logger.OperateLogApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 操作日志使用到 Feign 的配置项 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@EnableFeignClients(clients = OperateLogApi.class) // 主要是引入相关的 API 服务 +public class ChenfengOperateLogRpcAutoConfiguration { + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/annotations/OperateLog.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/annotations/OperateLog.java new file mode 100644 index 000000000..f8e71c37b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/annotations/OperateLog.java @@ -0,0 +1,57 @@ +package com.cf.imes.framework.operatelog.core.annotations; + +import com.cf.imes.framework.operatelog.core.enums.OperateTypeEnum; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 操作日志注解 + * + * @author 晨丰科技 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface OperateLog { + + // ========== 模块字段 ========== + + /** + * 操作模块 + * + * 为空时,会尝试读取 {@link Tag#name()} 属性 + */ + String module() default ""; + /** + * 操作名 + * + * 为空时,会尝试读取 {@link Operation#summary()} 属性 + */ + String name() default ""; + /** + * 操作分类 + * + * 实际并不是数组,因为枚举不能设置 null 作为默认值 + */ + OperateTypeEnum[] type() default {}; + + // ========== 开关字段 ========== + + /** + * 是否记录操作日志 + */ + boolean enable() default true; + /** + * 是否记录方法参数 + */ + boolean logArgs() default true; + /** + * 是否记录方法结果的数据 + */ + boolean logResultData() default true; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/aop/OperateLogAspect.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/aop/OperateLogAspect.java new file mode 100644 index 000000000..9a4d918d3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/aop/OperateLogAspect.java @@ -0,0 +1,380 @@ +package com.cf.imes.framework.operatelog.core.aop; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.enums.UserTypeEnum; +import com.cf.imes.framework.common.pojo.CommonResult; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.common.util.monitor.TracerUtils; +import com.cf.imes.framework.common.util.servlet.ServletUtils; +import com.cf.imes.framework.operatelog.core.enums.OperateTypeEnum; +import com.cf.imes.framework.operatelog.core.service.OperateLog; +import com.cf.imes.framework.operatelog.core.service.OperateLogFrameworkService; +import com.cf.imes.framework.web.core.util.WebFrameworkUtils; +import com.google.common.collect.Maps; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.annotation.Annotation; +import java.lang.reflect.Array; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +import static com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; + +/** + * 拦截使用 @OperateLog 注解,如果满足条件,则生成操作日志。 + * 满足如下任一条件,则会进行记录: + * 1. 使用 @ApiOperation + 非 @GetMapping + * 2. 使用 @OperateLog 注解 + *

+ * 但是,如果声明 @OperateLog 注解时,将 enable 属性设置为 false 时,强制不记录。 + * + * @author 晨丰科技 + */ +@Aspect +@Slf4j +public class OperateLogAspect { + + /** + * 用于记录操作内容的上下文 + * + * @see OperateLog#getContent() + */ + private static final ThreadLocal CONTENT = new ThreadLocal<>(); + /** + * 用于记录拓展字段的上下文 + * + * @see OperateLog#getExts() + */ + private static final ThreadLocal> EXTS = new ThreadLocal<>(); + + @Resource + private OperateLogFrameworkService operateLogFrameworkService; + + @Around("@annotation(operation)") + public Object around(ProceedingJoinPoint joinPoint, Operation operation) throws Throwable { + // 可能也添加了 @ApiOperation 注解 + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog = getMethodAnnotation(joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog.class); + return around0(joinPoint, operateLog, operation); + } + + @Around("!@annotation(io.swagger.v3.oas.annotations.Operation) && @annotation(operateLog)") + // 兼容处理,只添加 @OperateLog 注解的情况 + public Object around(ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog) throws Throwable { + return around0(joinPoint, operateLog, null); + } + + private Object around0(ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog, + Operation operation) throws Throwable { + // 目前,只有管理员,才记录操作日志!所以非管理员,直接调用,不进行记录 + Integer userType = WebFrameworkUtils.getLoginUserType(); + if (!Objects.equals(userType, UserTypeEnum.ADMIN.getValue())) { + return joinPoint.proceed(); + } + + // 记录开始时间 + LocalDateTime startTime = LocalDateTime.now(); + try { + // 执行原有方法 + Object result = joinPoint.proceed(); + // 记录正常执行时的操作日志 + this.log(joinPoint, operateLog, operation, startTime, result, null); + return result; + } catch (Throwable exception) { + this.log(joinPoint, operateLog, operation, startTime, null, exception); + throw exception; + } finally { + clearThreadLocal(); + } + } + + public static void setContent(String content) { + CONTENT.set(content); + } + + public static void addExt(String key, Object value) { + if (EXTS.get() == null) { + EXTS.set(new HashMap<>()); + } + EXTS.get().put(key, value); + } + + private static void clearThreadLocal() { + CONTENT.remove(); + EXTS.remove(); + } + + private void log(ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog, + Operation operation, + LocalDateTime startTime, Object result, Throwable exception) { + try { + // 判断不记录的情况 + if (!isLogEnable(joinPoint, operateLog)) { + return; + } + // 真正记录操作日志 + this.log0(joinPoint, operateLog, operation, startTime, result, exception); + } catch (Throwable ex) { + log.error("[log][记录操作日志时,发生异常,其中参数是 joinPoint({}) operateLog({}) apiOperation({}) result({}) exception({}) ]", + joinPoint, operateLog, operation, result, exception, ex); + } + } + + private void log0(ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog, + Operation operation, + LocalDateTime startTime, Object result, Throwable exception) { + OperateLog operateLogObj = new OperateLog(); + // 补全通用字段 + operateLogObj.setTraceId(TracerUtils.getTraceId()); + operateLogObj.setStartTime(startTime); + // 补充用户信息 + fillUserFields(operateLogObj); + // 补全模块信息 + fillModuleFields(operateLogObj, joinPoint, operateLog, operation); + // 补全请求信息 + fillRequestFields(operateLogObj); + // 补全方法信息 + fillMethodFields(operateLogObj, joinPoint, operateLog, startTime, result, exception); + + // 异步记录日志 + operateLogFrameworkService.createOperateLog(operateLogObj); + } + + private static void fillUserFields(OperateLog operateLogObj) { + operateLogObj.setUserId(WebFrameworkUtils.getLoginUserId()); + operateLogObj.setUserType(WebFrameworkUtils.getLoginUserType()); + } + + private static void fillModuleFields(OperateLog operateLogObj, + ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog, + Operation operation) { + // module 属性 + if (operateLog != null) { + operateLogObj.setModule(operateLog.module()); + } + if (StrUtil.isEmpty(operateLogObj.getModule())) { + Tag tag = getClassAnnotation(joinPoint, Tag.class); + if (tag != null) { + // 优先读取 @Tag 的 name 属性 + if (StrUtil.isNotEmpty(tag.name())) { + operateLogObj.setModule(tag.name()); + } + // 没有的话,读取 @API 的 description 属性 + if (StrUtil.isEmpty(operateLogObj.getModule()) && ArrayUtil.isNotEmpty(tag.description())) { + operateLogObj.setModule(tag.description()); + } + } + } + // name 属性 + if (operateLog != null) { + operateLogObj.setName(operateLog.name()); + } + if (StrUtil.isEmpty(operateLogObj.getName()) && operation != null) { + operateLogObj.setName(operation.summary()); + } + // type 属性 + if (operateLog != null && ArrayUtil.isNotEmpty(operateLog.type())) { + operateLogObj.setType(operateLog.type()[0].getType()); + } + if (operateLogObj.getType() == null) { + RequestMethod requestMethod = obtainFirstMatchRequestMethod(obtainRequestMethod(joinPoint)); + OperateTypeEnum operateLogType = convertOperateLogType(requestMethod); + operateLogObj.setType(operateLogType != null ? operateLogType.getType() : null); + } + // content 和 exts 属性 + operateLogObj.setContent(CONTENT.get()); + operateLogObj.setExts(EXTS.get()); + } + + private static void fillRequestFields(OperateLog operateLogObj) { + // 获得 Request 对象 + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + // 补全请求信息 + operateLogObj.setRequestMethod(request.getMethod()); + operateLogObj.setRequestUrl(request.getRequestURI()); + operateLogObj.setUserIp(ServletUtils.getClientIP(request)); + operateLogObj.setUserAgent(ServletUtils.getUserAgent(request)); + } + + private static void fillMethodFields(OperateLog operateLogObj, + ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog, + LocalDateTime startTime, Object result, Throwable exception) { + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + operateLogObj.setJavaMethod(methodSignature.toString()); + if (operateLog == null || operateLog.logArgs()) { + operateLogObj.setJavaMethodArgs(obtainMethodArgs(joinPoint)); + } + if (operateLog == null || operateLog.logResultData()) { + operateLogObj.setResultData(obtainResultData(result)); + } + operateLogObj.setDuration((int) (LocalDateTimeUtil.between(startTime, LocalDateTime.now()).toMillis())); + // (正常)处理 resultCode 和 resultMsg 字段 + if (result instanceof CommonResult) { + CommonResult commonResult = (CommonResult) result; + operateLogObj.setResultCode(commonResult.getCode()); + operateLogObj.setResultMsg(commonResult.getMsg()); + } else { + operateLogObj.setResultCode(SUCCESS.getCode()); + } + // (异常)处理 resultCode 和 resultMsg 字段 + if (exception != null) { + operateLogObj.setResultCode(INTERNAL_SERVER_ERROR.getCode()); + operateLogObj.setResultMsg(ExceptionUtil.getRootCauseMessage(exception)); + } + } + + private static boolean isLogEnable(ProceedingJoinPoint joinPoint, + com.cf.imes.framework.operatelog.core.annotations.OperateLog operateLog) { + // 有 @OperateLog 注解的情况下 + if (operateLog != null) { + return operateLog.enable(); + } + // Cloud 专属逻辑:如果是 RPC 请求,则必须 @OperateLog 注解,才会记录操作日志 + String className = joinPoint.getSignature().getDeclaringType().getName(); + if (WebFrameworkUtils.isRpcRequest(className)) { + return false; + } + // 没有 @ApiOperation 注解的情况下,只记录 POST、PUT、DELETE 的情况 + return obtainFirstLogRequestMethod(obtainRequestMethod(joinPoint)) != null; + } + + private static RequestMethod obtainFirstLogRequestMethod(RequestMethod[] requestMethods) { + if (ArrayUtil.isEmpty(requestMethods)) { + return null; + } + return Arrays.stream(requestMethods).filter(requestMethod -> + requestMethod == RequestMethod.POST + || requestMethod == RequestMethod.PUT + || requestMethod == RequestMethod.DELETE) + .findFirst().orElse(null); + } + + private static RequestMethod obtainFirstMatchRequestMethod(RequestMethod[] requestMethods) { + if (ArrayUtil.isEmpty(requestMethods)) { + return null; + } + // 优先,匹配最优的 POST、PUT、DELETE + RequestMethod result = obtainFirstLogRequestMethod(requestMethods); + if (result != null) { + return result; + } + // 然后,匹配次优的 GET + result = Arrays.stream(requestMethods).filter(requestMethod -> requestMethod == RequestMethod.GET) + .findFirst().orElse(null); + if (result != null) { + return result; + } + // 兜底,获得第一个 + return requestMethods[0]; + } + + private static OperateTypeEnum convertOperateLogType(RequestMethod requestMethod) { + if (requestMethod == null) { + return null; + } + switch (requestMethod) { + case GET: + return OperateTypeEnum.GET; + case POST: + return OperateTypeEnum.CREATE; + case PUT: + return OperateTypeEnum.UPDATE; + case DELETE: + return OperateTypeEnum.DELETE; + default: + return OperateTypeEnum.OTHER; + } + } + + private static RequestMethod[] obtainRequestMethod(ProceedingJoinPoint joinPoint) { + RequestMapping requestMapping = AnnotationUtils.getAnnotation( // 使用 Spring 的工具类,可以处理 @RequestMapping 别名注解 + ((MethodSignature) joinPoint.getSignature()).getMethod(), RequestMapping.class); + return requestMapping != null ? requestMapping.method() : new RequestMethod[]{}; + } + + @SuppressWarnings("SameParameterValue") + private static T getMethodAnnotation(ProceedingJoinPoint joinPoint, Class annotationClass) { + return ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(annotationClass); + } + + @SuppressWarnings("SameParameterValue") + private static T getClassAnnotation(ProceedingJoinPoint joinPoint, Class annotationClass) { + return ((MethodSignature) joinPoint.getSignature()).getMethod().getDeclaringClass().getAnnotation(annotationClass); + } + + private static String obtainMethodArgs(ProceedingJoinPoint joinPoint) { + // TODO 提升:参数脱敏和忽略 + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + String[] argNames = methodSignature.getParameterNames(); + Object[] argValues = joinPoint.getArgs(); + // 拼接参数 + Map args = Maps.newHashMapWithExpectedSize(argValues.length); + for (int i = 0; i < argNames.length; i++) { + String argName = argNames[i]; + Object argValue = argValues[i]; + // 被忽略时,标记为 ignore 字符串,避免和 null 混在一起 + args.put(argName, !isIgnoreArgs(argValue) ? argValue : "[ignore]"); + } + return JsonUtils.toJsonString(args); + } + + private static String obtainResultData(Object result) { + // TODO 提升:结果脱敏和忽略 + if (result instanceof CommonResult) { + result = ((CommonResult) result).getData(); + } + return JsonUtils.toJsonString(result); + } + + private static boolean isIgnoreArgs(Object object) { + Class clazz = object.getClass(); + // 处理数组的情况 + if (clazz.isArray()) { + return IntStream.range(0, Array.getLength(object)) + .anyMatch(index -> isIgnoreArgs(Array.get(object, index))); + } + // 递归,处理数组、Collection、Map 的情况 + if (Collection.class.isAssignableFrom(clazz)) { + return ((Collection) object).stream() + .anyMatch((Predicate) OperateLogAspect::isIgnoreArgs); + } + if (Map.class.isAssignableFrom(clazz)) { + return isIgnoreArgs(((Map) object).values()); + } + // obj + return object instanceof MultipartFile + || object instanceof HttpServletRequest + || object instanceof HttpServletResponse + || object instanceof BindingResult; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/enums/OperateTypeEnum.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/enums/OperateTypeEnum.java new file mode 100644 index 000000000..a125c37ed --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/enums/OperateTypeEnum.java @@ -0,0 +1,55 @@ +package com.cf.imes.framework.operatelog.core.enums; + +import com.cf.imes.framework.operatelog.core.annotations.OperateLog; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 操作日志的操作类型 + * + * @author ruoyi + */ +@Getter +@AllArgsConstructor +public enum OperateTypeEnum { + + /** + * 查询 + * + * 绝大多数情况下,不会记录查询动作,因为过于大量显得没有意义。 + * 在有需要的时候,通过声明 {@link OperateLog} 注解来记录 + */ + GET(1), + /** + * 新增 + */ + CREATE(2), + /** + * 修改 + */ + UPDATE(3), + /** + * 删除 + */ + DELETE(4), + /** + * 导出 + */ + EXPORT(5), + /** + * 导入 + */ + IMPORT(6), + /** + * 其它 + * + * 在无法归类时,可以选择使用其它。因为还有操作名可以进一步标识 + */ + OTHER(0); + + /** + * 类型 + */ + private final Integer type; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/package-info.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/package-info.java new file mode 100644 index 000000000..cda0ded2f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.operatelog.core; diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLog.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLog.java new file mode 100644 index 000000000..d6996a348 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLog.java @@ -0,0 +1,110 @@ +package com.cf.imes.framework.operatelog.core.service; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 操作日志 + * + * @author 晨丰科技 + */ +@Data +public class OperateLog { + + /** + * 链路追踪编号 + */ + private String traceId; + + /** + * 用户编号 + */ + private Long userId; + /** + * 用户类型 + */ + private Integer userType; + + /** + * 操作模块 + */ + private String module; + + /** + * 操作名 + */ + private String name; + + /** + * 操作分类 + */ + private Integer type; + + /** + * 操作明细 + */ + private String content; + + /** + * 拓展字段 + */ + private Map exts; + + /** + * 请求方法名 + */ + private String requestMethod; + + /** + * 请求地址 + */ + private String requestUrl; + + /** + * 用户 IP + */ + private String userIp; + + /** + * 浏览器 UserAgent + */ + private String userAgent; + + /** + * Java 方法名 + */ + private String javaMethod; + + /** + * Java 方法的参数 + */ + private String javaMethodArgs; + + /** + * 开始时间 + */ + private LocalDateTime startTime; + + /** + * 执行时长,单位:毫秒 + */ + private Integer duration; + + /** + * 结果码 + */ + private Integer resultCode; + + /** + * 结果提示 + */ + private String resultMsg; + + /** + * 结果数据 + */ + private String resultData; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLogFrameworkService.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLogFrameworkService.java new file mode 100644 index 000000000..67ce942cb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLogFrameworkService.java @@ -0,0 +1,17 @@ +package com.cf.imes.framework.operatelog.core.service; + +/** + * 操作日志 Framework Service 接口 + * + * @author 晨丰科技 + */ +public interface OperateLogFrameworkService { + + /** + * 记录操作日志 + * + * @param operateLog 操作日志请求 + */ + void createOperateLog(OperateLog operateLog); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLogFrameworkServiceImpl.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLogFrameworkServiceImpl.java new file mode 100644 index 000000000..699a79f77 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/service/OperateLogFrameworkServiceImpl.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.operatelog.core.service; + +import cn.hutool.core.bean.BeanUtil; +import com.cf.imes.module.system.api.logger.OperateLogApi; +import com.cf.imes.module.system.api.logger.dto.OperateLogCreateReqDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; + +/** + * 操作日志 Framework Service 实现类 + * + * 基于 {@link OperateLogApi} 实现,记录操作日志 + * + * @author 晨丰科技 + */ +@RequiredArgsConstructor +public class OperateLogFrameworkServiceImpl implements OperateLogFrameworkService { + + private final OperateLogApi operateLogApi; + + @Override + @Async + public void createOperateLog(OperateLog operateLog) { + OperateLogCreateReqDTO reqDTO = BeanUtil.toBean(operateLog, OperateLogCreateReqDTO.class); + operateLogApi.createOperateLog(reqDTO); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/util/OperateLogUtils.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/util/OperateLogUtils.java new file mode 100644 index 000000000..97816be6d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/core/util/OperateLogUtils.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.operatelog.core.util; + +import com.cf.imes.framework.operatelog.core.aop.OperateLogAspect; + +/** + * 操作日志工具类 + * 目前主要的作用,是提供给业务代码,记录操作明细和拓展字段 + * + * @author 晨丰科技 + */ +public class OperateLogUtils { + + public static void setContent(String content) { + OperateLogAspect.setContent(content); + } + + public static void addExt(String key, Object value) { + OperateLogAspect.addExt(key, value); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/package-info.java b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/package-info.java new file mode 100644 index 000000000..5a705eb35 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/java/com/cf/imes/framework/operatelog/package-info.java @@ -0,0 +1,6 @@ +/** + * 用户操作日志:记录用户的操作,用于对用户的操作的审计与追溯,永久保存。 + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.operatelog; diff --git a/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..5fd18eb91 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-operatelog/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.operatelog.config.ChenfengOperateLogRpcAutoConfiguration +com.cf.imes.framework.operatelog.config.ChenfengOperateLogAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/pom.xml b/cf-framework/cf-spring-boot-starter-biz-organ/pom.xml new file mode 100644 index 000000000..7838c7f70 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/pom.xml @@ -0,0 +1,91 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-organ + jar + + ${project.artifactId} + 多组织 + + + + com.cf.imes + cf-common + + + + + com.cf.imes + cf-spring-boot-starter-security + + + + + com.cf.imes + cf-spring-boot-starter-mybatis + + + + com.cf.imes + cf-spring-boot-starter-redis + + + + + com.cf.imes + cf-spring-boot-starter-rpc + true + + + + + com.cf.imes + cf-spring-boot-starter-job + true + + + + + com.cf.imes + cf-spring-boot-starter-mq + true + + + org.springframework.kafka + spring-kafka + true + + + org.springframework.amqp + spring-rabbit + true + + + org.apache.rocketmq + rocketmq-spring-boot-starter + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.google.guava + guava + + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/ChenfengOrganAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/ChenfengOrganAutoConfiguration.java new file mode 100644 index 000000000..5e0b362b1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/ChenfengOrganAutoConfiguration.java @@ -0,0 +1,144 @@ +package com.cf.imes.framework.organ.config; + +import com.cf.imes.framework.common.enums.WebFilterOrderEnum; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.cf.imes.framework.redis.config.ChenfengCacheProperties; +import com.cf.imes.framework.organ.core.aop.OrganIgnoreAspect; +import com.cf.imes.framework.organ.core.db.OrganDatabaseInterceptor; +import com.cf.imes.framework.organ.core.job.OrganJobAspect; +import com.cf.imes.framework.organ.core.mq.rabbitmq.OrganRabbitMQInitializer; +import com.cf.imes.framework.organ.core.mq.redis.OrganRedisMessageInterceptor; +import com.cf.imes.framework.organ.core.mq.rocketmq.OrganRocketMQInitializer; +import com.cf.imes.framework.organ.core.redis.OrganRedisCacheManager; +import com.cf.imes.framework.organ.core.security.OrganSecurityWebFilter; +import com.cf.imes.framework.organ.core.service.OrganFrameworkService; +import com.cf.imes.framework.organ.core.service.OrganFrameworkServiceImpl; +import com.cf.imes.framework.organ.core.web.OrganContextWebFilter; +import com.cf.imes.framework.web.config.WebProperties; +import com.cf.imes.framework.web.core.handler.GlobalExceptionHandler; +import com.cf.imes.module.system.api.organ.OrganApi; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.BatchStrategies; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Objects; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "chenfeng.organ", value = "enable", matchIfMissing = true) // 允许使用 chenfeng.organ.enable=false 禁用多组织 +@EnableConfigurationProperties(OrganProperties.class) +public class ChenfengOrganAutoConfiguration { + + @Bean + public OrganFrameworkService tenantFrameworkService(OrganApi organApi) { + return new OrganFrameworkServiceImpl(organApi); + } + + // ========== AOP ========== + + @Bean + public OrganIgnoreAspect organIgnoreAspect() { + return new OrganIgnoreAspect(); + } + + // ========== DB ========== + + @Bean + public TenantLineInnerInterceptor tenantLineInnerInterceptor(OrganProperties properties, + MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new OrganDatabaseInterceptor(properties)); + // 添加到 interceptor 中 + // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定 + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + // ========== WEB ========== + + @Bean + public FilterRegistrationBean organContextWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new OrganContextWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER); + return registrationBean; + } + + // ========== Security ========== + + @Bean + public FilterRegistrationBean organSecurityWebFilter(OrganProperties tenantProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + OrganFrameworkService organFrameworkService) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new OrganSecurityWebFilter(tenantProperties, webProperties, + globalExceptionHandler, organFrameworkService)); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER); + return registrationBean; + } + + // ========== Job ========== + + @Bean + @ConditionalOnClass(name = "com.xxl.job.core.handler.annotation.XxlJob") + public OrganJobAspect organJobAspect(OrganFrameworkService organFrameworkService) { + return new OrganJobAspect(organFrameworkService); + } + + // ========== MQ ========== + + /** + * 多组织 Redis 消息队列的配置类 + * + * 为什么要单独一个配置类呢?如果直接把 TenantRedisMessageInterceptor Bean 的初始化放外面,会报 RedisMessageInterceptor 类不存在的错误 + */ + @Configuration + @ConditionalOnClass(name = "com.cf.imes.framework.mq.redis.core.RedisMQTemplate") + public static class OrganRedisMQAutoConfiguration { + + @Bean + public OrganRedisMessageInterceptor organRedisMessageInterceptor() { + return new OrganRedisMessageInterceptor(); + } + + } + + @Bean + @ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") + public OrganRabbitMQInitializer organRabbitMQInitializer() { + return new OrganRabbitMQInitializer(); + } + + @Bean + @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate") + public OrganRocketMQInitializer organRocketMQInitializer() { + return new OrganRocketMQInitializer(); + } + + // ========== Redis ========== + + @Bean + @Primary // 引入组织时,tenantRedisCacheManager 为主 Bean + public RedisCacheManager organRedisCacheManager(RedisTemplate redisTemplate, + RedisCacheConfiguration redisCacheConfiguration, + ChenfengCacheProperties chenfengCacheProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(chenfengCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new OrganRedisCacheManager(cacheWriter, redisCacheConfiguration); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/ChenfengOrganRpcAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/ChenfengOrganRpcAutoConfiguration.java new file mode 100644 index 000000000..381e5c604 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/ChenfengOrganRpcAutoConfiguration.java @@ -0,0 +1,20 @@ +package com.cf.imes.framework.organ.config; + +import com.cf.imes.framework.organ.core.rpc.OrganRequestInterceptor; +import com.cf.imes.module.system.api.organ.OrganApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "chenfeng.organ", value = "enable", matchIfMissing = true) // 允许使用 chenfeng.organ.enable=false 禁用多组织 +@EnableFeignClients(clients = OrganApi.class) // 主要是引入相关的 API 服务 +public class ChenfengOrganRpcAutoConfiguration { + + @Bean + public OrganRequestInterceptor organRequestInterceptor() { + return new OrganRequestInterceptor(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/OrganProperties.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/OrganProperties.java new file mode 100644 index 000000000..cbebf6003 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/config/OrganProperties.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.organ.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Collections; +import java.util.Set; + +/** + * 多组织配置 + * + * @author 晨丰科技 + */ +@ConfigurationProperties(prefix = "chenfeng.organ") +@Data +public class OrganProperties { + + /** + * 组织是否开启 + */ + private static final Boolean ENABLE_DEFAULT = true; + + /** + * 是否开启 + */ + private Boolean enable = ENABLE_DEFAULT; + + /** + * 需要忽略多组织的请求 + * + * 默认情况下,每个请求需要带上 organ-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API! + */ + private Set ignoreUrls = Collections.emptySet(); + + /** + * 需要忽略多组织的表 + * + * 即默认所有表都开启多组织的功能,所以记得添加对应的 organ_id 字段哟 + */ + private Set ignoreTables = Collections.emptySet(); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/aop/OrganIgnore.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/aop/OrganIgnore.java new file mode 100644 index 000000000..b979dfb88 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/aop/OrganIgnore.java @@ -0,0 +1,18 @@ +package com.cf.imes.framework.organ.core.aop; + +import java.lang.annotation.*; + +/** + * 忽略组织,标记指定方法不进行组织的自动过滤 + * + * 注意,只有 DB 的场景会过滤,其它场景暂时不过滤: + * 1、Redis 场景:因为是基于 Key 实现多组织的能力,所以忽略没有意义,不像 DB 是一个 column 实现的 + * 2、MQ 场景:有点难以抉择,目前可以通过 Consumer 手动在消费的方法上,添加 @OrganIgnore 进行忽略 + * + * @author 晨丰科技 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface OrganIgnore { +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/aop/OrganIgnoreAspect.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/aop/OrganIgnoreAspect.java new file mode 100644 index 000000000..04dba6895 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/aop/OrganIgnoreAspect.java @@ -0,0 +1,35 @@ +package com.cf.imes.framework.organ.core.aop; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import com.cf.imes.framework.organ.core.util.OrganUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +/** + * 忽略多组织的 Aspect,基于 {@link OrganIgnore} 注解实现,用于一些全局的逻辑。 + * 例如说,一个定时任务,读取所有数据,进行处理。 + * 又例如说,读取所有数据,进行缓存。 + * + * 整体逻辑的实现,和 {@link OrganUtils#executeIgnore(Runnable)} 需要保持一致 + * + * @author 晨丰科技 + */ +@Aspect +@Slf4j +public class OrganIgnoreAspect { + + @Around("@annotation(organIgnore)") + public Object around(ProceedingJoinPoint joinPoint, OrganIgnore organIgnore) throws Throwable { + Boolean oldIgnore = OrganContextHolder.isIgnore(); + try { + OrganContextHolder.setIgnore(true); + // 执行逻辑 + return joinPoint.proceed(); + } finally { + OrganContextHolder.setIgnore(oldIgnore); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/context/OrganContextHolder.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/context/OrganContextHolder.java new file mode 100644 index 000000000..1912f9a3f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/context/OrganContextHolder.java @@ -0,0 +1,79 @@ +package com.cf.imes.framework.organ.core.context; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.enums.DocumentEnum; +import com.alibaba.ttl.TransmittableThreadLocal; + +/** + * 多组织上下文 Holder + * + * @author 晨丰科技 + */ +public class OrganContextHolder { + + /** + * 当前组织编号 + */ + private static final ThreadLocal ORGAN_ID = new TransmittableThreadLocal<>(); + + /** + * 是否忽略组织 + */ + private static final ThreadLocal IGNORE = new TransmittableThreadLocal<>(); + + /** + * 获得组织编号 + * + * @return 组织编号 + */ + public static Long getOrganId() { + return ORGAN_ID.get(); + } + + /** + * 获得组织编号 String + * + * @return 组织编号 + */ + public static String getOrganIdStr() { + Long organId = getOrganId(); + return StrUtil.toStringOrNull(organId); + } + + /** + * 获得组织编号。如果不存在,则抛出 NullPointerException 异常 + * + * @return 组织编号 + */ + public static Long getRequiredOrganId() { + Long organId = getOrganId(); + if (organId == null) { + throw new NullPointerException("OrganContextHolder 不存在组织编号!可参考文档:" + + DocumentEnum.ORGAN.getUrl()); + } + return organId; + } + + public static void setOrganId(Long organId) { + ORGAN_ID.set(organId); + } + + public static void setIgnore(Boolean ignore) { + IGNORE.set(ignore); + } + + /** + * 当前是否忽略组织 + * + * @return 是否忽略 + */ + public static boolean isIgnore() { + return Boolean.TRUE.equals(IGNORE.get()); + } + + public static void clear() { + ORGAN_ID.remove(); + IGNORE.remove(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/db/OrganBaseDO.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/db/OrganBaseDO.java new file mode 100644 index 000000000..a35fd30cc --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/db/OrganBaseDO.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.organ.core.db; + +import com.cf.imes.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 拓展多组织的 BaseDO 基类 + * + * @author 晨丰科技 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public abstract class OrganBaseDO extends BaseDO { + + /** + * 多组织编号 + */ + private Long organId; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/db/OrganDatabaseInterceptor.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/db/OrganDatabaseInterceptor.java new file mode 100644 index 000000000..a6654278b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/db/OrganDatabaseInterceptor.java @@ -0,0 +1,63 @@ +package com.cf.imes.framework.organ.core.db; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.organ.config.OrganProperties; +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.cf.imes.framework.security.core.LoginUser; +import com.cf.imes.framework.security.core.util.SecurityFrameworkUtils; +import com.cf.imes.module.system.enums.permission.RoleCodeEnum; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 基于 MyBatis Plus 多组织的功能,实现 DB 层面的多组织的功能 + * + * @author 晨丰科技 + */ +public class OrganDatabaseInterceptor implements TenantLineHandler { + + private final Set ignoreTables = new HashSet<>(); + + public OrganDatabaseInterceptor(OrganProperties properties) { + // 不同 DB 下,大小写的习惯不同,所以需要都添加进去 + properties.getIgnoreTables().forEach(table -> { + ignoreTables.add(table.toLowerCase()); + ignoreTables.add(table.toUpperCase()); + }); + // 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 ORGAN_ID 导致报错 + ignoreTables.add("DUAL"); + } + + @Override + public Expression getTenantId() { + return new LongValue(OrganContextHolder.getRequiredOrganId()); + } + + @Override + public String getTenantIdColumn() { + return "organ_id"; + } + + @Override + public boolean ignoreTable(String tableName) { + return OrganContextHolder.isIgnore() // 情况一,全局忽略多组织 + || CollUtil.contains(ignoreTables, tableName) // 情况二,忽略多组织的表 + || isAdmin(); //情况三,忽略超级管理员 + } + private boolean isAdmin() { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if(!Objects.isNull(loginUser)) { + Boolean isSupAdmin = loginUser.getIsSupAdmin(); + return Objects.isNull(isSupAdmin)? Boolean.FALSE : isSupAdmin; + } + return Boolean.FALSE; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/job/OrganJob.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/job/OrganJob.java new file mode 100644 index 000000000..8a0640749 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/job/OrganJob.java @@ -0,0 +1,14 @@ +package com.cf.imes.framework.organ.core.job; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 多组织 Job 注解 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface OrganJob { +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/job/OrganJobAspect.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/job/OrganJobAspect.java new file mode 100644 index 000000000..34b483a23 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/job/OrganJobAspect.java @@ -0,0 +1,65 @@ +package com.cf.imes.framework.organ.core.job; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.organ.core.service.OrganFrameworkService; +import com.cf.imes.framework.organ.core.util.OrganUtils; +import com.xxl.job.core.context.XxlJobHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 多组织 JobHandler AOP + * 任务执行时,会按照组织逐个执行 Job 的逻辑 + * + * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个组织执行失败重试时,之前执行成功的组织也会再次执行。 + * + * @author 晨丰科技 + */ +@Aspect +@RequiredArgsConstructor +@Slf4j +public class OrganJobAspect { + + private final OrganFrameworkService organFrameworkService; + + @Around("@annotation(organJob)") + public void around(ProceedingJoinPoint joinPoint, OrganJob organJob) { + // 获得组织列表 + List organIds = organFrameworkService.getOrganIds(); + if (CollUtil.isEmpty(organIds)) { + return; + } + + // 逐个组织,执行 Job + Map results = new ConcurrentHashMap<>(); + organIds.parallelStream().forEach(organId -> { + // TODO 晨丰:先通过 parallel 实现并行;1)多个组织,是一条执行日志;2)异常的情况 + OrganUtils.execute(organId, () -> { + try { + joinPoint.proceed(); + } catch (Throwable e) { + results.put(organId, ExceptionUtil.getRootCauseMessage(e)); + // 打印异常 + XxlJobHelper.log(StrUtil.format("[多组织({}) 执行任务({}),发生异常:{}]", + organId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e))); + } + }); + }); + // 如果 results 非空,说明发生了异常,标记 XXL-Job 执行失败 + if (CollUtil.isNotEmpty(results)) { + XxlJobHelper.handleFail(JsonUtils.toJsonString(results)); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/kafka/OrganKafkaEnvironmentPostProcessor.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/kafka/OrganKafkaEnvironmentPostProcessor.java new file mode 100644 index 000000000..ac1d22c23 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/kafka/OrganKafkaEnvironmentPostProcessor.java @@ -0,0 +1,37 @@ +package com.cf.imes.framework.organ.core.mq.kafka; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * 多组织的 Kafka 的 {@link EnvironmentPostProcessor} 实现类 + * + * Kafka Producer 发送消息时,增加 {@link OrganKafkaProducerInterceptor} 拦截器 + * + * @author 晨丰科技 + */ +@Slf4j +public class OrganKafkaEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String PROPERTY_KEY_INTERCEPTOR_CLASSES = "spring.kafka.producer.properties.interceptor.classes"; + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 添加 OrganKafkaProducerInterceptor 拦截器 + try { + String value = environment.getProperty(PROPERTY_KEY_INTERCEPTOR_CLASSES); + if (StrUtil.isEmpty(value)) { + value = OrganKafkaProducerInterceptor.class.getName(); + } else { + value += "," + OrganKafkaProducerInterceptor.class.getName(); + } + environment.getSystemProperties().put(PROPERTY_KEY_INTERCEPTOR_CLASSES, value); + } catch (NoClassDefFoundError ignore) { + // 如果触发 NoClassDefFoundError 异常,说明 TenantKafkaProducerInterceptor 类不存在,即没引入 kafka-spring 依赖 + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/kafka/OrganKafkaProducerInterceptor.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/kafka/OrganKafkaProducerInterceptor.java new file mode 100644 index 000000000..d0ae944b4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/kafka/OrganKafkaProducerInterceptor.java @@ -0,0 +1,47 @@ +package com.cf.imes.framework.organ.core.mq.kafka; + +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import org.apache.kafka.clients.producer.ProducerInterceptor; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.header.Headers; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import java.util.Map; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * Kafka 消息队列的多组织 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link OrganContextHolder} 组织编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的组织编号,添加到 {@link OrganContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 晨丰科技 + */ +public class OrganKafkaProducerInterceptor implements ProducerInterceptor { + + @Override + public ProducerRecord onSend(ProducerRecord record) { + Long organId = OrganContextHolder.getOrganId(); + if (organId != null) { + Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); // private 属性,没有 get 方法,智能反射 + headers.add(HEADER_ORGAN_ID, organId.toString().getBytes()); + } + return record; + } + + @Override + public void onAcknowledgement(RecordMetadata metadata, Exception exception) { + } + + @Override + public void close() { + } + + @Override + public void configure(Map configs) { + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rabbitmq/OrganRabbitMQInitializer.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rabbitmq/OrganRabbitMQInitializer.java new file mode 100644 index 000000000..4bd4e3c80 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rabbitmq/OrganRabbitMQInitializer.java @@ -0,0 +1,23 @@ +package com.cf.imes.framework.organ.core.mq.rabbitmq; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** + * 多组织的 RabbitMQ 初始化器 + * + * @author 晨丰科技 + */ +public class OrganRabbitMQInitializer implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof RabbitTemplate) { + RabbitTemplate rabbitTemplate = (RabbitTemplate) bean; + rabbitTemplate.addBeforePublishPostProcessors(new OrganRabbitMQMessagePostProcessor()); + } + return bean; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rabbitmq/OrganRabbitMQMessagePostProcessor.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rabbitmq/OrganRabbitMQMessagePostProcessor.java new file mode 100644 index 000000000..9d858df0c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rabbitmq/OrganRabbitMQMessagePostProcessor.java @@ -0,0 +1,31 @@ +package com.cf.imes.framework.organ.core.mq.rabbitmq; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import org.apache.kafka.clients.producer.ProducerInterceptor; +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * RabbitMQ 消息队列的多组织 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link OrganContextHolder} 组织编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的组织编号,添加到 {@link OrganContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 晨丰科技 + */ +public class OrganRabbitMQMessagePostProcessor implements MessagePostProcessor { + + @Override + public Message postProcessMessage(Message message) throws AmqpException { + Long organId = OrganContextHolder.getOrganId(); + if (organId != null) { + message.getMessageProperties().getHeaders().put(HEADER_ORGAN_ID, organId); + } + return message; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/redis/OrganRedisMessageInterceptor.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/redis/OrganRedisMessageInterceptor.java new file mode 100644 index 000000000..c5c5cd41a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/redis/OrganRedisMessageInterceptor.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.organ.core.mq.redis; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; +import com.cf.imes.framework.organ.core.context.OrganContextHolder; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * 多组织 {@link AbstractRedisMessage} 拦截器 + * + * 1. Producer 发送消息时,将 {@link OrganContextHolder} 组织编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的组织编号,添加到 {@link OrganContextHolder} 中 + * + * @author 晨丰科技 + */ +public class OrganRedisMessageInterceptor implements RedisMessageInterceptor { + + @Override + public void sendMessageBefore(AbstractRedisMessage message) { + Long organId = OrganContextHolder.getOrganId(); + if (organId != null) { + message.addHeader(HEADER_ORGAN_ID, organId.toString()); + } + } + + @Override + public void consumeMessageBefore(AbstractRedisMessage message) { + String organIdStr = message.getHeader(HEADER_ORGAN_ID); + if (StrUtil.isNotEmpty(organIdStr)) { + OrganContextHolder.setOrganId(Long.valueOf(organIdStr)); + } + } + + @Override + public void consumeMessageAfter(AbstractRedisMessage message) { + // 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在组织编号的情况 + OrganContextHolder.clear(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQConsumeMessageHook.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQConsumeMessageHook.java new file mode 100644 index 000000000..028e4d174 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQConsumeMessageHook.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.organ.core.mq.rocketmq; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import org.apache.rocketmq.client.hook.ConsumeMessageContext; +import org.apache.rocketmq.client.hook.ConsumeMessageHook; +import org.apache.rocketmq.common.message.MessageExt; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import java.util.List; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * RocketMQ 消息队列的多组织 {@link ConsumeMessageHook} 实现类 + * + * Consumer 消费消息时,将消息的 Header 的组织编号,添加到 {@link OrganContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 晨丰科技 + */ +public class OrganRocketMQConsumeMessageHook implements ConsumeMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void consumeMessageBefore(ConsumeMessageContext context) { + // 校验,消息必须是单条,不然设置组织可能不正确 + List messages = context.getMsgList(); + Assert.isTrue(messages.size() == 1, "消息条数({})不正确", messages.size()); + // 设置组织编号 + String organId = messages.get(0).getUserProperty(HEADER_ORGAN_ID); + if (StrUtil.isNotEmpty(organId)) { + OrganContextHolder.setOrganId(Long.parseLong(organId)); + } + } + + @Override + public void consumeMessageAfter(ConsumeMessageContext context) { + OrganContextHolder.clear(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQInitializer.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQInitializer.java new file mode 100644 index 000000000..b5bb72e12 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQInitializer.java @@ -0,0 +1,53 @@ +package com.cf.imes.framework.organ.core.mq.rocketmq; + +import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; +import org.apache.rocketmq.client.impl.consumer.DefaultMQPushConsumerImpl; +import org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl; +import org.apache.rocketmq.client.producer.DefaultMQProducer; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** + * 多组织的 RocketMQ 初始化器 + * + * @author 晨丰科技 + */ +public class OrganRocketMQInitializer implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof DefaultRocketMQListenerContainer) { + DefaultRocketMQListenerContainer container = (DefaultRocketMQListenerContainer) bean; + initOrganConsumer(container.getConsumer()); + } else if (bean instanceof RocketMQTemplate) { + RocketMQTemplate template = (RocketMQTemplate) bean; + initOrganProducer(template.getProducer()); + } + return bean; + } + + private void initOrganProducer(DefaultMQProducer producer) { + if (producer == null) { + return; + } + DefaultMQProducerImpl producerImpl = producer.getDefaultMQProducerImpl(); + if (producerImpl == null) { + return; + } + producerImpl.registerSendMessageHook(new OrganRocketMQSendMessageHook()); + } + + private void initOrganConsumer(DefaultMQPushConsumer consumer) { + if (consumer == null) { + return; + } + DefaultMQPushConsumerImpl consumerImpl = consumer.getDefaultMQPushConsumerImpl(); + if (consumerImpl == null) { + return; + } + consumerImpl.registerConsumeMessageHook(new OrganRocketMQConsumeMessageHook()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQSendMessageHook.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQSendMessageHook.java new file mode 100644 index 000000000..a10a7ddca --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/mq/rocketmq/OrganRocketMQSendMessageHook.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.organ.core.mq.rocketmq; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import org.apache.rocketmq.client.hook.SendMessageContext; +import org.apache.rocketmq.client.hook.SendMessageHook; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * RocketMQ 消息队列的多组织 {@link SendMessageHook} 实现类 + * + * Producer 发送消息时,将 {@link OrganContextHolder} 组织编号,添加到消息的 Header 中 + * + * @author 晨丰科技 + */ +public class OrganRocketMQSendMessageHook implements SendMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void sendMessageBefore(SendMessageContext sendMessageContext) { + Long organId = OrganContextHolder.getOrganId(); + if (organId == null) { + return; + } + sendMessageContext.getMessage().putUserProperty(HEADER_ORGAN_ID, organId.toString()); + } + + @Override + public void sendMessageAfter(SendMessageContext sendMessageContext) { + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/redis/OrganRedisCacheManager.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/redis/OrganRedisCacheManager.java new file mode 100644 index 000000000..5cc2d3ea7 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/redis/OrganRedisCacheManager.java @@ -0,0 +1,38 @@ +package com.cf.imes.framework.organ.core.redis; + +import com.cf.imes.framework.redis.core.TimeoutRedisCacheManager; +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; + +/** + * 多组织的 {@link RedisCacheManager} 实现类 + * + * 操作指定 name 的 {@link Cache} 时,自动拼接组织后缀,格式为 name + ":" + organId + 后缀 + * + * @author airhead + */ +@Slf4j +public class OrganRedisCacheManager extends TimeoutRedisCacheManager { + + public OrganRedisCacheManager(RedisCacheWriter cacheWriter, + RedisCacheConfiguration defaultCacheConfiguration) { + super(cacheWriter, defaultCacheConfiguration); + } + + @Override + public Cache getCache(String name) { + // 如果开启多组织,则 name 拼接组织后缀 + if (!OrganContextHolder.isIgnore() + && OrganContextHolder.getOrganId() != null) { + name = name + ":" + OrganContextHolder.getOrganId(); + } + + // 继续基于父方法 + return super.getCache(name); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/rpc/OrganRequestInterceptor.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/rpc/OrganRequestInterceptor.java new file mode 100644 index 000000000..eda2b2f8d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/rpc/OrganRequestInterceptor.java @@ -0,0 +1,24 @@ +package com.cf.imes.framework.organ.core.rpc; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import feign.RequestInterceptor; +import feign.RequestTemplate; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * Tenant 的 RequestInterceptor 实现类:Feign 请求时,将 {@link OrganContextHolder} 设置到 header 中,继续透传给被调用的服务 + * + * @author 晨丰科技 + */ +public class OrganRequestInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate requestTemplate) { + Long organId = OrganContextHolder.getOrganId(); + if (organId != null) { + requestTemplate.header(HEADER_ORGAN_ID, String.valueOf(organId)); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/security/OrganSecurityWebFilter.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/security/OrganSecurityWebFilter.java new file mode 100644 index 000000000..e189fc7c1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/security/OrganSecurityWebFilter.java @@ -0,0 +1,130 @@ +package com.cf.imes.framework.organ.core.security; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.enums.RpcConstants; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.cf.imes.framework.common.pojo.CommonResult; +import com.cf.imes.framework.common.util.servlet.ServletUtils; +import com.cf.imes.framework.security.core.LoginUser; +import com.cf.imes.framework.security.core.util.SecurityFrameworkUtils; +import com.cf.imes.framework.organ.config.OrganProperties; +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import com.cf.imes.framework.organ.core.service.OrganFrameworkService; +import com.cf.imes.framework.web.config.WebProperties; +import com.cf.imes.framework.web.core.filter.ApiRequestFilter; +import com.cf.imes.framework.web.core.handler.GlobalExceptionHandler; +import com.cf.imes.framework.web.core.util.WebFrameworkUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; + +/** + * 多组织 Security Web 过滤器 + * 1. 如果是登陆的用户,校验是否有权限访问该组织,避免越权问题。 + * 2. 如果请求未带组织的编号,检查是否是忽略的 URL,否则也不允许访问。 + * 3. 校验组织是合法,例如说被禁用、到期 + * + * 校验用户访问的组织,是否是其所在的组织, + * + * @author 晨丰科技 + */ +@Slf4j +public class OrganSecurityWebFilter extends ApiRequestFilter { + + private final OrganProperties organProperties; + + private final AntPathMatcher pathMatcher; + + private final GlobalExceptionHandler globalExceptionHandler; + private final OrganFrameworkService organFrameworkService; + + public OrganSecurityWebFilter(OrganProperties organProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + OrganFrameworkService organFrameworkService) { + super(webProperties); + this.organProperties = organProperties; + this.pathMatcher = new AntPathMatcher(); + this.globalExceptionHandler = globalExceptionHandler; + this.organFrameworkService = organFrameworkService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return super.shouldNotFilter(request) && + !StrUtil.startWithAny(request.getRequestURI(), RpcConstants.RPC_API_PREFIX); // 因为 RPC API 也会透传组织编号 + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Long organId = OrganContextHolder.getOrganId(); + boolean isRpcRequest = WebFrameworkUtils.isRpcRequest(request); + // 1. 登陆的用户,校验是否有权限访问该组织,避免越权问题。 + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user != null) { + // 如果获取不到组织编号,则尝试使用登陆用户的组织编号 + if (organId == null) { + organId = user.getOrganId(); + OrganContextHolder.setOrganId(organId); + // 如果传递了组织编号,则进行比对组织编号,避免越权问题 + } else if (!Objects.equals(user.getOrganId(), OrganContextHolder.getOrganId()) + && !isRpcRequest) { // Cloud 特殊逻辑:如果是 RPC 请求,就不校验了。主要考虑,一些场景下,会调用 OrganUtils 去切换组织 + log.error("[doFilterInternal][组织({}) User({}/{}) 越权访问组织({}) URL({}/{})]", + user.getOrganId(), user.getId(), user.getUserType(), + OrganContextHolder.getOrganId(), request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(), + "您无权访问该组织的数据")); + return; + } + } + + // 如果非允许忽略组织的 URL,则校验组织是否合法 + if (!isIgnoreUrl(request)) { + // 2. 如果请求未带组织的编号,不允许访问。 + if (organId == null) { + log.error("[doFilterInternal][URL({}/{}) 未传递组织编号]", request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), + "请求的组织标识未传递,请进行排查")); + return; + } + // 3. 校验组织是合法,例如说被禁用、到期 + try { + organFrameworkService.validOrgan(organId); + } catch (Throwable ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } else { // 如果是允许忽略组织的 URL,若未传递组织编号,则默认忽略组织编号,避免报错 + if (organId == null) { + OrganContextHolder.setIgnore(true); + } + } + + // 继续过滤 + chain.doFilter(request, response); + } + + private boolean isIgnoreUrl(HttpServletRequest request) { + // 快速匹配,保证性能 + if (CollUtil.contains(organProperties.getIgnoreUrls(), request.getRequestURI())) { + return true; + } + // 逐个 Ant 路径匹配 + for (String url : organProperties.getIgnoreUrls()) { + if (pathMatcher.match(url, request.getRequestURI())) { + return true; + } + } + return false; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/service/OrganFrameworkService.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/service/OrganFrameworkService.java new file mode 100644 index 000000000..9bb84d026 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/service/OrganFrameworkService.java @@ -0,0 +1,26 @@ +package com.cf.imes.framework.organ.core.service; + +import java.util.List; + +/** + * Organ 框架 Service 接口,定义获取组织信息 + * + * @author 晨丰科技 + */ +public interface OrganFrameworkService { + + /** + * 获得所有组织 + * + * @return 组织编号数组 + */ + List getOrganIds(); + + /** + * 校验组织是否合法 + * + * @param id 组织编号 + */ + void validOrgan(Long id); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/service/OrganFrameworkServiceImpl.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/service/OrganFrameworkServiceImpl.java new file mode 100644 index 000000000..134767c92 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/service/OrganFrameworkServiceImpl.java @@ -0,0 +1,64 @@ +package com.cf.imes.framework.organ.core.service; + +import com.cf.imes.framework.common.pojo.CommonResult; +import com.cf.imes.framework.common.util.cache.CacheUtils; +import com.cf.imes.module.system.api.organ.OrganApi; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; + +import java.time.Duration; +import java.util.List; + +/** + * Organ 框架 Service 实现类 + * + * @author 晨丰科技 + */ +@RequiredArgsConstructor +public class OrganFrameworkServiceImpl implements OrganFrameworkService { + + private final OrganApi organApi; + + /** + * 针对 {@link #getOrganIds()} 的缓存 + */ + private final LoadingCache> getOrganIdsCache = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader>() { + + @Override + public List load(Object key) { + return organApi.getOrganIdList().getCheckedData(); + } + + }); + + /** + * 针对 {@link #validOrgan(Long)} 的缓存 + */ + private final LoadingCache> validOrganCache = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader>() { + + @Override + public CommonResult load(Long id) { + return organApi.validOrgan(id); + } + + }); + + @Override + @SneakyThrows + public List getOrganIds() { + return getOrganIdsCache.get(Boolean.TRUE); + } + + @Override + @SneakyThrows + public void validOrgan(Long id) { + validOrganCache.get(id).checkError(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/util/OrganUtils.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/util/OrganUtils.java new file mode 100644 index 000000000..0fd449e64 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/util/OrganUtils.java @@ -0,0 +1,93 @@ +package com.cf.imes.framework.organ.core.util; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; + +import java.util.Map; +import java.util.concurrent.Callable; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * 多组织 Util + * + * @author 晨丰科技 + */ +public class OrganUtils { + + /** + * 使用指定组织,执行对应的逻辑 + * + * 注意,如果当前是忽略组织的情况下,会被强制设置成不忽略组织 + * 当然,执行完成后,还是会恢复回去 + * + * @param organId 组织编号 + * @param runnable 逻辑 + */ + public static void execute(Long organId, Runnable runnable) { + Long oldorganId = OrganContextHolder.getOrganId(); + Boolean oldIgnore = OrganContextHolder.isIgnore(); + try { + OrganContextHolder.setOrganId(organId); + OrganContextHolder.setIgnore(false); + // 执行逻辑 + runnable.run(); + } finally { + OrganContextHolder.setOrganId(oldorganId); + OrganContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 使用指定组织,执行对应的逻辑 + * + * 注意,如果当前是忽略组织的情况下,会被强制设置成不忽略组织 + * 当然,执行完成后,还是会恢复回去 + * + * @param organId 组织编号 + * @param callable 逻辑 + */ + public static V execute(Long organId, Callable callable) { + Long oldorganId = OrganContextHolder.getOrganId(); + Boolean oldIgnore = OrganContextHolder.isIgnore(); + try { + OrganContextHolder.setOrganId(organId); + OrganContextHolder.setIgnore(false); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + OrganContextHolder.setOrganId(oldorganId); + OrganContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 忽略组织,执行对应的逻辑 + * + * @param runnable 逻辑 + */ + public static void executeIgnore(Runnable runnable) { + Boolean oldIgnore = OrganContextHolder.isIgnore(); + try { + OrganContextHolder.setIgnore(true); + // 执行逻辑 + runnable.run(); + } finally { + OrganContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 将多组织编号,添加到 header 中 + * + * @param headers HTTP 请求 headers + * @param organId 组织编号 + */ + public static void addTenantHeader(Map headers, Long organId) { + if (organId != null) { + headers.put(HEADER_ORGAN_ID, organId.toString()); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/web/OrganContextWebFilter.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/web/OrganContextWebFilter.java new file mode 100644 index 000000000..d526ca436 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/core/web/OrganContextWebFilter.java @@ -0,0 +1,37 @@ +package com.cf.imes.framework.organ.core.web; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import com.cf.imes.framework.web.core.util.WebFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 多组织 Context Web 过滤器 + * 将请求 Header 中的 tenant-id 解析出来,添加到 {@link OrganContextHolder} 中,这样后续的 DB 等操作,可以获得到组织编号。 + * + * @author 晨丰科技 + */ +public class OrganContextWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 设置 + Long organId = WebFrameworkUtils.getOrganId(request); + if (organId != null) { + OrganContextHolder.setOrganId(organId); + } + try { + chain.doFilter(request, response); + } finally { + // 清理 + OrganContextHolder.clear(); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/package-info.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/package-info.java new file mode 100644 index 000000000..339ac7d1f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/com/cf/imes/framework/organ/package-info.java @@ -0,0 +1,17 @@ +/** + * 多组织,支持如下层面: + * 1. DB:基于 MyBatis Plus 多组织的功能实现。 + * 2. Redis:通过在 Redis Key 上拼接组织编号的方式,进行隔离。 + * 3. Web:请求 HTTP API 时,解析 Header 的 organ-id 组织编号,添加到组织上下文。 + * 4. Security:校验当前登陆的用户,是否越权访问其它组织的数据。 + * 5. Job:在 JobHandler 执行任务时,会按照每个组织,都独立并行执行一次。 + * 6. MQ:在 Producer 发送消息时,Header 带上 organ-id 组织编号;在 Consumer 消费消息时,将 Header 的 organ-id 组织编号,添加到组织上下文。 + * 7. Async:异步需要保证 ThreadLocal 的传递性,通过使用阿里开源的 TransmittableThreadLocal 实现。相关的改造点,可见: + * 1)Spring Async: + * {@link com.cf.imes.framework.quartz.config.ChenfengAsyncAutoConfiguration#threadPoolTaskExecutorBeanPostProcessor()} + * 2)Spring Security: + * TransmittableThreadLocalSecurityContextHolderStrategy + * 和 chenfengSecurityAutoConfiguration#securityContextHolderMethodInvokingFactoryBean() 方法 + * + */ +package com.cf.imes.framework.organ; diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java new file mode 100644 index 000000000..4e4462811 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java @@ -0,0 +1,269 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * 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 + * + * https://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.springframework.messaging.handler.invocation; + +import com.cf.imes.framework.organ.core.context.OrganContextHolder; +import com.cf.imes.framework.organ.core.util.OrganUtils; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.util.ObjectUtils; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Arrays; + +import static com.cf.imes.framework.web.core.util.WebFrameworkUtils.HEADER_ORGAN_ID; + +/** + * Extension of {@link HandlerMethod} that invokes the underlying method with + * argument values resolved from the current HTTP request through a list of + * {@link HandlerMethodArgumentResolver}. + * + * 针对 rabbitmq-spring 和 kafka-spring,不存在合适的拓展点,可以实现 Consumer 消费前,读取 Header 中的 organ-id 设置到 {@link OrganContextHolder} 中 + * TODO 晨丰:持续跟进,看看有没新的拓展点 + * + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @since 4.0 + */ +public class InvocableHandlerMethod extends HandlerMethod { + + private static final Object[] EMPTY_ARGS = new Object[0]; + + private HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + /** + * Create an instance from a {@code HandlerMethod}. + */ + public InvocableHandlerMethod(HandlerMethod handlerMethod) { + super(handlerMethod); + } + + /** + * Create an instance from a bean instance and a method. + */ + public InvocableHandlerMethod(Object bean, Method method) { + super(bean, method); + } + + /** + * Construct a new handler method with the given bean instance, method name and parameters. + * @param bean the object bean + * @param methodName the method name + * @param parameterTypes the method parameter types + * @throws NoSuchMethodException when the method cannot be found + */ + public InvocableHandlerMethod(Object bean, String methodName, Class... parameterTypes) + throws NoSuchMethodException { + + super(bean, methodName, parameterTypes); + } + + /** + * Set {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} to use for resolving method argument values. + */ + public void setMessageMethodArgumentResolvers(HandlerMethodArgumentResolverComposite argumentResolvers) { + this.resolvers = argumentResolvers; + } + + /** + * Set the ParameterNameDiscoverer for resolving parameter names when needed + * (e.g. default request attribute name). + *

Default is a {@link DefaultParameterNameDiscoverer}. + */ + public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) { + this.parameterNameDiscoverer = parameterNameDiscoverer; + } + + /** + * Invoke the method after resolving its argument values in the context of the given message. + *

Argument values are commonly resolved through + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * The {@code providedArgs} parameter however may supply argument values to be used directly, + * i.e. without argument resolution. + *

Delegates to {@link #getMethodArgumentValues} and calls {@link #doInvoke} with the + * resolved arguments. + * @param message the current message being processed + * @param providedArgs "given" arguments matched by type, not resolved + * @return the raw value returned by the invoked method + * @throws Exception raised if no suitable argument resolver can be found, + * or if the method raised an exception + * @see #getMethodArgumentValues + * @see #doInvoke + */ + @Nullable + public Object invoke(Message message, Object... providedArgs) throws Exception { + Object[] args = getMethodArgumentValues(message, providedArgs); + if (logger.isTraceEnabled()) { + logger.trace("Arguments: " + Arrays.toString(args)); + } + // 注意:如下是本类的改动点!!! + // 情况一:无组织编号的情况 + Long organId= parseorganId(message); + if (organId == null) { + return doInvoke(args); + } + // 情况二:有组织的情况下 + return OrganUtils.execute(organId, () -> doInvoke(args)); + } + + private Long parseorganId(Message message) { + Object organId = message.getHeaders().get(HEADER_ORGAN_ID); + if (organId == null) { + return null; + } + if (organId instanceof Long) { + return (Long) organId; + } + if (organId instanceof Number) { + return ((Number) organId).longValue(); + } + if (organId instanceof String) { + return Long.parseLong((String) organId); + } + if (organId instanceof byte[]) { + return Long.parseLong(new String((byte[]) organId)); + } + throw new IllegalArgumentException("未知的数据类型:" + organId); + } + + /** + * Get the method argument values for the current message, checking the provided + * argument values and falling back to the configured argument resolvers. + *

The resulting array will be passed into {@link #doInvoke}. + * @since 5.1.2 + */ + protected Object[] getMethodArgumentValues(Message message, Object... providedArgs) throws Exception { + MethodParameter[] parameters = getMethodParameters(); + if (ObjectUtils.isEmpty(parameters)) { + return EMPTY_ARGS; + } + + Object[] args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + MethodParameter parameter = parameters[i]; + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + args[i] = findProvidedArgument(parameter, providedArgs); + if (args[i] != null) { + continue; + } + if (!this.resolvers.supportsParameter(parameter)) { + throw new MethodArgumentResolutionException( + message, parameter, formatArgumentError(parameter, "No suitable resolver")); + } + try { + args[i] = this.resolvers.resolveArgument(parameter, message); + } + catch (Exception ex) { + // Leave stack trace for later, exception may actually be resolved and handled... + if (logger.isDebugEnabled()) { + String exMsg = ex.getMessage(); + if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) { + logger.debug(formatArgumentError(parameter, exMsg)); + } + } + throw ex; + } + } + return args; + } + + /** + * Invoke the handler method with the given argument values. + */ + @Nullable + protected Object doInvoke(Object... args) throws Exception { + try { + return getBridgedMethod().invoke(getBean(), args); + } + catch (IllegalArgumentException ex) { + assertTargetBean(getBridgedMethod(), getBean(), args); + String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument"); + throw new IllegalStateException(formatInvokeError(text, args), ex); + } + catch (InvocationTargetException ex) { + // Unwrap for HandlerExceptionResolvers ... + Throwable targetException = ex.getTargetException(); + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; + } + else if (targetException instanceof Error) { + throw (Error) targetException; + } + else if (targetException instanceof Exception) { + throw (Exception) targetException; + } + else { + throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException); + } + } + } + + MethodParameter getAsyncReturnValueType(@Nullable Object returnValue) { + return new AsyncResultMethodParameter(returnValue); + } + + private class AsyncResultMethodParameter extends HandlerMethodParameter { + + @Nullable + private final Object returnValue; + + private final ResolvableType returnType; + + public AsyncResultMethodParameter(@Nullable Object returnValue) { + super(-1); + this.returnValue = returnValue; + this.returnType = ResolvableType.forType(super.getGenericParameterType()).getGeneric(); + } + + protected AsyncResultMethodParameter(AsyncResultMethodParameter original) { + super(original); + this.returnValue = original.returnValue; + this.returnType = original.returnType; + } + + @Override + public Class getParameterType() { + if (this.returnValue != null) { + return this.returnValue.getClass(); + } + if (!ResolvableType.NONE.equals(this.returnType)) { + return this.returnType.toClass(); + } + return super.getParameterType(); + } + + @Override + public Type getGenericParameterType() { + return this.returnType.getType(); + } + + @Override + public AsyncResultMethodParameter clone() { + return new AsyncResultMethodParameter(this); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/resources/META-INF/spring.factories b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..38b5f89ff --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + com.cf.imes.framework.organ.core.mq.kafka.OrganKafkaEnvironmentPostProcessor diff --git a/cf-framework/cf-spring-boot-starter-biz-organ/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..1d88236d4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-organ/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.organ.config.ChenfengOrganRpcAutoConfiguration +com.cf.imes.framework.organ.config.ChenfengOrganAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/pom.xml b/cf-framework/cf-spring-boot-starter-biz-pay/pom.xml new file mode 100644 index 000000000..35ecd5c3a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/pom.xml @@ -0,0 +1,76 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + + cf-spring-boot-starter-biz-pay + ${project.artifactId} + 支付拓展,接入国内多个支付渠道 + 1. 支付宝,基于官方 SDK 接入 + 2. 微信支付,基于 weixin-java-pay 接入 + + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.slf4j + slf4j-api + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + + + com.alipay.sdk + alipay-sdk-java + 4.35.79.ALL + + + org.bouncycastle + bcprov-jdk15on + + + + + com.github.binarywang + weixin-java-pay + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/config/ChenfengPayAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/config/ChenfengPayAutoConfiguration.java new file mode 100644 index 000000000..12859b3d1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/config/ChenfengPayAutoConfiguration.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.pay.config; + +import com.cf.imes.framework.pay.core.client.PayClientFactory; +import com.cf.imes.framework.pay.core.client.impl.PayClientFactoryImpl; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * 支付配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +public class ChenfengPayAutoConfiguration { + + @Bean + public PayClientFactory payClientFactory() { + return new PayClientFactoryImpl(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClient.java new file mode 100644 index 000000000..612a53838 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClient.java @@ -0,0 +1,89 @@ +package com.cf.imes.framework.pay.core.client; + +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; + +import java.util.Map; + +/** + * 支付客户端,用于对接各支付渠道的 SDK,实现发起支付、退款等功能 + * + * @author 晨丰科技 + */ +public interface PayClient { + + /** + * 获得渠道编号 + * + * @return 渠道编号 + */ + Long getId(); + + // ============ 支付相关 ========== + + /** + * 调用支付渠道,统一下单 + * + * @param reqDTO 下单信息 + * @return 支付订单信息 + */ + PayOrderRespDTO unifiedOrder(PayOrderUnifiedReqDTO reqDTO); + + /** + * 解析 order 回调数据 + * + * @param params HTTP 回调接口 content type 为 application/x-www-form-urlencoded 的所有参数 + * @param body HTTP 回调接口的 request body + * @return 支付订单信息 + */ + PayOrderRespDTO parseOrderNotify(Map params, String body); + + /** + * 获得支付订单信息 + * + * @param outTradeNo 外部订单号 + * @return 支付订单信息 + */ + PayOrderRespDTO getOrder(String outTradeNo); + + // ============ 退款相关 ========== + + /** + * 调用支付渠道,进行退款 + * + * @param reqDTO 统一退款请求信息 + * @return 退款信息 + */ + PayRefundRespDTO unifiedRefund(PayRefundUnifiedReqDTO reqDTO); + + /** + * 解析 refund 回调数据 + * + * @param params HTTP 回调接口 content type 为 application/x-www-form-urlencoded 的所有参数 + * @param body HTTP 回调接口的 request body + * @return 支付订单信息 + */ + PayRefundRespDTO parseRefundNotify(Map params, String body); + + /** + * 获得退款订单信息 + * + * @param outTradeNo 外部订单号 + * @param outRefundNo 外部退款号 + * @return 退款订单信息 + */ + PayRefundRespDTO getRefund(String outTradeNo, String outRefundNo); + + /** + * 调用渠道,进行转账 + * + * @param reqDTO 统一转账请求信息 + * @return 转账信息 + */ + PayTransferRespDTO unifiedTransfer(PayTransferUnifiedReqDTO reqDTO); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClientConfig.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClientConfig.java new file mode 100644 index 000000000..ac08c4e3c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClientConfig.java @@ -0,0 +1,26 @@ +package com.cf.imes.framework.pay.core.client; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import javax.validation.Validator; + +/** + * 支付客户端的配置,本质是支付渠道的配置 + * 每个不同的渠道,需要不同的配置,通过子类来定义 + * + * @author 晨丰科技 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +// @JsonTypeInfo 注解的作用,Jackson 多态 +// 1. 序列化到时数据库时,增加 @class 属性。 +// 2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型 +public interface PayClientConfig { + + /** + * 参数校验 + * + * @param validator 校验对象 + */ + void validate(Validator validator); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClientFactory.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClientFactory.java new file mode 100644 index 000000000..66ec60ca4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/PayClientFactory.java @@ -0,0 +1,38 @@ +package com.cf.imes.framework.pay.core.client; + +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; + +/** + * 支付客户端的工厂接口 + * + * @author 晨丰科技 + */ +public interface PayClientFactory { + + /** + * 获得支付客户端 + * + * @param channelId 渠道编号 + * @return 支付客户端 + */ + PayClient getPayClient(Long channelId); + + /** + * 创建支付客户端 + * + * @param channelId 渠道编号 + * @param channelCode 渠道编码 + * @param config 支付配置 + */ + void createOrUpdatePayClient(Long channelId, String channelCode, + Config config); + + /** + * 注册支付客户端 Class,用于模块中实现的 PayClient + * + * @param channel 支付渠道的编码的枚举 + * @param payClientClass 支付客户端 class + */ + void registerPayClientClass(PayChannelEnum channel, Class payClientClass); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/order/PayOrderRespDTO.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/order/PayOrderRespDTO.java new file mode 100644 index 000000000..ed5fa5c1e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/order/PayOrderRespDTO.java @@ -0,0 +1,141 @@ +package com.cf.imes.framework.pay.core.client.dto.order; + +import com.cf.imes.framework.pay.core.client.exception.PayException; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 渠道支付订单 Response DTO + * + * @author 晨丰科技 + */ +@Data +public class PayOrderRespDTO { + + /** + * 支付状态 + * + * 枚举:{@link PayOrderStatusRespEnum} + */ + private Integer status; + + /** + * 外部订单号 + * + * 对应 PayOrderExtensionDO 的 no 字段 + */ + private String outTradeNo; + + /** + * 支付渠道编号 + */ + private String channelOrderNo; + /** + * 支付渠道用户编号 + */ + private String channelUserId; + + /** + * 支付成功时间 + */ + private LocalDateTime successTime; + + /** + * 原始的同步/异步通知结果 + */ + private Object rawData; + + // ========== 主动发起支付时,会返回的字段 ========== + + /** + * 展示模式 + * + * 枚举 {@link PayOrderDisplayModeEnum} 类 + */ + private String displayMode; + /** + * 展示内容 + */ + private String displayContent; + + /** + * 调用渠道的错误码 + * + * 注意:这里返回的是业务异常,而是不系统异常。 + * 如果是系统异常,则会抛出 {@link PayException} + */ + private String channelErrorCode; + /** + * 调用渠道报错时,错误信息 + */ + private String channelErrorMsg; + + public PayOrderRespDTO() { + } + + /** + * 创建【WAITING】状态的订单返回 + */ + public static PayOrderRespDTO waitingOf(String displayMode, String displayContent, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = PayOrderStatusRespEnum.WAITING.getStatus(); + respDTO.displayMode = displayMode; + respDTO.displayContent = displayContent; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【SUCCESS】状态的订单返回 + */ + public static PayOrderRespDTO successOf(String channelOrderNo, String channelUserId, LocalDateTime successTime, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = PayOrderStatusRespEnum.SUCCESS.getStatus(); + respDTO.channelOrderNo = channelOrderNo; + respDTO.channelUserId = channelUserId; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建指定状态的订单返回,适合支付渠道回调时 + */ + public static PayOrderRespDTO of(Integer status, String channelOrderNo, String channelUserId, LocalDateTime successTime, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = status; + respDTO.channelOrderNo = channelOrderNo; + respDTO.channelUserId = channelUserId; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【CLOSED】状态的订单返回,适合调用支付渠道失败时 + */ + public static PayOrderRespDTO closedOf(String channelErrorCode, String channelErrorMsg, + String outTradeNo, Object rawData) { + PayOrderRespDTO respDTO = new PayOrderRespDTO(); + respDTO.status = PayOrderStatusRespEnum.CLOSED.getStatus(); + respDTO.channelErrorCode = channelErrorCode; + respDTO.channelErrorMsg = channelErrorMsg; + // 相对通用的字段 + respDTO.outTradeNo = outTradeNo; + respDTO.rawData = rawData; + return respDTO; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/order/PayOrderUnifiedReqDTO.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/order/PayOrderUnifiedReqDTO.java new file mode 100644 index 000000000..8b9791fdb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/order/PayOrderUnifiedReqDTO.java @@ -0,0 +1,92 @@ +package com.cf.imes.framework.pay.core.client.dto.order; + +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import lombok.Data; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 统一下单 Request DTO + * + * @author 晨丰科技 + */ +@Data +public class PayOrderUnifiedReqDTO { + + /** + * 用户 IP + */ + @NotEmpty(message = "用户 IP 不能为空") + private String userIp; + + // ========== 商户相关字段 ========== + + /** + * 外部订单号 + * + * 对应 PayOrderExtensionDO 的 no 字段 + */ + @NotEmpty(message = "外部订单编号不能为空") + private String outTradeNo; + /** + * 商品标题 + */ + @NotEmpty(message = "商品标题不能为空") + @Length(max = 32, message = "商品标题不能超过 32") + private String subject; + /** + * 商品描述信息 + */ + @Length(max = 128, message = "商品描述信息长度不能超过128") + private String body; + /** + * 支付结果的 notify 回调地址 + */ + @NotEmpty(message = "支付结果的回调地址不能为空") + @URL(message = "支付结果的 notify 回调地址必须是 URL 格式") + private String notifyUrl; + /** + * 支付结果的 return 回调地址 + */ + @URL(message = "支付结果的 return 回调地址必须是 URL 格式") + private String returnUrl; + + // ========== 订单相关字段 ========== + + /** + * 支付金额,单位:分 + */ + @NotNull(message = "支付金额不能为空") + @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") + private Integer price; + + /** + * 支付过期时间 + */ + @NotNull(message = "支付过期时间不能为空") + private LocalDateTime expireTime; + + // ========== 拓展参数 ========== + /** + * 支付渠道的额外参数 + * + * 例如说,微信公众号需要传递 openid 参数 + */ + private Map channelExtras; + + /** + * 展示模式 + * + * 如果不传递,则每个支付渠道使用默认的方式 + * + * 枚举 {@link PayOrderDisplayModeEnum} + */ + private String displayMode; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/refund/PayRefundRespDTO.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/refund/PayRefundRespDTO.java new file mode 100644 index 000000000..ee388eff2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/refund/PayRefundRespDTO.java @@ -0,0 +1,115 @@ +package com.cf.imes.framework.pay.core.client.dto.refund; + +import com.cf.imes.framework.pay.core.client.exception.PayException; +import com.cf.imes.framework.pay.core.enums.refund.PayRefundStatusRespEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 渠道退款订单 Response DTO + * + * @author jason + */ +@Data +public class PayRefundRespDTO { + + /** + * 退款状态 + * + * 枚举 {@link PayRefundStatusRespEnum} + */ + private Integer status; + + /** + * 外部退款号 + * + * 对应 PayRefundDO 的 no 字段 + */ + private String outRefundNo; + + /** + * 渠道退款单号 + * + * 对应 PayRefundDO.channelRefundNo 字段 + */ + private String channelRefundNo; + + /** + * 退款成功时间 + */ + private LocalDateTime successTime; + + /** + * 原始的异步通知结果 + */ + private Object rawData; + + /** + * 调用渠道的错误码 + * + * 注意:这里返回的是业务异常,而是不系统异常。 + * 如果是系统异常,则会抛出 {@link PayException} + */ + private String channelErrorCode; + /** + * 调用渠道报错时,错误信息 + */ + private String channelErrorMsg; + + private PayRefundRespDTO() { + } + + /** + * 创建【WAITING】状态的退款返回 + */ + public static PayRefundRespDTO waitingOf(String channelRefundNo, + String outRefundNo, Object rawData) { + PayRefundRespDTO respDTO = new PayRefundRespDTO(); + respDTO.status = PayRefundStatusRespEnum.WAITING.getStatus(); + respDTO.channelRefundNo = channelRefundNo; + // 相对通用的字段 + respDTO.outRefundNo = outRefundNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【SUCCESS】状态的退款返回 + */ + public static PayRefundRespDTO successOf(String channelRefundNo, LocalDateTime successTime, + String outRefundNo, Object rawData) { + PayRefundRespDTO respDTO = new PayRefundRespDTO(); + respDTO.status = PayRefundStatusRespEnum.SUCCESS.getStatus(); + respDTO.channelRefundNo = channelRefundNo; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outRefundNo = outRefundNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【FAILURE】状态的退款返回 + */ + public static PayRefundRespDTO failureOf(String outRefundNo, Object rawData) { + return failureOf(null, null, + outRefundNo, rawData); + } + + /** + * 创建【FAILURE】状态的退款返回 + */ + public static PayRefundRespDTO failureOf(String channelErrorCode, String channelErrorMsg, + String outRefundNo, Object rawData) { + PayRefundRespDTO respDTO = new PayRefundRespDTO(); + respDTO.status = PayRefundStatusRespEnum.FAILURE.getStatus(); + respDTO.channelErrorCode = channelErrorCode; + respDTO.channelErrorMsg = channelErrorMsg; + // 相对通用的字段 + respDTO.outRefundNo = outRefundNo; + respDTO.rawData = rawData; + return respDTO; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/refund/PayRefundUnifiedReqDTO.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/refund/PayRefundUnifiedReqDTO.java new file mode 100644 index 000000000..a5af9ab99 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/refund/PayRefundUnifiedReqDTO.java @@ -0,0 +1,70 @@ +package com.cf.imes.framework.pay.core.client.dto.refund; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 统一 退款 Request DTO + * + * @author jason + */ +@Accessors(chain = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Data +public class PayRefundUnifiedReqDTO { + + /** + * 外部订单号 + * + * 对应 PayOrderExtensionDO 的 no 字段 + */ + @NotEmpty(message = "外部订单编号不能为空") + private String outTradeNo; + + /** + * 外部退款号 + * + * 对应 PayRefundDO 的 no 字段 + */ + @NotEmpty(message = "退款请求单号不能为空") + private String outRefundNo; + + /** + * 退款原因 + */ + @NotEmpty(message = "退款原因不能为空") + private String reason; + + /** + * 支付金额,单位:分 + * + * 目前微信支付在退款的时候,必须传递该字段 + */ + @NotNull(message = "支付金额不能为空") + @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") + private Integer payPrice; + /** + * 退款金额,单位:分 + */ + @NotNull(message = "退款金额不能为空") + @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") + private Integer refundPrice; + + /** + * 退款结果的 notify 回调地址 + */ + @NotEmpty(message = "支付结果的回调地址不能为空") + @URL(message = "支付结果的 notify 回调地址必须是 URL 格式") + private String notifyUrl; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/transfer/PayTransferRespDTO.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/transfer/PayTransferRespDTO.java new file mode 100644 index 000000000..fbaec24de --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/transfer/PayTransferRespDTO.java @@ -0,0 +1,96 @@ +package com.cf.imes.framework.pay.core.client.dto.transfer; + +import com.cf.imes.framework.pay.core.enums.transfer.PayTransferStatusRespEnum; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 统一转账 Response DTO + * + * @author jason + */ +@Data +public class PayTransferRespDTO { + + /** + * 转账状态 + * + * 关联 {@link PayTransferStatusRespEnum#getStatus()} + */ + private Integer status; + + /** + * 外部转账单号 + * + */ + private String outTransferNo; + + /** + * 支付渠道编号 + */ + private String channelOrderNo; + + /** + * 支付成功时间 + */ + private LocalDateTime successTime; + + /** + * 原始的返回结果 + */ + private Object rawData; + + /** + * 调用渠道的错误码 + */ + private String channelErrorCode; + /** + * 调用渠道报错时,错误信息 + */ + private String channelErrorMsg; + + /** + * 创建【WAITING】状态的转账返回 + */ + public static PayTransferRespDTO waitingOf(String channelOrderNo, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.WAITING.getStatus(); + respDTO.channelOrderNo = channelOrderNo; + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【CLOSED】状态的转账返回 + */ + public static PayTransferRespDTO closedOf(String channelErrorCode, String channelErrorMsg, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.CLOSED.getStatus(); + respDTO.channelErrorCode = channelErrorCode; + respDTO.channelErrorMsg = channelErrorMsg; + // 相对通用的字段 + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + + /** + * 创建【SUCCESS】状态的转账返回 + */ + public static PayTransferRespDTO successOf(String channelTransferNo, LocalDateTime successTime, + String outTransferNo, Object rawData) { + PayTransferRespDTO respDTO = new PayTransferRespDTO(); + respDTO.status = PayTransferStatusRespEnum.SUCCESS.getStatus(); + respDTO.channelOrderNo = channelTransferNo; + respDTO.successTime = successTime; + // 相对通用的字段 + respDTO.outTransferNo = outTransferNo; + respDTO.rawData = rawData; + return respDTO; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/transfer/PayTransferUnifiedReqDTO.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/transfer/PayTransferUnifiedReqDTO.java new file mode 100644 index 000000000..83b35f0b4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/dto/transfer/PayTransferUnifiedReqDTO.java @@ -0,0 +1,66 @@ +package com.cf.imes.framework.pay.core.client.dto.transfer; + +import com.cf.imes.framework.common.validation.InEnum; +import com.cf.imes.framework.pay.core.enums.transfer.PayTransferTypeEnum; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Map; + +/** + * 统一转账 Request DTO + * + * @author jason + */ +@Data +public class PayTransferUnifiedReqDTO { + + /** + * 转账类型 + * + * 关联 {@link PayTransferTypeEnum#getType()} + */ + @NotNull(message = "转账类型不能为空") + @InEnum(PayTransferTypeEnum.class) + private Integer type; + + /** + * 用户 IP + */ + @NotEmpty(message = "用户 IP 不能为空") + private String userIp; + + @NotEmpty(message = "外部转账单编号不能为空") + private String outTransferNo; + + /** + * 转账金额,单位:分 + */ + @NotNull(message = "转账金额不能为空") + @Min(value = 1, message = "转账金额必须大于零") + private Integer price; + + /** + * 转账标题 + */ + @NotEmpty(message = "转账标题不能为空") + @Length(max = 128, message = "转账标题不能超过 128") + private String title; + + /** + * 收款方信息。 + * + * 转账类型 {@link #type} 不同,收款方信息不同 + */ + @NotEmpty(message = "收款方信息 不能为空") + private Map payeeInfo; + + /** + * 支付渠道的额外参数 + */ + private Map channelExtras; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/exception/PayException.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/exception/PayException.java new file mode 100644 index 000000000..94ad5d432 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/exception/PayException.java @@ -0,0 +1,17 @@ +package com.cf.imes.framework.pay.core.client.exception; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 支付系统异常 Exception + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PayException extends RuntimeException { + + public PayException(Throwable cause) { + super(cause); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/AbstractPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/AbstractPayClient.java new file mode 100644 index 000000000..698b33aeb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/AbstractPayClient.java @@ -0,0 +1,215 @@ +package com.cf.imes.framework.pay.core.client.impl; + +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.util.validation.ValidationUtils; +import com.cf.imes.framework.pay.core.client.PayClient; +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.exception.PayException; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +import static com.cf.imes.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 支付客户端的抽象类,提供模板方法,减少子类的冗余代码 + * + * @author 晨丰科技 + */ +@Slf4j +public abstract class AbstractPayClient implements PayClient { + + /** + * 渠道编号 + */ + private final Long channelId; + /** + * 渠道编码 + */ + @SuppressWarnings("FieldCanBeLocal") + private final String channelCode; + /** + * 支付配置 + */ + protected Config config; + + public AbstractPayClient(Long channelId, String channelCode, Config config) { + this.channelId = channelId; + this.channelCode = channelCode; + this.config = config; + } + + /** + * 初始化 + */ + public final void init() { + doInit(); + log.debug("[init][客户端({}) 初始化完成]", getId()); + } + + /** + * 自定义初始化 + */ + protected abstract void doInit(); + + public final void refresh(Config config) { + // 判断是否更新 + if (config.equals(this.config)) { + return; + } + log.info("[refresh][客户端({})发生变化,重新初始化]", getId()); + this.config = config; + // 初始化 + this.init(); + } + + @Override + public Long getId() { + return channelId; + } + + // ============ 支付相关 ========== + + @Override + public final PayOrderRespDTO unifiedOrder(PayOrderUnifiedReqDTO reqDTO) { + ValidationUtils.validate(reqDTO); + // 执行统一下单 + PayOrderRespDTO resp; + try { + resp = doUnifiedOrder(reqDTO); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + // 系统异常,则包装成 PayException 异常抛出 + log.error("[unifiedOrder][客户端({}) request({}) 发起支付异常]", + getId(), toJsonString(reqDTO), ex); + throw buildPayException(ex); + } + return resp; + } + + protected abstract PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) + throws Throwable; + + @Override + public final PayOrderRespDTO parseOrderNotify(Map params, String body) { + try { + return doParseOrderNotify(params, body); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[parseOrderNotify][客户端({}) params({}) body({}) 解析失败]", + getId(), params, body, ex); + throw buildPayException(ex); + } + } + + protected abstract PayOrderRespDTO doParseOrderNotify(Map params, String body) + throws Throwable; + + @Override + public final PayOrderRespDTO getOrder(String outTradeNo) { + try { + return doGetOrder(outTradeNo); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[getOrder][客户端({}) outTradeNo({}) 查询支付单异常]", + getId(), outTradeNo, ex); + throw buildPayException(ex); + } + } + + protected abstract PayOrderRespDTO doGetOrder(String outTradeNo) + throws Throwable; + + // ============ 退款相关 ========== + + @Override + public final PayRefundRespDTO unifiedRefund(PayRefundUnifiedReqDTO reqDTO) { + ValidationUtils.validate(reqDTO); + // 执行统一退款 + PayRefundRespDTO resp; + try { + resp = doUnifiedRefund(reqDTO); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + // 系统异常,则包装成 PayException 异常抛出 + log.error("[unifiedRefund][客户端({}) request({}) 发起退款异常]", + getId(), toJsonString(reqDTO), ex); + throw buildPayException(ex); + } + return resp; + } + + protected abstract PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws Throwable; + + @Override + public final PayRefundRespDTO parseRefundNotify(Map params, String body) { + try { + return doParseRefundNotify(params, body); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[parseRefundNotify][客户端({}) params({}) body({}) 解析失败]", + getId(), params, body, ex); + throw buildPayException(ex); + } + } + + protected abstract PayRefundRespDTO doParseRefundNotify(Map params, String body) + throws Throwable; + + @Override + public final PayRefundRespDTO getRefund(String outTradeNo, String outRefundNo) { + try { + return doGetRefund(outTradeNo, outRefundNo); + } catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + log.error("[getRefund][客户端({}) outTradeNo({}) outRefundNo({}) 查询退款单异常]", + getId(), outTradeNo, outRefundNo, ex); + throw buildPayException(ex); + } + } + + protected abstract PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) + throws Throwable; + + @Override + public final PayTransferRespDTO unifiedTransfer(PayTransferUnifiedReqDTO reqDTO) { + ValidationUtils.validate(reqDTO); + PayTransferRespDTO resp; + try{ + resp = doUnifiedTransfer(reqDTO); + }catch (ServiceException ex) { // 业务异常,都是实现类已经翻译,所以直接抛出即可 + throw ex; + } catch (Throwable ex) { + // 系统异常,则包装成 PayException 异常抛出 + log.error("[unifiedTransfer][客户端({}) request({}) 发起转账异常]", + getId(), toJsonString(reqDTO), ex); + throw buildPayException(ex); + } + return resp; + } + + protected abstract PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) + throws Throwable; + + // ========== 各种工具方法 ========== + + private PayException buildPayException(Throwable ex) { + if (ex instanceof PayException) { + return (PayException) ex; + } + throw new PayException(ex); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/NonePayClientConfig.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/NonePayClientConfig.java new file mode 100644 index 000000000..74a94ec7d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/NonePayClientConfig.java @@ -0,0 +1,31 @@ +package com.cf.imes.framework.pay.core.client.impl; + +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.Validator; + +/** + * 无需任何配置 PayClientConfig 实现类 + * + * @author jason + */ +@Data +public class NonePayClientConfig implements PayClientConfig { + + /** + * 配置名称 + *

+ * 如果不加任何属性,JsonUtils.parseObject2 解析会报错,所以暂时加个名称 + */ + private String name; + + public NonePayClientConfig(){ + this.name = "none-config"; + } + + @Override + public void validate(Validator validator) { + // 无任何配置不需要校验 + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/PayClientFactoryImpl.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/PayClientFactoryImpl.java new file mode 100644 index 000000000..771798d56 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/PayClientFactoryImpl.java @@ -0,0 +1,105 @@ +package com.cf.imes.framework.pay.core.client.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.pay.core.client.PayClient; +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import com.cf.imes.framework.pay.core.client.PayClientFactory; +import com.cf.imes.framework.pay.core.client.impl.alipay.*; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayAppPayClient; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayBarPayClient; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPcPayClient; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayQrPayClient; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayWapPayClient; +import com.cf.imes.framework.pay.core.client.impl.mock.MockPayClient; +import com.cf.imes.framework.pay.core.client.impl.weixin.*; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxAppPayClient; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxBarPayClient; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxLitePayClient; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxNativePayClient; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxPubPayClient; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum.*; + +/** + * 支付客户端的工厂实现类 + * + * @author 晨丰科技 + */ +@Slf4j +public class PayClientFactoryImpl implements PayClientFactory { + + /** + * 支付客户端 Map + * + * key:渠道编号 + */ + private final ConcurrentMap> clients = new ConcurrentHashMap<>(); + + /** + * 支付客户端 Class Map + */ + private final Map> clientClass = new ConcurrentHashMap<>(); + + public PayClientFactoryImpl() { + // 微信支付客户端 + clientClass.put(WX_PUB, WxPubPayClient.class); + clientClass.put(WX_LITE, WxLitePayClient.class); + clientClass.put(WX_APP, WxAppPayClient.class); + clientClass.put(WX_BAR, WxBarPayClient.class); + clientClass.put(WX_NATIVE, WxNativePayClient.class); + // 支付包支付客户端 + clientClass.put(ALIPAY_WAP, AlipayWapPayClient.class); + clientClass.put(ALIPAY_QR, AlipayQrPayClient.class); + clientClass.put(ALIPAY_APP, AlipayAppPayClient.class); + clientClass.put(ALIPAY_PC, AlipayPcPayClient.class); + clientClass.put(ALIPAY_BAR, AlipayBarPayClient.class); + // Mock 支付客户端 + clientClass.put(MOCK, MockPayClient.class); + } + + @Override + public void registerPayClientClass(PayChannelEnum channel, Class payClientClass) { + clientClass.put(channel, payClientClass); + } + + @Override + public PayClient getPayClient(Long channelId) { + AbstractPayClient client = clients.get(channelId); + if (client == null) { + log.error("[getPayClient][渠道编号({}) 找不到客户端]", channelId); + } + return client; + } + + @Override + @SuppressWarnings("unchecked") + public void createOrUpdatePayClient(Long channelId, String channelCode, + Config config) { + AbstractPayClient client = (AbstractPayClient) clients.get(channelId); + if (client == null) { + client = this.createPayClient(channelId, channelCode, config); + client.init(); + clients.put(client.getId(), client); + } else { + client.refresh(config); + } + } + + @SuppressWarnings("unchecked") + private AbstractPayClient createPayClient(Long channelId, String channelCode, + Config config) { + PayChannelEnum channelEnum = PayChannelEnum.getByCode(channelCode); + Assert.notNull(channelEnum, String.format("支付渠道(%s) 为空", channelCode)); + Class payClientClass = clientClass.get(channelEnum); + Assert.notNull(payClientClass, String.format("支付渠道(%s) Class 为空", channelCode)); + return (AbstractPayClient) ReflectUtil.newInstance(payClientClass, channelId, config); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java new file mode 100644 index 000000000..fd49fef79 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java @@ -0,0 +1,304 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.common.util.object.ObjectUtils; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.impl.AbstractPayClient; +import com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import com.cf.imes.framework.pay.core.enums.transfer.PayTransferTypeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.AlipayConfig; +import com.alipay.api.AlipayResponse; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.domain.*; +import com.alipay.api.internal.util.AlipaySignature; +import com.alipay.api.request.AlipayFundTransUniTransferRequest; +import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; +import com.alipay.api.request.AlipayTradeQueryRequest; +import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.response.AlipayFundTransUniTransferResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; +import com.alipay.api.response.AlipayTradeQueryResponse; +import com.alipay.api.response.AlipayTradeRefundResponse; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import static cn.hutool.core.date.DatePattern.NORM_DATETIME_FORMATTER; +import static com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static com.cf.imes.framework.common.exception.util.ServiceExceptionUtil.exception0; +import static com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_CERTIFICATE; + +/** + * 支付宝抽象类,实现支付宝统一的接口、以及部分实现(退款) + * + * @author jason + */ +@Slf4j +public abstract class AbstractAlipayPayClient extends AbstractPayClient { + + @Getter // 仅用于单测场景 + protected DefaultAlipayClient client; + + public AbstractAlipayPayClient(Long channelId, String channelCode, AlipayPayClientConfig config) { + super(channelId, channelCode, config); + } + + @Override + @SneakyThrows + protected void doInit() { + AlipayConfig alipayConfig = new AlipayConfig(); + BeanUtil.copyProperties(config, alipayConfig, false); + this.client = new DefaultAlipayClient(alipayConfig); + } + + // ============ 支付相关 ========== + + /** + * 构造支付关闭的 {@link PayOrderRespDTO} 对象 + * + * @return 支付关闭的 {@link PayOrderRespDTO} 对象 + */ + protected PayOrderRespDTO buildClosedPayOrderRespDTO(PayOrderUnifiedReqDTO reqDTO, AlipayResponse response) { + Assert.isFalse(response.isSuccess()); + return PayOrderRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + reqDTO.getOutTradeNo(), response); + } + + @Override + public PayOrderRespDTO doParseOrderNotify(Map params, String body) throws Throwable { + // 1. 校验回调数据 + Map bodyObj = HttpUtil.decodeParamMap(body, StandardCharsets.UTF_8); + AlipaySignature.rsaCheckV1(bodyObj, config.getAlipayPublicKey(), + StandardCharsets.UTF_8.name(), config.getSignType()); + + // 2. 解析订单的状态 + // 额外说明:支付宝不仅仅支付成功会回调,再各种触发支付单数据变化时,都会进行回调,所以这里 status 的解析会写的比较复杂 + Integer status = parseStatus(bodyObj.get("trade_status")); + // 特殊逻辑: 支付宝没有退款成功的状态,所以,如果有退款金额,我们认为是退款成功 + if (MapUtil.getDouble(bodyObj, "refund_fee", 0D) > 0) { + status = PayOrderStatusRespEnum.REFUND.getStatus(); + } + Assert.notNull(status, (Supplier) () -> { + throw new IllegalArgumentException(StrUtil.format("body({}) 的 trade_status 不正确", body)); + }); + return PayOrderRespDTO.of(status, bodyObj.get("trade_no"), bodyObj.get("seller_id"), parseTime(params.get("gmt_payment")), + bodyObj.get("out_trade_no"), body); + } + + @Override + protected PayOrderRespDTO doGetOrder(String outTradeNo) throws Throwable { + // 1.1 构建 AlipayTradeRefundModel 请求 + AlipayTradeQueryModel model = new AlipayTradeQueryModel(); + model.setOutTradeNo(outTradeNo); + // 1.2 构建 AlipayTradeQueryRequest 请求 + AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); + request.setBizModel(model); + AlipayTradeQueryResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + if (!response.isSuccess()) { // 不成功,例如说订单不存在 + return PayOrderRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + outTradeNo, response); + } + // 2.2 解析订单的状态 + Integer status = parseStatus(response.getTradeStatus()); + Assert.notNull(status, () -> { + throw new IllegalArgumentException(StrUtil.format("body({}) 的 trade_status 不正确", response.getBody())); + }); + return PayOrderRespDTO.of(status, response.getTradeNo(), response.getBuyerUserId(), LocalDateTimeUtil.of(response.getSendPayDate()), + outTradeNo, response); + } + + private static Integer parseStatus(String tradeStatus) { + return Objects.equals("WAIT_BUYER_PAY", tradeStatus) ? PayOrderStatusRespEnum.WAITING.getStatus() + : ObjectUtils.equalsAny(tradeStatus, "TRADE_FINISHED", "TRADE_SUCCESS") ? PayOrderStatusRespEnum.SUCCESS.getStatus() + : Objects.equals("TRADE_CLOSED", tradeStatus) ? PayOrderStatusRespEnum.CLOSED.getStatus() : null; + } + + // ============ 退款相关 ========== + + /** + * 支付宝统一的退款接口 alipay.trade.refund + * + * @param reqDTO 退款请求 request DTO + * @return 退款请求 Response + */ + @Override + protected PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradeRefundModel 请求 + AlipayTradeRefundModel model = new AlipayTradeRefundModel(); + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setOutRequestNo(reqDTO.getOutRefundNo()); + model.setRefundAmount(formatAmount(reqDTO.getRefundPrice())); + model.setRefundReason(reqDTO.getReason()); + // 1.2 构建 AlipayTradePayRequest 请求 + AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); + request.setBizModel(model); + + // 2.1 执行请求 + AlipayTradeRefundResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + if (!response.isSuccess()) { + // 当出现 ACQ.SYSTEM_ERROR, 退款可能成功也可能失败。 返回 WAIT 状态. 后续 job 会轮询 + if (ObjectUtils.equalsAny(response.getSubCode(), "ACQ.SYSTEM_ERROR", "SYSTEM_ERROR")) { + return PayRefundRespDTO.waitingOf(null, reqDTO.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(response.getSubCode(), response.getSubMsg(), reqDTO.getOutRefundNo(), response); + } + // 2.2 创建返回结果 + // 支付宝只要退款调用返回 success,就认为退款成功,不需要回调。具体可见 parseNotify 方法的说明。 + // 另外,支付宝没有退款单号,所以不用设置 + return PayRefundRespDTO.successOf(null, LocalDateTimeUtil.of(response.getGmtRefundPay()), + reqDTO.getOutRefundNo(), response); + } + + @Override + public PayRefundRespDTO doParseRefundNotify(Map params, String body) { + // 补充说明:支付宝退款时,没有回调,这点和微信支付是不同的。并且,退款分成部分退款、和全部退款。 + // ① 部分退款:是会有回调,但是它回调的是订单状态的同步回调,不是退款订单的回调 + // ② 全部退款:Wap 支付有订单状态的同步回调,但是 PC/扫码又没有 + // 所以,这里在解析时,即使是退款导致的订单状态同步,我们也忽略不做为“退款同步”,而是订单的回调。 + // 实际上,支付宝退款只要发起成功,就可以认为退款成功,不需要等待回调。 + throw new UnsupportedOperationException("支付宝无退款回调"); + } + + @Override + protected PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) throws AlipayApiException { + // 1.1 构建 AlipayTradeFastpayRefundQueryModel 请求 + AlipayTradeFastpayRefundQueryModel model = new AlipayTradeFastpayRefundQueryModel(); + model.setOutTradeNo(outTradeNo); + model.setOutRequestNo(outRefundNo); + model.setQueryOptions(Collections.singletonList("gmt_refund_pay")); + // 1.2 构建 AlipayTradeFastpayRefundQueryRequest 请求 + AlipayTradeFastpayRefundQueryRequest request = new AlipayTradeFastpayRefundQueryRequest(); + request.setBizModel(model); + + // 2.1 执行请求 + AlipayTradeFastpayRefundQueryResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + if (!response.isSuccess()) { + // 明确不存在的情况,应该就是失败,可进行关闭 + if (ObjectUtils.equalsAny(response.getSubCode(), "TRADE_NOT_EXIST", "ACQ.TRADE_NOT_EXIST")) { + return PayRefundRespDTO.failureOf(outRefundNo, response); + } + // 可能存在“ACQ.SYSTEM_ERROR”系统错误等情况,所以返回 WAIT 继续等待 + return PayRefundRespDTO.waitingOf(null, outRefundNo, response); + } + // 2.2 创建返回结果 + if (Objects.equals(response.getRefundStatus(), "REFUND_SUCCESS")) { + return PayRefundRespDTO.successOf(null, LocalDateTimeUtil.of(response.getGmtRefundPay()), + outRefundNo, response); + } + return PayRefundRespDTO.waitingOf(null, outRefundNo, response); + } + + @Override + protected PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 校验公钥类型 必须使用公钥证书模式 + if (!Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + throw new IllegalStateException("支付宝单笔转账必须使用公钥证书模式"); + } + + // 1.2 构建 AlipayFundTransUniTransferModel + AlipayFundTransUniTransferModel model = new AlipayFundTransUniTransferModel(); + // ① 通用的参数 + model.setTransAmount(formatAmount(reqDTO.getPrice())); // 转账金额 + model.setOrderTitle(reqDTO.getTitle()); // 转账业务的标题,用于在支付宝用户的账单里显示。 + model.setOutBizNo(reqDTO.getOutTransferNo()); + model.setProductCode("TRANS_ACCOUNT_NO_PWD"); // 销售产品码。单笔无密转账固定为 TRANS_ACCOUNT_NO_PWD + model.setBizScene("DIRECT_TRANSFER"); // 业务场景 单笔无密转账固定为 DIRECT_TRANSFER + model.setBusinessParams(JsonUtils.toJsonString(reqDTO.getChannelExtras())); + PayTransferTypeEnum transferType = PayTransferTypeEnum.typeOf(reqDTO.getType()); + switch (transferType) { + // TODO @jason:是不是不用传递 transferType 参数哈?因为应该已经明确是支付宝啦? + // @晨丰。 是不是还要考虑转账到银行卡。所以传 transferType 但是转账到银行卡不知道要如何测试?? + case ALIPAY_BALANCE: { + // ② 个性化的参数 + Participant payeeInfo = new Participant(); + payeeInfo.setIdentityType("ALIPAY_LOGON_ID"); + String logonId = MapUtil.getStr(reqDTO.getPayeeInfo(), "ALIPAY_LOGON_ID"); + if (StrUtil.isEmpty(logonId)) { + throw exception0(BAD_REQUEST.getCode(), "支付包登录 ID 不能为空"); + } + String accountName = MapUtil.getStr(reqDTO.getPayeeInfo(), "ALIPAY_ACCOUNT_NAME"); + if (StrUtil.isEmpty(accountName)) { + throw exception0(BAD_REQUEST.getCode(), "支付包账户名称不能为空"); + } + payeeInfo.setIdentity(logonId); // 支付宝登录号 + payeeInfo.setName(accountName); // 支付宝账号姓名 + model.setPayeeInfo(payeeInfo); + // 1.3 构建 AlipayFundTransUniTransferRequest + AlipayFundTransUniTransferRequest request = new AlipayFundTransUniTransferRequest(); + request.setBizModel(model); + // 执行请求 + AlipayFundTransUniTransferResponse response = client.certificateExecute(request); + // 处理结果 + if (!response.isSuccess()) { + // 当出现 SYSTEM_ERROR, 转账可能成功也可能失败。 返回 WAIT 状态. 后续 job 会轮询 + if (ObjectUtils.equalsAny(response.getSubCode(), "SYSTEM_ERROR", "ACQ.SYSTEM_ERROR")) { + return PayTransferRespDTO.waitingOf(null, reqDTO.getOutTransferNo(), response); + } + return PayTransferRespDTO.closedOf(response.getSubCode(), response.getSubMsg(), + reqDTO.getOutTransferNo(), response); + } + return PayTransferRespDTO.successOf(response.getOrderId(), parseTime(response.getTransDate()), + response.getOutBizNo(), response); + } + case BANK_CARD: { + Participant payeeInfo = new Participant(); + payeeInfo.setIdentityType("BANKCARD_ACCOUNT"); + // TODO 待实现 + throw new UnsupportedOperationException("待实现"); + } + default: { + throw new IllegalStateException("不正确的转账类型: " + transferType); + } + } + } + + // ========== 各种工具方法 ========== + + protected String formatAmount(Integer amount) { + return String.valueOf(amount / 100.0); + } + + protected String formatTime(LocalDateTime time) { + return LocalDateTimeUtil.format(time, NORM_DATETIME_FORMATTER); + } + + protected LocalDateTime parseTime(String str) { + return LocalDateTimeUtil.parse(str, NORM_DATETIME_FORMATTER); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayAppPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayAppPayClient.java new file mode 100644 index 000000000..7ba4f12bb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayAppPayClient.java @@ -0,0 +1,59 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradeAppPayModel; +import com.alipay.api.request.AlipayTradeAppPayRequest; +import com.alipay.api.response.AlipayTradeAppPayResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 支付宝【App 支付】的 PayClient 实现类 + * + * 文档:App 支付 + * + * // TODO 晨丰:未详细测试,因为手头没 App + * + * @author 晨丰科技 + */ +@Slf4j +public class AlipayAppPayClient extends AbstractAlipayPayClient { + + public AlipayAppPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_APP.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradeAppPayModel 请求 + AlipayTradeAppPayModel model = new AlipayTradeAppPayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody() + "test"); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setTimeExpire(formatTime(reqDTO.getExpireTime())); + model.setProductCode("QUICK_MSECURITY_PAY"); // 销售产品码:无线快捷支付产品 + // ② 个性化的参数【无】 + // ③ 支付宝扫码支付只有一种展示 + String displayMode = PayOrderDisplayModeEnum.APP.getMode(); + + // 1.2 构建 AlipayTradePrecreateRequest 请求 + AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradeAppPayResponse response = client.sdkExecute(request); + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getBody(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayBarPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayBarPayClient.java new file mode 100644 index 000000000..b7d3127b4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayBarPayClient.java @@ -0,0 +1,85 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePayModel; +import com.alipay.api.request.AlipayTradePayRequest; +import com.alipay.api.response.AlipayTradePayResponse; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.Objects; + +import static com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static com.cf.imes.framework.common.exception.util.ServiceExceptionUtil.exception0; +import static com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_CERTIFICATE; + +/** + * 支付宝【条码支付】的 PayClient 实现类 + * + * 文档:当面付 + * + * @author 晨丰科技 + */ +@Slf4j +public class AlipayBarPayClient extends AbstractAlipayPayClient { + + public AlipayBarPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_BAR.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + String authCode = MapUtil.getStr(reqDTO.getChannelExtras(), "auth_code"); + if (StrUtil.isEmpty(authCode)) { + throw exception0(BAD_REQUEST.getCode(), "条形码不能为空"); + } + + // 1.1 构建 AlipayTradePayModel 请求 + AlipayTradePayModel model = new AlipayTradePayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setScene("bar_code"); // 当面付条码支付场景 + // ② 个性化的参数 + model.setAuthCode(authCode); + // ③ 支付宝条码支付只有一种展示 + String displayMode = PayOrderDisplayModeEnum.BAR_CODE.getMode(); + + // 1.2 构建 AlipayTradePayRequest 请求 + AlipayTradePayRequest request = new AlipayTradePayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradePayResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + if ("10000".equals(response.getCode())) { // 免密支付 + LocalDateTime successTime = LocalDateTimeUtil.of(response.getGmtPayment()); + return PayOrderRespDTO.successOf(response.getTradeNo(), response.getBuyerUserId(), successTime, + response.getOutTradeNo(), response) + .setDisplayMode(displayMode).setDisplayContent(""); + } + // 大额支付,需要用户输入密码,所以返回 waiting。此时,前端一般会进行轮询 + return PayOrderRespDTO.waitingOf(displayMode, "", + reqDTO.getOutTradeNo(), response); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPayClientConfig.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPayClientConfig.java new file mode 100644 index 000000000..3b1c4280a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPayClientConfig.java @@ -0,0 +1,107 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import com.cf.imes.framework.common.util.validation.ValidationUtils; +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.Validator; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * 支付宝的 PayClientConfig 实现类 + * 属性主要来自 {@link com.alipay.api.AlipayConfig} 的必要属性 + * + * @author 晨丰科技 + */ +@Data +public class AlipayPayClientConfig implements PayClientConfig { + + /** + * 公钥类型 - 公钥模式 + */ + public static final Integer MODE_PUBLIC_KEY = 1; + /** + * 公钥类型 - 证书模式 + */ + public static final Integer MODE_CERTIFICATE = 2; + + /** + * 签名算法类型 - RSA + */ + public static final String SIGN_TYPE_DEFAULT = "RSA2"; + + /** + * 网关地址 + * + * 1. 生产环境 + * 2. 沙箱环境 + */ + @NotBlank(message = "网关地址不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private String serverUrl; + + /** + * 开放平台上创建的应用的 ID + */ + @NotBlank(message = "开放平台上创建的应用的 ID不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private String appId; + + /** + * 签名算法类型,推荐:RSA2 + *

+ * {@link #SIGN_TYPE_DEFAULT} + */ + @NotBlank(message = "签名算法类型不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private String signType; + + /** + * 公钥类型 + * 1. {@link #MODE_PUBLIC_KEY} 情况,privateKey + alipayPublicKey + * 2. {@link #MODE_CERTIFICATE} 情况,appCertContent + alipayPublicCertContent + rootCertContent + */ + @NotNull(message = "公钥类型不能为空", groups = {ModePublicKey.class, ModeCertificate.class}) + private Integer mode; + + // ========== 公钥模式 ========== + /** + * 商户私钥 + */ + @NotBlank(message = "商户私钥不能为空", groups = {ModePublicKey.class}) + private String privateKey; + + /** + * 支付宝公钥字符串 + */ + @NotBlank(message = "支付宝公钥字符串不能为空", groups = {ModePublicKey.class}) + private String alipayPublicKey; + + // ========== 证书模式 ========== + /** + * 指定商户公钥应用证书内容字符串 + */ + @NotBlank(message = "指定商户公钥应用证书内容不能为空", groups = {ModeCertificate.class}) + private String appCertContent; + /** + * 指定支付宝公钥证书内容字符串 + */ + @NotBlank(message = "指定支付宝公钥证书内容不能为空", groups = {ModeCertificate.class}) + private String alipayPublicCertContent; + /** + * 指定根证书内容字符串 + */ + @NotBlank(message = "指定根证书内容字符串不能为空", groups = {ModeCertificate.class}) + private String rootCertContent; + + public interface ModePublicKey { + } + + public interface ModeCertificate { + } + + @Override + public void validate(Validator validator) { + ValidationUtils.validate(validator, this, + MODE_PUBLIC_KEY.equals(this.getMode()) ? ModePublicKey.class : ModeCertificate.class); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPcPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPcPayClient.java new file mode 100644 index 000000000..0669c516d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPcPayClient.java @@ -0,0 +1,69 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.http.Method; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePagePayModel; +import com.alipay.api.request.AlipayTradePagePayRequest; +import com.alipay.api.response.AlipayTradePagePayResponse; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +/** + * 支付宝【PC 网站】的 PayClient 实现类 + * + * 文档:电脑网站支付 + * + * @author XGD + */ +@Slf4j +public class AlipayPcPayClient extends AbstractAlipayPayClient { + + public AlipayPcPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_PC.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradePagePayModel 请求 + AlipayTradePagePayModel model = new AlipayTradePagePayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setTimeExpire(formatTime(reqDTO.getExpireTime())); + model.setProductCode("FAST_INSTANT_TRADE_PAY"); // 销售产品码. 目前 PC 支付场景下仅支持 FAST_INSTANT_TRADE_PAY + // ② 个性化的参数 + // 如果想弄更多个性化的参数,可参考 https://www.pingxx.com/api/支付渠道 extra 参数说明.html 的 alipay_pc_direct 部分进行拓展 + model.setQrPayMode("2"); // 跳转模式 - 订单码,效果参见:https://help.pingxx.com/article/1137360/ + // ③ 支付宝 PC 支付有两种展示模式:FORM、URL + String displayMode = ObjectUtil.defaultIfNull(reqDTO.getDisplayMode(), + PayOrderDisplayModeEnum.URL.getMode()); + + // 1.2 构建 AlipayTradePagePayRequest 请求 + AlipayTradePagePayRequest request = new AlipayTradePagePayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradePagePayResponse response; + if (Objects.equals(displayMode, PayOrderDisplayModeEnum.FORM.getMode())) { + response = client.pageExecute(request, Method.POST.name()); // 需要特殊使用 POST 请求 + } else { + response = client.pageExecute(request, Method.GET.name()); + } + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getBody(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayQrPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayQrPayClient.java new file mode 100644 index 000000000..9a61af2b1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayQrPayClient.java @@ -0,0 +1,66 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePrecreateModel; +import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.response.AlipayTradePrecreateResponse; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +import static com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_CERTIFICATE; + +/** + * 支付宝【扫码支付】的 PayClient 实现类 + * + * 文档:扫码支付 + * + * @author 晨丰科技 + */ +@Slf4j +public class AlipayQrPayClient extends AbstractAlipayPayClient { + + public AlipayQrPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_QR.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradePrecreateModel 请求 + AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setProductCode("FACE_TO_FACE_PAYMENT"); // 销售产品码. 目前扫码支付场景下仅支持 FACE_TO_FACE_PAYMENT + // ② 个性化的参数【无】 + // ③ 支付宝扫码支付只有一种展示,考虑到前端可能希望二维码扫描后,手机打开 + String displayMode = PayOrderDisplayModeEnum.QR_CODE.getMode(); + + // 1.2 构建 AlipayTradePrecreateRequest 请求 + AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradePrecreateResponse response; + if (Objects.equals(config.getMode(), MODE_CERTIFICATE)) { + // 证书模式 + response = client.certificateExecute(request); + } else { + response = client.execute(request); + } + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getQrCode(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayWapPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayWapPayClient.java new file mode 100644 index 000000000..8ccc5316e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayWapPayClient.java @@ -0,0 +1,58 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.http.Method; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradeWapPayModel; +import com.alipay.api.request.AlipayTradeWapPayRequest; +import com.alipay.api.response.AlipayTradeWapPayResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 支付宝【Wap 网站】的 PayClient 实现类 + * + * 文档:手机网站支付接口 + * + * @author 晨丰科技 + */ +@Slf4j +public class AlipayWapPayClient extends AbstractAlipayPayClient { + + public AlipayWapPayClient(Long channelId, AlipayPayClientConfig config) { + super(channelId, PayChannelEnum.ALIPAY_WAP.getCode(), config); + } + + @Override + public PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws AlipayApiException { + // 1.1 构建 AlipayTradeWapPayModel 请求 + AlipayTradeWapPayModel model = new AlipayTradeWapPayModel(); + // ① 通用的参数 + model.setOutTradeNo(reqDTO.getOutTradeNo()); + model.setSubject(reqDTO.getSubject()); + model.setBody(reqDTO.getBody()); + model.setTotalAmount(formatAmount(reqDTO.getPrice())); + model.setProductCode("QUICK_WAP_PAY"); // 销售产品码. 目前 Wap 支付场景下仅支持 QUICK_WAP_PAY + // ② 个性化的参数【无】 + // ③ 支付宝 Wap 支付只有一种展示:URL + String displayMode = PayOrderDisplayModeEnum.URL.getMode(); + + // 1.2 构建 AlipayTradeWapPayRequest 请求 + AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest(); + request.setBizModel(model); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + request.setReturnUrl(reqDTO.getReturnUrl()); + model.setQuitUrl(reqDTO.getReturnUrl()); + + // 2.1 执行请求 + AlipayTradeWapPayResponse response = client.pageExecute(request, Method.GET.name()); + // 2.2 处理结果 + if (!response.isSuccess()) { + return buildClosedPayOrderRespDTO(reqDTO, response); + } + return PayOrderRespDTO.waitingOf(displayMode, response.getBody(), + reqDTO.getOutTradeNo(), response); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/mock/MockPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/mock/MockPayClient.java new file mode 100644 index 000000000..c7ec4dfbe --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/mock/MockPayClient.java @@ -0,0 +1,74 @@ +package com.cf.imes.framework.pay.core.client.impl.mock; + +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.impl.AbstractPayClient; +import com.cf.imes.framework.pay.core.client.impl.NonePayClientConfig; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 模拟支付的 PayClient 实现类 + * + * 模拟支付返回结果都是成功,方便大家日常流畅 + * + * @author jason + */ +public class MockPayClient extends AbstractPayClient { + + private static final String MOCK_RESP_SUCCESS_DATA = "MOCK_SUCCESS"; + + public MockPayClient(Long channelId, NonePayClientConfig config) { + super(channelId, PayChannelEnum.MOCK.getCode(), config); + } + + @Override + protected void doInit() { + } + + @Override + protected PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) { + return PayOrderRespDTO.successOf("MOCK-P-" + reqDTO.getOutTradeNo(), "", LocalDateTime.now(), + reqDTO.getOutTradeNo(), MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayOrderRespDTO doGetOrder(String outTradeNo) { + return PayOrderRespDTO.successOf("MOCK-P-" + outTradeNo, "", LocalDateTime.now(), + outTradeNo, MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) { + return PayRefundRespDTO.successOf("MOCK-R-" + reqDTO.getOutRefundNo(), LocalDateTime.now(), + reqDTO.getOutRefundNo(), MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) { + return PayRefundRespDTO.successOf("MOCK-R-" + outRefundNo, LocalDateTime.now(), + outRefundNo, MOCK_RESP_SUCCESS_DATA); + } + + @Override + protected PayRefundRespDTO doParseRefundNotify(Map params, String body) { + throw new UnsupportedOperationException("模拟支付无退款回调"); + } + + @Override + protected PayOrderRespDTO doParseOrderNotify(Map params, String body) { + throw new UnsupportedOperationException("模拟支付无支付回调"); + } + + @Override + protected PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) { + throw new UnsupportedOperationException("待实现"); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/mock/MockPayClientConfig.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/mock/MockPayClientConfig.java new file mode 100644 index 000000000..05a0cb9ba --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/mock/MockPayClientConfig.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.pay.core.client.impl.mock; + +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.Validator; + +/** + * 模拟支付的 PayClientConfig 实现类 + * + * @author jason + */ +@Data +public class MockPayClientConfig implements PayClientConfig { + + /** + * 配置名称 + * + * 如果不加任何属性,JsonUtils.parseObject2 解析会报错,所以暂时加个名称 + */ + private String name; + + @Override + public void validate(Validator validator) { + // 模拟支付配置无需校验 + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java new file mode 100644 index 000000000..170bacc48 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java @@ -0,0 +1,475 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.codec.Base64; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.date.TemporalAccessorUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.io.FileUtils; +import com.cf.imes.framework.common.util.object.ObjectUtils; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import com.cf.imes.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.impl.AbstractPayClient; +import com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import com.github.binarywang.wxpay.bean.notify.WxPayNotifyV3Result; +import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult; +import com.github.binarywang.wxpay.bean.notify.WxPayRefundNotifyResult; +import com.github.binarywang.wxpay.bean.notify.WxPayRefundNotifyV3Result; +import com.github.binarywang.wxpay.bean.request.*; +import com.github.binarywang.wxpay.bean.result.*; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import java.util.Objects; + +import static cn.hutool.core.date.DatePattern.*; + +/** + * 微信支付抽象类,实现微信统一的接口、以及部分实现(退款) + * + * @author 遇到源码 + */ +@Slf4j +public abstract class AbstractWxPayClient extends AbstractPayClient { + + protected WxPayService client; + + public AbstractWxPayClient(Long channelId, String channelCode, WxPayClientConfig config) { + super(channelId, channelCode, config); + } + + /** + * 初始化 client 客户端 + * + * @param tradeType 交易类型 + */ + protected void doInit(String tradeType) { + // 创建 config 配置 + WxPayConfig payConfig = new WxPayConfig(); + BeanUtil.copyProperties(config, payConfig, "keyContent", "privateKeyContent", "privateCertContent"); + payConfig.setTradeType(tradeType); + // weixin-pay-java 无法设置内容,只允许读取文件,所以这里要创建临时文件来解决 + if (Base64.isBase64(config.getKeyContent())) { + payConfig.setKeyPath(FileUtils.createTempFile(Base64.decode(config.getKeyContent())).getPath()); + } + if (StrUtil.isNotEmpty(config.getPrivateKeyContent())) { + payConfig.setPrivateKeyPath(FileUtils.createTempFile(config.getPrivateKeyContent()).getPath()); + } + if (StrUtil.isNotEmpty(config.getPrivateCertContent())) { + payConfig.setPrivateCertPath(FileUtils.createTempFile(config.getPrivateCertContent()).getPath()); + } + + // 创建 client 客户端 + client = new WxPayServiceImpl(); + client.setConfig(payConfig); + } + + // ============ 支付相关 ========== + + @Override + protected PayOrderRespDTO doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws Exception { + try { + switch (config.getApiVersion()) { + case WxPayClientConfig.API_VERSION_V2: + return doUnifiedOrderV2(reqDTO); + case WxPayClientConfig.API_VERSION_V3: + return doUnifiedOrderV3(reqDTO); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayOrderRespDTO.closedOf(errorCode, errorMessage, + reqDTO.getOutTradeNo(), e.getXmlString()); + } + } + + /** + * 【V2】调用支付渠道,统一下单 + * + * @param reqDTO 下单信息 + * @return 各支付渠道的返回结果 + */ + protected abstract PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) + throws Exception; + + /** + * 【V3】调用支付渠道,统一下单 + * + * @param reqDTO 下单信息 + * @return 各支付渠道的返回结果 + */ + protected abstract PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) + throws WxPayException; + + /** + * 【V2】创建微信下单请求 + * + * @param reqDTO 下信息 + * @return 下单请求 + */ + protected WxPayUnifiedOrderRequest buildPayUnifiedOrderRequestV2(PayOrderUnifiedReqDTO reqDTO) { + return WxPayUnifiedOrderRequest.newBuilder() + .outTradeNo(reqDTO.getOutTradeNo()) + .body(reqDTO.getSubject()) + .detail(reqDTO.getBody()) + .totalFee(reqDTO.getPrice()) // 单位分 + .timeExpire(formatDateV2(reqDTO.getExpireTime())) + .spbillCreateIp(reqDTO.getUserIp()) + .notifyUrl(reqDTO.getNotifyUrl()) + .build(); + } + + /** + * 【V3】创建微信下单请求 + * + * @param reqDTO 下信息 + * @return 下单请求 + */ + protected WxPayUnifiedOrderV3Request buildPayUnifiedOrderRequestV3(PayOrderUnifiedReqDTO reqDTO) { + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request(); + request.setOutTradeNo(reqDTO.getOutTradeNo()); + request.setDescription(reqDTO.getSubject()); + request.setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(reqDTO.getPrice())); // 单位分 + request.setTimeExpire(formatDateV3(reqDTO.getExpireTime())); + request.setSceneInfo(new WxPayUnifiedOrderV3Request.SceneInfo().setPayerClientIp(reqDTO.getUserIp())); + request.setNotifyUrl(reqDTO.getNotifyUrl()); + return request; + } + + @Override + public PayOrderRespDTO doParseOrderNotify(Map params, String body) throws WxPayException { + switch (config.getApiVersion()) { + case WxPayClientConfig.API_VERSION_V2: + return doParseOrderNotifyV2(body); + case WxPayClientConfig.API_VERSION_V3: + return doParseOrderNotifyV3(body); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } + + private PayOrderRespDTO doParseOrderNotifyV2(String body) throws WxPayException { + // 1. 解析回调 + WxPayOrderNotifyResult response = client.parseOrderNotifyResult(body); + // 2. 构建结果 + // V2 微信支付的回调,只有 SUCCESS 支付成功、CLOSED 支付失败两种情况,无需像支付宝一样解析的比较复杂 + Integer status = Objects.equals(response.getResultCode(), "SUCCESS") ? + PayOrderStatusRespEnum.SUCCESS.getStatus() : PayOrderStatusRespEnum.CLOSED.getStatus(); + return PayOrderRespDTO.of(status, response.getTransactionId(), response.getOpenid(), parseDateV2(response.getTimeEnd()), + response.getOutTradeNo(), body); + } + + private PayOrderRespDTO doParseOrderNotifyV3(String body) throws WxPayException { + // 1. 解析回调 + WxPayNotifyV3Result response = client.parseOrderNotifyV3Result(body, null); + WxPayNotifyV3Result.DecryptNotifyResult result = response.getResult(); + // 2. 构建结果 + Integer status = parseStatus(result.getTradeState()); + String openid = result.getPayer() != null ? result.getPayer().getOpenid() : null; + return PayOrderRespDTO.of(status, result.getTransactionId(), openid, parseDateV3(result.getSuccessTime()), + result.getOutTradeNo(), body); + } + + @Override + protected PayOrderRespDTO doGetOrder(String outTradeNo) throws Throwable { + try { + switch (config.getApiVersion()) { + case WxPayClientConfig.API_VERSION_V2: + return doGetOrderV2(outTradeNo); + case WxPayClientConfig.API_VERSION_V3: + return doGetOrderV3(outTradeNo); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + if (ObjectUtils.equalsAny(e.getErrCode(), "ORDERNOTEXIST", "ORDER_NOT_EXIST")) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayOrderRespDTO.closedOf(errorCode, errorMessage, + outTradeNo, e.getXmlString()); + } + throw e; + } + } + + private PayOrderRespDTO doGetOrderV2(String outTradeNo) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayOrderQueryRequest request = WxPayOrderQueryRequest.newBuilder() + .outTradeNo(outTradeNo).build(); + // 执行请求 + WxPayOrderQueryResult response = client.queryOrder(request); + + // 转换结果 + Integer status = parseStatus(response.getTradeState()); + return PayOrderRespDTO.of(status, response.getTransactionId(), response.getOpenid(), parseDateV2(response.getTimeEnd()), + outTradeNo, response); + } + + private PayOrderRespDTO doGetOrderV3(String outTradeNo) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayOrderQueryV3Request request = new WxPayOrderQueryV3Request() + .setOutTradeNo(outTradeNo); + // 执行请求 + WxPayOrderQueryV3Result response = client.queryOrderV3(request); + + // 转换结果 + Integer status = parseStatus(response.getTradeState()); + String openid = response.getPayer() != null ? response.getPayer().getOpenid() : null; + return PayOrderRespDTO.of(status, response.getTransactionId(), openid, parseDateV3(response.getSuccessTime()), + outTradeNo, response); + } + + private static Integer parseStatus(String tradeState) { + switch (tradeState) { + case "NOTPAY": + case "USERPAYING": // 支付中,等待用户输入密码(条码支付独有) + return PayOrderStatusRespEnum.WAITING.getStatus(); + case "SUCCESS": + return PayOrderStatusRespEnum.SUCCESS.getStatus(); + case "REFUND": + return PayOrderStatusRespEnum.REFUND.getStatus(); + case "CLOSED": + case "REVOKED": // 已撤销(刷卡支付独有) + case "PAYERROR": // 支付失败(其它原因,如银行返回失败) + return PayOrderStatusRespEnum.CLOSED.getStatus(); + default: + throw new IllegalArgumentException(StrUtil.format("未知的支付状态({})", tradeState)); + } + } + + // ============ 退款相关 ========== + + @Override + protected PayRefundRespDTO doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws Throwable { + try { + switch (config.getApiVersion()) { + case WxPayClientConfig.API_VERSION_V2: + return doUnifiedRefundV2(reqDTO); + case WxPayClientConfig.API_VERSION_V3: + return doUnifiedRefundV3(reqDTO); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayRefundRespDTO.failureOf(errorCode, errorMessage, + reqDTO.getOutTradeNo(), e.getXmlString()); + } + } + + private PayRefundRespDTO doUnifiedRefundV2(PayRefundUnifiedReqDTO reqDTO) throws Throwable { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundRequest request = new WxPayRefundRequest() + .setOutTradeNo(reqDTO.getOutTradeNo()) + .setOutRefundNo(reqDTO.getOutRefundNo()) + .setRefundFee(reqDTO.getRefundPrice()) + .setRefundDesc(reqDTO.getReason()) + .setTotalFee(reqDTO.getPayPrice()) + .setNotifyUrl(reqDTO.getNotifyUrl()); + // 2.1 执行请求 + WxPayRefundResult response = client.refundV2(request); + // 2.2 创建返回结果 + if (Objects.equals("SUCCESS", response.getResultCode())) { // V2 情况下,不直接返回退款成功,而是等待异步通知 + return PayRefundRespDTO.waitingOf(response.getRefundId(), + reqDTO.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(reqDTO.getOutRefundNo(), response); + } + + private PayRefundRespDTO doUnifiedRefundV3(PayRefundUnifiedReqDTO reqDTO) throws Throwable { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundV3Request request = new WxPayRefundV3Request() + .setOutTradeNo(reqDTO.getOutTradeNo()) + .setOutRefundNo(reqDTO.getOutRefundNo()) + .setAmount(new WxPayRefundV3Request.Amount().setRefund(reqDTO.getRefundPrice()) + .setTotal(reqDTO.getPayPrice()).setCurrency("CNY")) + .setReason(reqDTO.getReason()) + .setNotifyUrl(reqDTO.getNotifyUrl()); + // 2.1 执行请求 + WxPayRefundV3Result response = client.refundV3(request); + // 2.2 创建返回结果 + if (Objects.equals("SUCCESS", response.getStatus())) { + return PayRefundRespDTO.successOf(response.getRefundId(), parseDateV3(response.getSuccessTime()), + reqDTO.getOutRefundNo(), response); + } + if (Objects.equals("PROCESSING", response.getStatus())) { + return PayRefundRespDTO.waitingOf(response.getRefundId(), + reqDTO.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(reqDTO.getOutRefundNo(), response); + } + + @Override + public PayRefundRespDTO doParseRefundNotify(Map params, String body) throws WxPayException { + switch (config.getApiVersion()) { + case WxPayClientConfig.API_VERSION_V2: + return doParseRefundNotifyV2(body); + case WxPayClientConfig.API_VERSION_V3: + return parseRefundNotifyV3(body); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } + + private PayRefundRespDTO doParseRefundNotifyV2(String body) throws WxPayException { + // 1. 解析回调 + WxPayRefundNotifyResult response = client.parseRefundNotifyResult(body); + WxPayRefundNotifyResult.ReqInfo result = response.getReqInfo(); + // 2. 构建结果 + if (Objects.equals("SUCCESS", result.getRefundStatus())) { + return PayRefundRespDTO.successOf(result.getRefundId(), parseDateV2B(result.getSuccessTime()), + result.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(result.getOutRefundNo(), response); + } + + private PayRefundRespDTO parseRefundNotifyV3(String body) throws WxPayException { + // 1. 解析回调 + WxPayRefundNotifyV3Result response = client.parseRefundNotifyV3Result(body, null); + WxPayRefundNotifyV3Result.DecryptNotifyResult result = response.getResult(); + // 2. 构建结果 + if (Objects.equals("SUCCESS", result.getRefundStatus())) { + return PayRefundRespDTO.successOf(result.getRefundId(), parseDateV3(result.getSuccessTime()), + result.getOutRefundNo(), response); + } + return PayRefundRespDTO.failureOf(result.getOutRefundNo(), response); + } + + @Override + protected PayRefundRespDTO doGetRefund(String outTradeNo, String outRefundNo) throws WxPayException { + try { + switch (config.getApiVersion()) { + case WxPayClientConfig.API_VERSION_V2: + return doGetRefundV2(outTradeNo, outRefundNo); + case WxPayClientConfig.API_VERSION_V3: + return doGetRefundV3(outTradeNo, outRefundNo); + default: + throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion())); + } + } catch (WxPayException e) { + if (ObjectUtils.equalsAny(e.getErrCode(), "REFUNDNOTEXIST", "RESOURCE_NOT_EXISTS")) { + String errorCode = getErrorCode(e); + String errorMessage = getErrorMessage(e); + return PayRefundRespDTO.failureOf(errorCode, errorMessage, + outRefundNo, e.getXmlString()); + } + throw e; + } + } + + private PayRefundRespDTO doGetRefundV2(String outTradeNo, String outRefundNo) throws WxPayException { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundQueryRequest request = WxPayRefundQueryRequest.newBuilder() + .outTradeNo(outTradeNo) + .outRefundNo(outRefundNo) + .build(); + // 2.1 执行请求 + WxPayRefundQueryResult response = client.refundQuery(request); + // 2.2 创建返回结果 + if (!Objects.equals("SUCCESS", response.getResultCode())) { + return PayRefundRespDTO.waitingOf(null, + outRefundNo, response); + } + WxPayRefundQueryResult.RefundRecord refund = CollUtil.findOne(response.getRefundRecords(), + record -> record.getOutRefundNo().equals(outRefundNo)); + if (refund == null) { + return PayRefundRespDTO.failureOf(outRefundNo, response); + } + switch (refund.getRefundStatus()) { + case "SUCCESS": + return PayRefundRespDTO.successOf(refund.getRefundId(), parseDateV2B(refund.getRefundSuccessTime()), + outRefundNo, response); + case "PROCESSING": + return PayRefundRespDTO.waitingOf(refund.getRefundId(), + outRefundNo, response); + case "CHANGE": // 退款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,资金回流到商户的现金帐号,需要商户人工干预,通过线下或者财付通转账的方式进行退款 + case "FAIL": + return PayRefundRespDTO.failureOf(outRefundNo, response); + default: + throw new IllegalArgumentException(String.format("未知的退款状态(%s)", refund.getRefundStatus())); + } + } + + private PayRefundRespDTO doGetRefundV3(String outTradeNo, String outRefundNo) throws WxPayException { + // 1. 构建 WxPayRefundRequest 请求 + WxPayRefundQueryV3Request request = new WxPayRefundQueryV3Request(); + request.setOutRefundNo(outRefundNo); + // 2.1 执行请求 + WxPayRefundQueryV3Result response = client.refundQueryV3(request); + // 2.2 创建返回结果 + switch (response.getStatus()) { + case "SUCCESS": + return PayRefundRespDTO.successOf(response.getRefundId(), parseDateV3(response.getSuccessTime()), + outRefundNo, response); + case "PROCESSING": + return PayRefundRespDTO.waitingOf(response.getRefundId(), + outRefundNo, response); + case "ABNORMAL": // 退款异常 + case "CLOSED": + return PayRefundRespDTO.failureOf(outRefundNo, response); + default: + throw new IllegalArgumentException(String.format("未知的退款状态(%s)", response.getStatus())); + } + } + + @Override + protected PayTransferRespDTO doUnifiedTransfer(PayTransferUnifiedReqDTO reqDTO) { + throw new UnsupportedOperationException("待实现"); + } + // ========== 各种工具方法 ========== + + static String formatDateV2(LocalDateTime time) { + return TemporalAccessorUtil.format(time.atZone(ZoneId.systemDefault()), PURE_DATETIME_PATTERN); + } + + static LocalDateTime parseDateV2(String time) { + return LocalDateTimeUtil.parse(time, PURE_DATETIME_PATTERN); + } + + static LocalDateTime parseDateV2B(String time) { + return LocalDateTimeUtil.parse(time, NORM_DATETIME_PATTERN); + } + + static String formatDateV3(LocalDateTime time) { + return TemporalAccessorUtil.format(time.atZone(ZoneId.systemDefault()), UTC_WITH_XXX_OFFSET_PATTERN); + } + + static LocalDateTime parseDateV3(String time) { + return LocalDateTimeUtil.parse(time, UTC_WITH_XXX_OFFSET_PATTERN); + } + + static String getErrorCode(WxPayException e) { + if (StrUtil.isNotEmpty(e.getErrCode())) { + return e.getErrCode(); + } + if (StrUtil.isNotEmpty(e.getCustomErrorMsg())) { + return "CUSTOM_ERROR"; + } + return e.getReturnCode(); + } + + static String getErrorMessage(WxPayException e) { + if (StrUtil.isNotEmpty(e.getErrCode())) { + return e.getErrCodeDes(); + } + if (StrUtil.isNotEmpty(e.getCustomErrorMsg())) { + return e.getCustomErrorMsg(); + } + return e.getReturnMsg(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxAppPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxAppPayClient.java new file mode 100644 index 000000000..8bdf12a8d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxAppPayClient.java @@ -0,0 +1,63 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.order.WxPayMpOrderResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +import static com.cf.imes.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 微信支付【App 支付】的 PayClient 实现类 + * + * 文档:App 支付 + * + * // TODO 晨丰:未详细测试,因为手头没 App + * + * @author 晨丰科技 + */ +@Slf4j +public class WxAppPayClient extends AbstractWxPayClient { + + public WxAppPayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_APP.getCode(), config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.APP); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderRequest request = buildPayUnifiedOrderRequestV2(reqDTO); + // 执行请求 + WxPayMpOrderResult response = client.createOrder(request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderV3Request 对象 + WxPayUnifiedOrderV3Request request = buildPayUnifiedOrderRequestV3(reqDTO); + // 执行请求 + WxPayUnifiedOrderV3Result.AppResult response = client.createOrderV3(TradeTypeEnum.APP, request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxBarPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxBarPayClient.java new file mode 100644 index 000000000..055f38a84 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxBarPayClient.java @@ -0,0 +1,107 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.date.LocalDateTimeUtils; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.request.WxPayMicropayRequest; +import com.github.binarywang.wxpay.bean.result.WxPayMicropayResult; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; + +import static com.cf.imes.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static com.cf.imes.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 微信支付【付款码支付】的 PayClient 实现类 + * + * 文档:付款码支付 + * + * @author 晨丰科技 + */ +@Slf4j +public class WxBarPayClient extends AbstractWxPayClient { + + /** + * 微信付款码的过期时间 + */ + private static final Duration AUTH_CODE_EXPIRE = Duration.ofMinutes(3); + + public WxBarPayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_BAR.getCode(), config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.MICROPAY); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 由于付款码需要不断轮询,所以需要在较短的时间完成支付 + LocalDateTime expireTime = LocalDateTimeUtils.addTime(AUTH_CODE_EXPIRE); + if (expireTime.isAfter(reqDTO.getExpireTime())) { + expireTime = reqDTO.getExpireTime(); + } + // 构建 WxPayMicropayRequest 对象 + WxPayMicropayRequest request = WxPayMicropayRequest.newBuilder() + .outTradeNo(reqDTO.getOutTradeNo()) + .body(reqDTO.getSubject()) + .detail(reqDTO.getBody()) + .totalFee(reqDTO.getPrice()) // 单位分 + .timeExpire(formatDateV2(expireTime)) + .spbillCreateIp(reqDTO.getUserIp()) + .authCode(getAuthCode(reqDTO)) + .build(); + // 执行请求,重试直到失败(过期),或者成功 + WxPayException lastWxPayException = null; + for (int i = 1; i < Byte.MAX_VALUE; i++) { + try { + WxPayMicropayResult response = client.micropay(request); + // 支付成功,例如说:1)用户输入了密码;2)用户免密支付 + return PayOrderRespDTO.successOf(response.getTransactionId(), response.getOpenid(), parseDateV2(response.getTimeEnd()), + response.getOutTradeNo(), response) + .setDisplayMode(PayOrderDisplayModeEnum.BAR_CODE.getMode()); + } catch (WxPayException ex) { + lastWxPayException = ex; + // 如果不满足这 3 种任一的,则直接抛出 WxPayException 异常,不仅需处理 + // 1. SYSTEMERROR:接口返回错误:请立即调用被扫订单结果查询API,查询当前订单状态,并根据订单的状态决定下一步的操作。 + // 2. USERPAYING:用户支付中,需要输入密码:等待 5 秒,然后调用被扫订单结果查询 API,查询当前订单的不同状态,决定下一步的操作。 + // 3. BANKERROR:银行系统异常:请立即调用被扫订单结果查询 API,查询当前订单的不同状态,决定下一步的操作。 + if (!StrUtil.equalsAny(ex.getErrCode(), "SYSTEMERROR", "USERPAYING", "BANKERROR")) { + throw ex; + } + // 等待 5 秒,继续下一轮重新发起支付 + log.info("[doUnifiedOrderV2][发起微信 Bar 支付第({})失败,等待下一轮重试,请求({}),响应({})]", i, + toJsonString(request), ex.getMessage()); + ThreadUtil.sleep(5, TimeUnit.SECONDS); + } + } + throw lastWxPayException; + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + return doUnifiedOrderV2(reqDTO); + } + + // ========== 各种工具方法 ========== + + static String getAuthCode(PayOrderUnifiedReqDTO reqDTO) { + String authCode = MapUtil.getStr(reqDTO.getChannelExtras(), "authCode"); + if (StrUtil.isEmpty(authCode)) { + throw invalidParamException("支付请求的 authCode 不能为空!"); + } + return authCode; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxLitePayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxLitePayClient.java new file mode 100644 index 000000000..cfd4125e2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxLitePayClient.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import lombok.extern.slf4j.Slf4j; + +/** + * 微信支付【小程序】的 PayClient 实现类 + * + * 由于公众号和小程序的微信支付逻辑一致,所以直接进行继承 + * + * 文档:JSAPI 下单 + * + * @author zwy + */ +@Slf4j +public class WxLitePayClient extends WxPubPayClient { + + public WxLitePayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_LITE.getCode(), config); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxNativePayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxNativePayClient.java new file mode 100644 index 000000000..0869a7779 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxNativePayClient.java @@ -0,0 +1,58 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.order.WxPayNativeOrderResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +/** + * 微信支付【Native 二维码】的 PayClient 实现类 + * + * 文档:Native 下单 + * + * @author zwy + */ +@Slf4j +public class WxNativePayClient extends AbstractWxPayClient { + + public WxNativePayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_NATIVE.getCode(), config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.NATIVE); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderRequest request = buildPayUnifiedOrderRequestV2(reqDTO); + // 执行请求 + WxPayNativeOrderResult response = client.createOrder(request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.QR_CODE.getMode(), response.getCodeUrl(), + reqDTO.getOutTradeNo(), response); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderV3Request 对象 + WxPayUnifiedOrderV3Request request = buildPayUnifiedOrderRequestV3(reqDTO); + // 执行请求 + String response = client.createOrderV3(TradeTypeEnum.NATIVE, request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.QR_CODE.getMode(), response, + reqDTO.getOutTradeNo(), response); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxPayClientConfig.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxPayClientConfig.java new file mode 100644 index 000000000..b8ab645a5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxPayClientConfig.java @@ -0,0 +1,110 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.io.IoUtil; +import com.cf.imes.framework.common.util.validation.ValidationUtils; +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import lombok.Data; + +import javax.validation.Validator; +import javax.validation.constraints.NotBlank; +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * 微信支付的 PayClientConfig 实现类 + * 属性主要来自 {@link com.github.binarywang.wxpay.config.WxPayConfig} 的必要属性 + * + * @author 晨丰科技 + */ +@Data +public class WxPayClientConfig implements PayClientConfig { + + /** + * API 版本 - V2 + * + * V2 协议说明 + */ + public static final String API_VERSION_V2 = "v2"; + /** + * API 版本 - V3 + * + * V3 协议说明 + */ + public static final String API_VERSION_V3 = "v3"; + + /** + * 公众号或者小程序的 appid + * + * 只有公众号或小程序需要该字段 + */ + @NotBlank(message = "APPID 不能为空", groups = {V2.class, V3.class}) + private String appId; + /** + * 商户号 + */ + @NotBlank(message = "商户号不能为空", groups = {V2.class, V3.class}) + private String mchId; + /** + * API 版本 + */ + @NotBlank(message = "API 版本不能为空", groups = {V2.class, V3.class}) + private String apiVersion; + + // ========== V2 版本的参数 ========== + + /** + * 商户密钥 + */ + @NotBlank(message = "商户密钥不能为空", groups = V2.class) + private String mchKey; + /** + * apiclient_cert.p12 证书文件的对应字符串【base64 格式】 + * + * 为什么采用 base64 格式?因为 p12 读取后是二进制,需要转换成 base64 格式才好传输和存储 + */ + @NotBlank(message = "apiclient_cert.p12 不能为空", groups = V2.class) + private String keyContent; + + // ========== V3 版本的参数 ========== + /** + * apiclient_key.pem 证书文件的对应字符串 + */ + @NotBlank(message = "apiclient_key 不能为空", groups = V3.class) + private String privateKeyContent; + /** + * apiclient_cert.pem 证书文件的对应的字符串 + */ + @NotBlank(message = "apiclient_cert 不能为空", groups = V3.class) + private String privateCertContent; + /** + * apiV3 密钥值 + */ + @NotBlank(message = "apiV3 密钥值不能为空", groups = V3.class) + private String apiV3Key; + + /** + * 分组校验 v2版本 + */ + public interface V2 { + } + + /** + * 分组校验 v3版本 + */ + public interface V3 { + } + + @Override + public void validate(Validator validator) { + ValidationUtils.validate(validator, this, + API_VERSION_V2.equals(this.getApiVersion()) ? V2.class : V3.class); + } + + public static void main(String[] args) throws FileNotFoundException { + String path = "/Users/yunai/Downloads/wx_pay/apiclient_cert.p12"; + /// String path = "/Users/yunai/Downloads/wx_pay/apiclient_key.pem"; + /// String path = "/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"; + System.out.println(IoUtil.readUtf8(new FileInputStream(path))); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxPubPayClient.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxPubPayClient.java new file mode 100644 index 000000000..1bf008979 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxPubPayClient.java @@ -0,0 +1,80 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.github.binarywang.wxpay.bean.order.WxPayMpOrderResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.constant.WxPayConstants; +import com.github.binarywang.wxpay.exception.WxPayException; +import lombok.extern.slf4j.Slf4j; + +import static com.cf.imes.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static com.cf.imes.framework.common.util.json.JsonUtils.toJsonString; + +/** + * 微信支付(公众号)的 PayClient 实现类 + * + * 文档:JSAPI 下单 + * + * @author 晨丰科技 + */ +@Slf4j +public class WxPubPayClient extends AbstractWxPayClient { + + public WxPubPayClient(Long channelId, WxPayClientConfig config) { + super(channelId, PayChannelEnum.WX_PUB.getCode(), config); + } + + protected WxPubPayClient(Long channelId, String channelCode, WxPayClientConfig config) { + super(channelId, channelCode, config); + } + + @Override + protected void doInit() { + super.doInit(WxPayConstants.TradeType.JSAPI); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV2(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderRequest request = buildPayUnifiedOrderRequestV2(reqDTO) + .setOpenid(getOpenid(reqDTO)); + // 执行请求 + WxPayMpOrderResult response = client.createOrder(request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + + @Override + protected PayOrderRespDTO doUnifiedOrderV3(PayOrderUnifiedReqDTO reqDTO) throws WxPayException { + // 构建 WxPayUnifiedOrderRequest 对象 + WxPayUnifiedOrderV3Request request = buildPayUnifiedOrderRequestV3(reqDTO) + .setPayer(new WxPayUnifiedOrderV3Request.Payer().setOpenid(getOpenid(reqDTO))); + // 执行请求 + WxPayUnifiedOrderV3Result.JsapiResult response = client.createOrderV3(TradeTypeEnum.JSAPI, request); + + // 转换结果 + return PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), toJsonString(response), + reqDTO.getOutTradeNo(), response); + } + + // ========== 各种工具方法 ========== + + static String getOpenid(PayOrderUnifiedReqDTO reqDTO) { + String openid = MapUtil.getStr(reqDTO.getChannelExtras(), "openid"); + if (StrUtil.isEmpty(openid)) { + throw invalidParamException("支付请求的 openid 不能为空!"); + } + return openid; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/channel/PayChannelEnum.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/channel/PayChannelEnum.java new file mode 100644 index 000000000..77947db72 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/channel/PayChannelEnum.java @@ -0,0 +1,65 @@ +package com.cf.imes.framework.pay.core.enums.channel; + +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.pay.core.client.PayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.NonePayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxPayClientConfig; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付渠道的编码的枚举 + * + * @author 晨丰科技 + */ +@Getter +@AllArgsConstructor +public enum PayChannelEnum { + + WX_PUB("wx_pub", "微信 JSAPI 支付", WxPayClientConfig.class), // 公众号网页 + WX_LITE("wx_lite", "微信小程序支付", WxPayClientConfig.class), + WX_APP("wx_app", "微信 App 支付", WxPayClientConfig.class), + WX_NATIVE("wx_native", "微信 Native 支付", WxPayClientConfig.class), + WX_BAR("wx_bar", "微信付款码支付", WxPayClientConfig.class), + + ALIPAY_PC("alipay_pc", "支付宝 PC 网站支付", AlipayPayClientConfig.class), + ALIPAY_WAP("alipay_wap", "支付宝 Wap 网站支付", AlipayPayClientConfig.class), + ALIPAY_APP("alipay_app", "支付宝App 支付", AlipayPayClientConfig.class), + ALIPAY_QR("alipay_qr", "支付宝扫码支付", AlipayPayClientConfig.class), + ALIPAY_BAR("alipay_bar", "支付宝条码支付", AlipayPayClientConfig.class), + MOCK("mock", "模拟支付", NonePayClientConfig.class), + + WALLET("wallet", "钱包支付", NonePayClientConfig.class); + + /** + * 编码 + * + * 参考 支付渠道属性值 + */ + private final String code; + /** + * 名字 + */ + private final String name; + + /** + * 配置类 + */ + private final Class configClass; + + /** + * 微信支付 + */ + public static final String WECHAT = "WECHAT"; + + /** + * 支付宝支付 + */ + public static final String ALIPAY = "ALIPAY"; + + public static PayChannelEnum getByCode(String code) { + return ArrayUtil.firstMatch(o -> o.getCode().equals(code), values()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/order/PayOrderDisplayModeEnum.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/order/PayOrderDisplayModeEnum.java new file mode 100644 index 000000000..727274179 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/order/PayOrderDisplayModeEnum.java @@ -0,0 +1,29 @@ +package com.cf.imes.framework.pay.core.enums.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 支付 UI 展示模式 + * + * @author 晨丰科技 + */ +@Getter +@AllArgsConstructor +public enum PayOrderDisplayModeEnum { + + URL("url"), // Redirect 跳转链接的方式 + IFRAME("iframe"), // IFrame 内嵌链接的方式【目前暂时用不到】 + FORM("form"), // HTML 表单提交 + QR_CODE("qr_code"), // 二维码的文字内容 + QR_CODE_URL("qr_code_url"), // 二维码的图片链接 + BAR_CODE("bar_code"), // 条形码 + APP("app"), // 应用:Android、iOS、微信小程序、微信公众号等,需要做自定义处理的 + ; + + /** + * 展示模式 + */ + private final String mode; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/order/PayOrderStatusRespEnum.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/order/PayOrderStatusRespEnum.java new file mode 100644 index 000000000..343995a22 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/order/PayOrderStatusRespEnum.java @@ -0,0 +1,56 @@ +package com.cf.imes.framework.pay.core.enums.order; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 渠道的支付状态枚举 + * + * @author 晨丰科技 + */ +@Getter +@AllArgsConstructor +public enum PayOrderStatusRespEnum { + + WAITING(0, "未支付"), + SUCCESS(10, "支付成功"), + REFUND(20, "已退款"), + CLOSED(30, "支付关闭"), + ; + + private final Integer status; + private final String name; + + /** + * 判断是否支付成功 + * + * @param status 状态 + * @return 是否支付成功 + */ + public static boolean isSuccess(Integer status) { + return Objects.equals(status, SUCCESS.getStatus()); + } + + /** + * 判断是否已退款 + * + * @param status 状态 + * @return 是否支付成功 + */ + public static boolean isRefund(Integer status) { + return Objects.equals(status, REFUND.getStatus()); + } + + /** + * 判断是否支付关闭 + * + * @param status 状态 + * @return 是否支付关闭 + */ + public static boolean isClosed(Integer status) { + return Objects.equals(status, CLOSED.getStatus()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/refund/PayRefundStatusRespEnum.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/refund/PayRefundStatusRespEnum.java new file mode 100644 index 000000000..87562ec41 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/refund/PayRefundStatusRespEnum.java @@ -0,0 +1,32 @@ +package com.cf.imes.framework.pay.core.enums.refund; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 渠道的退款状态枚举 + * + * @author jason + */ +@Getter +@AllArgsConstructor +public enum PayRefundStatusRespEnum { + + WAITING(0, "等待退款"), + SUCCESS(10, "退款成功"), + FAILURE(20, "退款失败"); + + private final Integer status; + private final String name; + + public static boolean isSuccess(Integer status) { + return Objects.equals(status, SUCCESS.getStatus()); + } + + public static boolean isFailure(Integer status) { + return Objects.equals(status, FAILURE.getStatus()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/transfer/PayTransferStatusRespEnum.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/transfer/PayTransferStatusRespEnum.java new file mode 100644 index 000000000..f19ba75cf --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/transfer/PayTransferStatusRespEnum.java @@ -0,0 +1,41 @@ +package com.cf.imes.framework.pay.core.enums.transfer; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 渠道的转账状态枚举 + * + * @author jason + */ +@Getter +@AllArgsConstructor +public enum PayTransferStatusRespEnum { + + WAITING(0, "转账中"), + + /** + * TODO 转账到银行卡. 会有T+0 T+1 到账的请情况。 还未实现 + * TODO @jason:可以看看其它开源项目,针对这个场景,处理策略是怎么样的?例如说,每天主动轮询?这个状态的单子? + */ + IN_PROGRESS(10, "转账进行中"), + + SUCCESS(20, "转账成功"), + /** + * 转账关闭 (失败,或者其它情况) + */ + CLOSED(30, "转账关闭"); + + private final Integer status; + private final String name; + + public static boolean isSuccess(Integer status) { + return Objects.equals(status, SUCCESS.getStatus()); + } + + public static boolean isClosed(Integer status) { + return Objects.equals(status, CLOSED.getStatus()); + } +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/transfer/PayTransferTypeEnum.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/transfer/PayTransferTypeEnum.java new file mode 100644 index 000000000..1d777f1bc --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/java/com/cf/imes/framework/pay/core/enums/transfer/PayTransferTypeEnum.java @@ -0,0 +1,41 @@ +package com.cf.imes.framework.pay.core.enums.transfer; + +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 转账类型枚举 + * + * @author jason + */ +@AllArgsConstructor +@Getter +public enum PayTransferTypeEnum implements IntArrayValuable { + + ALIPAY_BALANCE(1, "支付宝余额"), + WX_BALANCE(2, "微信余额"), + BANK_CARD(3, "银行卡"), + WALLET_BALANCE(4, "钱包余额"); + + public static final String ALIPAY_LOGON_ID = "ALIPAY_LOGON_ID"; + public static final String ALIPAY_ACCOUNT_NAME = "ALIPAY_ACCOUNT_NAME"; + + private final Integer type; + private final String name; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(PayTransferTypeEnum::getType).toArray(); + + @Override + public int[] array() { + return ARRAYS; + } + + public static PayTransferTypeEnum typeOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..18bfffb08 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.pay.config.ChenfengPayAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test-integration/java/cn/iocoder/yudao/framework/core/client/impl/PayClientFactoryImplTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test-integration/java/cn/iocoder/yudao/framework/core/client/impl/PayClientFactoryImplTest.java new file mode 100644 index 000000000..80ce475f3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test-integration/java/cn/iocoder/yudao/framework/core/client/impl/PayClientFactoryImplTest.java @@ -0,0 +1,133 @@ +package com.cf.imes.framework.core.client.impl; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.RandomUtil; +import com.cf.imes.framework.common.pojo.CommonResult; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.pay.core.client.PayClient; +import com.cf.imes.framework.pay.core.client.dto.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.impl.PayClientFactoryImpl; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayQrPayClient; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayWapPayClient; +import com.cf.imes.framework.pay.core.client.impl.wx.WXPayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.wx.WXPubPayClient; +import com.cf.imes.framework.pay.core.enums.PayChannelEnum; +import com.alipay.api.response.AlipayTradePrecreateResponse; +import org.junit.jupiter.api.Test; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * {@link PayClientFactoryImpl} 的集成测试 + * + * @author 晨丰科技 + */ +public class PayClientFactoryImplTest { + + private final PayClientFactoryImpl payClientFactory = new PayClientFactoryImpl(); + + /** + * {@link WXPubPayClient} 的 V2 版本 + */ + @Test + public void testCreatePayClient_WX_PUB_V2() { + // 创建配置 + WXPayClientConfig config = new WXPayClientConfig(); + config.setAppId("wx041349c6f39b268b"); + config.setMchId("1545083881"); + config.setApiVersion(WXPayClientConfig.API_VERSION_V2); + config.setMchKey("0alL64UDQdlCwiKZ73ib7ypaIjMns06p"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.WX_PUB.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); + CommonResult result = client.unifiedOrder(reqDTO); + System.out.println(result); + } + + /** + * {@link WXPubPayClient} 的 V3 版本 + */ + @Test + public void testCreatePayClient_WX_PUB_V3() throws FileNotFoundException { + // 创建配置 + WXPayClientConfig config = new WXPayClientConfig(); + config.setAppId("wx041349c6f39b268b"); + config.setMchId("1545083881"); + config.setApiVersion(WXPayClientConfig.API_VERSION_V3); + config.setPrivateKeyContent(IoUtil.readUtf8(new FileInputStream("/Users/yunai/Downloads/wx_pay/apiclient_key.pem"))); + config.setPrivateCertContent(IoUtil.readUtf8(new FileInputStream("/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"))); + config.setApiV3Key("joerVi8y5DJ3o4ttA0o1uH47Xz1u2Ase"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.WX_PUB.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); + CommonResult result = client.unifiedOrder(reqDTO); + System.out.println(result); + } + + /** + * {@link AlipayQrPayClient} + */ + @Test + @SuppressWarnings("unchecked") + public void testCreatePayClient_ALIPAY_QR() { + // 创建配置 + AlipayPayClientConfig config = new AlipayPayClientConfig(); + config.setAppId("2021000118634035"); + config.setServerUrl(AlipayPayClientConfig.SERVER_URL_SANDBOX); + config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + config.setPrivateKey("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCHsEV1cDupwJv890x84qbppUtRIfhaKSwSVN0thCcsDCaAsGR5MZslDkO8NCT9V4r2SVXjyY7eJUZlZd1M0C8T01Tg4UOx5LUbic0O3A1uJMy6V1n9IyYwbAW3AEZhBd5bSbPgrqvmv3NeWSTQT6Anxnllf+2iDH6zyA2fPl7cYyQtbZoDJQFGqr4F+cGh2R6akzRKNoBkAeMYwoY6es2lX8sJxCVPWUmxNUoL3tScwlSpd7Bxw0q9c/X01jMwuQ0+Va358zgFiGERTE6yD01eu40OBDXOYO3z++y+TAYHlQQ2toMO63trepo88X3xV3R44/1DH+k2pAm2IF5ixiLrAgMBAAECggEAPx3SoXcseaD7rmcGcE0p4SMfbsUDdkUSmBBbtfF0GzwnqNLkWa+mgE0rWt9SmXngTQH97vByAYmLPl1s3G82ht1V7Sk7yQMe74lhFllr8eEyTjeVx3dTK1EEM4TwN+936DTXdFsr4TELJEcJJdD0KaxcCcfBLRDs2wnitEFZ9N+GoZybVmY8w0e0MI7PLObUZ2l0X4RurQnfG9ZxjXjC7PkeMVv7cGGylpNFi3BbvkRhdhLPDC2E6wqnr9e7zk+hiENivAezXrtxtwKovzCtnWJ1r0IO14Rh47H509Ic0wFnj+o5YyUL4LdmpL7yaaH6fM7zcSLFjNZPHvZCKPwYcQKBgQDQFho98QvnL8ex4v6cry4VitGpjSXm1qP3vmMQk4rTsn8iPWtcxPjqGEqOQJjdi4Mi0VZKQOLFwlH0kl95wNrD/isJ4O1yeYfX7YAXApzHqYNINzM79HemO3Yx1qLMW3okRFJ9pPRzbQ9qkTpsaegsmyX316zOBhzGRYjKbutTYwKBgQCm7phr9XdFW5Vh+XR90mVs483nrLmMiDKg7YKxSLJ8amiDjzPejCn7i95Hah08P+2MIZLIPbh2VLacczR6ltRRzN5bg5etFuqSgfkuHyxpoDmpjbe08+Q2h8JBYqcC5Nhv1AKU4iOUhVLHo/FBAQliMcGc/J3eiYTFC7EsNx382QKBgClb20doe7cttgFTXswBvaUmfFm45kmla924B7SpvrQpDD/f+VDtDZRp05fGmxuduSjYdtA3aVtpLiTwWu22OUUvZZqHDGruYOO4Hvdz23mL5b4ayqImCwoNU4bAZIc9v18p/UNf3/55NNE3oGcf/bev9rH2OjCQ4nM+Ktwhg8CFAoGACSgvbkShzUkv0ZcIf9ppu+ZnJh1AdGgINvGwaJ8vQ0nm/8h8NOoFZ4oNoGc+wU5Ubops7dUM6FjPR5e+OjdJ4E7Xp7d5O4J1TaIZlCEbo5OpdhaTDDcQvrkFu+Z4eN0qzj+YAKjDAOOrXc4tbr5q0FsgXscwtcNfaBuzFVTUrUkCgYEAwzPnMNhWG3zOWLUs2QFA2GP4Y+J8cpUYfj6pbKKzeLwyG9qBwF1NJpN8m+q9q7V9P2LY+9Lp9e1mGsGeqt5HMEA3P6vIpcqLJLqE/4PBLLRzfccTcmqb1m71+erxTRhHBRkGS+I7dZEb3olQfnS1Y1tpMBxiwYwR3LW4oXuJwj8="); + config.setAlipayPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnq90KnF4dTnlzzmxpujbI05OYqi5WxAS6cL0gnZFv2gK51HExF8v/BaP7P979PhFMgWTqmOOI+Dtno5s+yD09XTY1WkshbLk6i4g2Xlr8fyW9ODnkU88RI2w9UdPhQU4cPPwBNlrsYhKkVK2OxwM3kFqjoBBY0CZoZCsSQ3LDH5WeZqPArlsS6xa2zqJBuuoKjMrdpELl3eXSjP8K54eDJCbeetCZNKWLL3DPahTPB7LZikfYmslb0QUvCgGapD0xkS7eVq70NaL1G57MWABs4tbfWgxike4Daj3EfUrzIVspQxj7w8HEj9WozJPgL88kSJSits0pqD3n5r8HSuseQIDAQAB"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.ALIPAY_QR.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); + reqDTO.setNotifyUrl("http://niubi.natapp1.cc/api/pay/order/notify/alipay-qr/1"); // TODO @tina: 这里改成你的 natapp 回调地址 + CommonResult result = (CommonResult) client.unifiedOrder(reqDTO); + System.out.println(JsonUtils.toJsonString(result)); + System.out.println(result.getData().getQrCode()); + } + + /** + * {@link AlipayWapPayClient} + */ + @Test + public void testCreatePayClient_ALIPAY_WAP() { + // 创建配置 + AlipayPayClientConfig config = new AlipayPayClientConfig(); + config.setAppId("2021000118634035"); + config.setServerUrl(AlipayPayClientConfig.SERVER_URL_SANDBOX); + config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + config.setPrivateKey("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCHsEV1cDupwJv890x84qbppUtRIfhaKSwSVN0thCcsDCaAsGR5MZslDkO8NCT9V4r2SVXjyY7eJUZlZd1M0C8T01Tg4UOx5LUbic0O3A1uJMy6V1n9IyYwbAW3AEZhBd5bSbPgrqvmv3NeWSTQT6Anxnllf+2iDH6zyA2fPl7cYyQtbZoDJQFGqr4F+cGh2R6akzRKNoBkAeMYwoY6es2lX8sJxCVPWUmxNUoL3tScwlSpd7Bxw0q9c/X01jMwuQ0+Va358zgFiGERTE6yD01eu40OBDXOYO3z++y+TAYHlQQ2toMO63trepo88X3xV3R44/1DH+k2pAm2IF5ixiLrAgMBAAECggEAPx3SoXcseaD7rmcGcE0p4SMfbsUDdkUSmBBbtfF0GzwnqNLkWa+mgE0rWt9SmXngTQH97vByAYmLPl1s3G82ht1V7Sk7yQMe74lhFllr8eEyTjeVx3dTK1EEM4TwN+936DTXdFsr4TELJEcJJdD0KaxcCcfBLRDs2wnitEFZ9N+GoZybVmY8w0e0MI7PLObUZ2l0X4RurQnfG9ZxjXjC7PkeMVv7cGGylpNFi3BbvkRhdhLPDC2E6wqnr9e7zk+hiENivAezXrtxtwKovzCtnWJ1r0IO14Rh47H509Ic0wFnj+o5YyUL4LdmpL7yaaH6fM7zcSLFjNZPHvZCKPwYcQKBgQDQFho98QvnL8ex4v6cry4VitGpjSXm1qP3vmMQk4rTsn8iPWtcxPjqGEqOQJjdi4Mi0VZKQOLFwlH0kl95wNrD/isJ4O1yeYfX7YAXApzHqYNINzM79HemO3Yx1qLMW3okRFJ9pPRzbQ9qkTpsaegsmyX316zOBhzGRYjKbutTYwKBgQCm7phr9XdFW5Vh+XR90mVs483nrLmMiDKg7YKxSLJ8amiDjzPejCn7i95Hah08P+2MIZLIPbh2VLacczR6ltRRzN5bg5etFuqSgfkuHyxpoDmpjbe08+Q2h8JBYqcC5Nhv1AKU4iOUhVLHo/FBAQliMcGc/J3eiYTFC7EsNx382QKBgClb20doe7cttgFTXswBvaUmfFm45kmla924B7SpvrQpDD/f+VDtDZRp05fGmxuduSjYdtA3aVtpLiTwWu22OUUvZZqHDGruYOO4Hvdz23mL5b4ayqImCwoNU4bAZIc9v18p/UNf3/55NNE3oGcf/bev9rH2OjCQ4nM+Ktwhg8CFAoGACSgvbkShzUkv0ZcIf9ppu+ZnJh1AdGgINvGwaJ8vQ0nm/8h8NOoFZ4oNoGc+wU5Ubops7dUM6FjPR5e+OjdJ4E7Xp7d5O4J1TaIZlCEbo5OpdhaTDDcQvrkFu+Z4eN0qzj+YAKjDAOOrXc4tbr5q0FsgXscwtcNfaBuzFVTUrUkCgYEAwzPnMNhWG3zOWLUs2QFA2GP4Y+J8cpUYfj6pbKKzeLwyG9qBwF1NJpN8m+q9q7V9P2LY+9Lp9e1mGsGeqt5HMEA3P6vIpcqLJLqE/4PBLLRzfccTcmqb1m71+erxTRhHBRkGS+I7dZEb3olQfnS1Y1tpMBxiwYwR3LW4oXuJwj8="); + config.setAlipayPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnq90KnF4dTnlzzmxpujbI05OYqi5WxAS6cL0gnZFv2gK51HExF8v/BaP7P979PhFMgWTqmOOI+Dtno5s+yD09XTY1WkshbLk6i4g2Xlr8fyW9ODnkU88RI2w9UdPhQU4cPPwBNlrsYhKkVK2OxwM3kFqjoBBY0CZoZCsSQ3LDH5WeZqPArlsS6xa2zqJBuuoKjMrdpELl3eXSjP8K54eDJCbeetCZNKWLL3DPahTPB7LZikfYmslb0QUvCgGapD0xkS7eVq70NaL1G57MWABs4tbfWgxike4Daj3EfUrzIVspQxj7w8HEj9WozJPgL88kSJSits0pqD3n5r8HSuseQIDAQAB"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.ALIPAY_WAP.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); + CommonResult result = client.unifiedOrder(reqDTO); + System.out.println(JsonUtils.toJsonString(result)); + } + + private static PayOrderUnifiedReqDTO buildPayOrderUnifiedReqDTO() { + PayOrderUnifiedReqDTO reqDTO = new PayOrderUnifiedReqDTO(); + reqDTO.setAmount(123L); + reqDTO.setSubject("IPhone 13"); + reqDTO.setBody("biubiubiu"); + reqDTO.setMerchantOrderId(String.valueOf(System.currentTimeMillis())); + reqDTO.setUserIp("127.0.0.1"); + reqDTO.setNotifyUrl("http://127.0.0.1:8080"); + return reqDTO; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/PayClientFactoryImplIntegrationTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/PayClientFactoryImplIntegrationTest.java new file mode 100644 index 000000000..003d2c63c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/PayClientFactoryImplIntegrationTest.java @@ -0,0 +1,133 @@ +package com.cf.imes.framework.pay.core.client.impl; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.RandomUtil; +import com.cf.imes.framework.pay.core.client.PayClient; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayQrPayClient; +import com.cf.imes.framework.pay.core.client.impl.alipay.AlipayWapPayClient; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxPayClientConfig; +import com.cf.imes.framework.pay.core.client.impl.weixin.WxPubPayClient; +import com.cf.imes.framework.pay.core.enums.channel.PayChannelEnum; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * {@link PayClientFactoryImpl} 的集成测试 + * + * @author 晨丰科技 + */ +@Disabled +public class PayClientFactoryImplIntegrationTest { + + private static final String SERVER_URL_SANDBOX = "https://openapi.alipaydev.com/gateway.do"; + + private final PayClientFactoryImpl payClientFactory = new PayClientFactoryImpl(); + + /** + * {@link WxPubPayClient} 的 V2 版本 + */ + @Test + public void testCreatePayClient_WX_PUB_V2() { + // 创建配置 + WxPayClientConfig config = new WxPayClientConfig(); + config.setAppId("wx041349c6f39b268b"); + config.setMchId("1545083881"); + config.setApiVersion(WxPayClientConfig.API_VERSION_V2); + config.setMchKey("0alL64UDQdlCwiKZ73ib7ypaIjMns06p"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.WX_PUB.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); +// CommonResult result = client.unifiedOrder(reqDTO); +// System.out.println(result); + } + + /** + * {@link WxPubPayClient} 的 V3 版本 + */ + @Test + public void testCreatePayClient_WX_PUB_V3() throws FileNotFoundException { + // 创建配置 + WxPayClientConfig config = new WxPayClientConfig(); + config.setAppId("wx041349c6f39b268b"); + config.setMchId("1545083881"); + config.setApiVersion(WxPayClientConfig.API_VERSION_V3); + config.setPrivateKeyContent(IoUtil.readUtf8(new FileInputStream("/Users/yunai/Downloads/wx_pay/apiclient_key.pem"))); + config.setPrivateCertContent(IoUtil.readUtf8(new FileInputStream("/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"))); + config.setApiV3Key("joerVi8y5DJ3o4ttA0o1uH47Xz1u2Ase"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.WX_PUB.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); +// CommonResult result = client.unifiedOrder(reqDTO); +// System.out.println(result); + } + + /** + * {@link AlipayQrPayClient} + */ + @Test + @SuppressWarnings("unchecked") + public void testCreatePayClient_ALIPAY_QR() { + // 创建配置 + AlipayPayClientConfig config = new AlipayPayClientConfig(); + config.setAppId("2021000118634035"); + config.setServerUrl(SERVER_URL_SANDBOX); + config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + config.setPrivateKey("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCHsEV1cDupwJv890x84qbppUtRIfhaKSwSVN0thCcsDCaAsGR5MZslDkO8NCT9V4r2SVXjyY7eJUZlZd1M0C8T01Tg4UOx5LUbic0O3A1uJMy6V1n9IyYwbAW3AEZhBd5bSbPgrqvmv3NeWSTQT6Anxnllf+2iDH6zyA2fPl7cYyQtbZoDJQFGqr4F+cGh2R6akzRKNoBkAeMYwoY6es2lX8sJxCVPWUmxNUoL3tScwlSpd7Bxw0q9c/X01jMwuQ0+Va358zgFiGERTE6yD01eu40OBDXOYO3z++y+TAYHlQQ2toMO63trepo88X3xV3R44/1DH+k2pAm2IF5ixiLrAgMBAAECggEAPx3SoXcseaD7rmcGcE0p4SMfbsUDdkUSmBBbtfF0GzwnqNLkWa+mgE0rWt9SmXngTQH97vByAYmLPl1s3G82ht1V7Sk7yQMe74lhFllr8eEyTjeVx3dTK1EEM4TwN+936DTXdFsr4TELJEcJJdD0KaxcCcfBLRDs2wnitEFZ9N+GoZybVmY8w0e0MI7PLObUZ2l0X4RurQnfG9ZxjXjC7PkeMVv7cGGylpNFi3BbvkRhdhLPDC2E6wqnr9e7zk+hiENivAezXrtxtwKovzCtnWJ1r0IO14Rh47H509Ic0wFnj+o5YyUL4LdmpL7yaaH6fM7zcSLFjNZPHvZCKPwYcQKBgQDQFho98QvnL8ex4v6cry4VitGpjSXm1qP3vmMQk4rTsn8iPWtcxPjqGEqOQJjdi4Mi0VZKQOLFwlH0kl95wNrD/isJ4O1yeYfX7YAXApzHqYNINzM79HemO3Yx1qLMW3okRFJ9pPRzbQ9qkTpsaegsmyX316zOBhzGRYjKbutTYwKBgQCm7phr9XdFW5Vh+XR90mVs483nrLmMiDKg7YKxSLJ8amiDjzPejCn7i95Hah08P+2MIZLIPbh2VLacczR6ltRRzN5bg5etFuqSgfkuHyxpoDmpjbe08+Q2h8JBYqcC5Nhv1AKU4iOUhVLHo/FBAQliMcGc/J3eiYTFC7EsNx382QKBgClb20doe7cttgFTXswBvaUmfFm45kmla924B7SpvrQpDD/f+VDtDZRp05fGmxuduSjYdtA3aVtpLiTwWu22OUUvZZqHDGruYOO4Hvdz23mL5b4ayqImCwoNU4bAZIc9v18p/UNf3/55NNE3oGcf/bev9rH2OjCQ4nM+Ktwhg8CFAoGACSgvbkShzUkv0ZcIf9ppu+ZnJh1AdGgINvGwaJ8vQ0nm/8h8NOoFZ4oNoGc+wU5Ubops7dUM6FjPR5e+OjdJ4E7Xp7d5O4J1TaIZlCEbo5OpdhaTDDcQvrkFu+Z4eN0qzj+YAKjDAOOrXc4tbr5q0FsgXscwtcNfaBuzFVTUrUkCgYEAwzPnMNhWG3zOWLUs2QFA2GP4Y+J8cpUYfj6pbKKzeLwyG9qBwF1NJpN8m+q9q7V9P2LY+9Lp9e1mGsGeqt5HMEA3P6vIpcqLJLqE/4PBLLRzfccTcmqb1m71+erxTRhHBRkGS+I7dZEb3olQfnS1Y1tpMBxiwYwR3LW4oXuJwj8="); + config.setAlipayPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnq90KnF4dTnlzzmxpujbI05OYqi5WxAS6cL0gnZFv2gK51HExF8v/BaP7P979PhFMgWTqmOOI+Dtno5s+yD09XTY1WkshbLk6i4g2Xlr8fyW9ODnkU88RI2w9UdPhQU4cPPwBNlrsYhKkVK2OxwM3kFqjoBBY0CZoZCsSQ3LDH5WeZqPArlsS6xa2zqJBuuoKjMrdpELl3eXSjP8K54eDJCbeetCZNKWLL3DPahTPB7LZikfYmslb0QUvCgGapD0xkS7eVq70NaL1G57MWABs4tbfWgxike4Daj3EfUrzIVspQxj7w8HEj9WozJPgL88kSJSits0pqD3n5r8HSuseQIDAQAB"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.ALIPAY_QR.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); + reqDTO.setNotifyUrl("http://yunai.natapp1.cc/admin-api/pay/notify/callback/18"); // TODO @tina: 这里改成你的 natapp 回调地址 +// CommonResult result = (CommonResult) client.unifiedOrder(reqDTO); +// System.out.println(JsonUtils.toJsonString(result)); +// System.out.println(result.getData().getQrCode()); + } + + /** + * {@link AlipayWapPayClient} + */ + @Test + public void testCreatePayClient_ALIPAY_WAP() { + // 创建配置 + AlipayPayClientConfig config = new AlipayPayClientConfig(); + config.setAppId("2021000118634035"); + config.setServerUrl(SERVER_URL_SANDBOX); + config.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + config.setPrivateKey("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCHsEV1cDupwJv890x84qbppUtRIfhaKSwSVN0thCcsDCaAsGR5MZslDkO8NCT9V4r2SVXjyY7eJUZlZd1M0C8T01Tg4UOx5LUbic0O3A1uJMy6V1n9IyYwbAW3AEZhBd5bSbPgrqvmv3NeWSTQT6Anxnllf+2iDH6zyA2fPl7cYyQtbZoDJQFGqr4F+cGh2R6akzRKNoBkAeMYwoY6es2lX8sJxCVPWUmxNUoL3tScwlSpd7Bxw0q9c/X01jMwuQ0+Va358zgFiGERTE6yD01eu40OBDXOYO3z++y+TAYHlQQ2toMO63trepo88X3xV3R44/1DH+k2pAm2IF5ixiLrAgMBAAECggEAPx3SoXcseaD7rmcGcE0p4SMfbsUDdkUSmBBbtfF0GzwnqNLkWa+mgE0rWt9SmXngTQH97vByAYmLPl1s3G82ht1V7Sk7yQMe74lhFllr8eEyTjeVx3dTK1EEM4TwN+936DTXdFsr4TELJEcJJdD0KaxcCcfBLRDs2wnitEFZ9N+GoZybVmY8w0e0MI7PLObUZ2l0X4RurQnfG9ZxjXjC7PkeMVv7cGGylpNFi3BbvkRhdhLPDC2E6wqnr9e7zk+hiENivAezXrtxtwKovzCtnWJ1r0IO14Rh47H509Ic0wFnj+o5YyUL4LdmpL7yaaH6fM7zcSLFjNZPHvZCKPwYcQKBgQDQFho98QvnL8ex4v6cry4VitGpjSXm1qP3vmMQk4rTsn8iPWtcxPjqGEqOQJjdi4Mi0VZKQOLFwlH0kl95wNrD/isJ4O1yeYfX7YAXApzHqYNINzM79HemO3Yx1qLMW3okRFJ9pPRzbQ9qkTpsaegsmyX316zOBhzGRYjKbutTYwKBgQCm7phr9XdFW5Vh+XR90mVs483nrLmMiDKg7YKxSLJ8amiDjzPejCn7i95Hah08P+2MIZLIPbh2VLacczR6ltRRzN5bg5etFuqSgfkuHyxpoDmpjbe08+Q2h8JBYqcC5Nhv1AKU4iOUhVLHo/FBAQliMcGc/J3eiYTFC7EsNx382QKBgClb20doe7cttgFTXswBvaUmfFm45kmla924B7SpvrQpDD/f+VDtDZRp05fGmxuduSjYdtA3aVtpLiTwWu22OUUvZZqHDGruYOO4Hvdz23mL5b4ayqImCwoNU4bAZIc9v18p/UNf3/55NNE3oGcf/bev9rH2OjCQ4nM+Ktwhg8CFAoGACSgvbkShzUkv0ZcIf9ppu+ZnJh1AdGgINvGwaJ8vQ0nm/8h8NOoFZ4oNoGc+wU5Ubops7dUM6FjPR5e+OjdJ4E7Xp7d5O4J1TaIZlCEbo5OpdhaTDDcQvrkFu+Z4eN0qzj+YAKjDAOOrXc4tbr5q0FsgXscwtcNfaBuzFVTUrUkCgYEAwzPnMNhWG3zOWLUs2QFA2GP4Y+J8cpUYfj6pbKKzeLwyG9qBwF1NJpN8m+q9q7V9P2LY+9Lp9e1mGsGeqt5HMEA3P6vIpcqLJLqE/4PBLLRzfccTcmqb1m71+erxTRhHBRkGS+I7dZEb3olQfnS1Y1tpMBxiwYwR3LW4oXuJwj8="); + config.setAlipayPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnq90KnF4dTnlzzmxpujbI05OYqi5WxAS6cL0gnZFv2gK51HExF8v/BaP7P979PhFMgWTqmOOI+Dtno5s+yD09XTY1WkshbLk6i4g2Xlr8fyW9ODnkU88RI2w9UdPhQU4cPPwBNlrsYhKkVK2OxwM3kFqjoBBY0CZoZCsSQ3LDH5WeZqPArlsS6xa2zqJBuuoKjMrdpELl3eXSjP8K54eDJCbeetCZNKWLL3DPahTPB7LZikfYmslb0QUvCgGapD0xkS7eVq70NaL1G57MWABs4tbfWgxike4Daj3EfUrzIVspQxj7w8HEj9WozJPgL88kSJSits0pqD3n5r8HSuseQIDAQAB"); + // 创建客户端 + Long channelId = RandomUtil.randomLong(); + payClientFactory.createOrUpdatePayClient(channelId, PayChannelEnum.ALIPAY_WAP.getCode(), config); + PayClient client = payClientFactory.getPayClient(channelId); + // 发起支付 + PayOrderUnifiedReqDTO reqDTO = buildPayOrderUnifiedReqDTO(); +// CommonResult result = client.unifiedOrder(reqDTO); +// System.out.println(JsonUtils.toJsonString(result)); + } + + private static PayOrderUnifiedReqDTO buildPayOrderUnifiedReqDTO() { + PayOrderUnifiedReqDTO reqDTO = new PayOrderUnifiedReqDTO(); + reqDTO.setPrice(123); + reqDTO.setSubject("IPhone 13"); + reqDTO.setBody("biubiubiu"); + reqDTO.setOutTradeNo(String.valueOf(System.currentTimeMillis())); + reqDTO.setUserIp("127.0.0.1"); + reqDTO.setNotifyUrl("http://127.0.0.1:8080"); + return reqDTO; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java new file mode 100644 index 000000000..8af1e1bb4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java @@ -0,0 +1,222 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.RandomUtil; +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundRespDTO; +import com.cf.imes.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.exception.PayException; +import com.cf.imes.framework.pay.core.enums.refund.PayRefundStatusRespEnum; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import com.alipay.api.AlipayApiException; +import com.alipay.api.DefaultAlipayClient; +import com.alipay.api.DefaultSigner; +import com.alipay.api.domain.AlipayTradeRefundModel; +import com.alipay.api.request.AlipayTradeRefundRequest; +import com.alipay.api.response.AlipayTradeRefundResponse; +import lombok.Setter; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.Mock; + +import javax.validation.ConstraintViolationException; +import java.util.Date; + +import static com.cf.imes.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_PUBLIC_KEY; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * 支付宝 Client 的测试基类 + * + * @author jason + */ +public abstract class AbstractAlipayClientTest extends BaseMockitoUnitTest { + + protected AlipayPayClientConfig config = randomPojo(AlipayPayClientConfig.class, o -> { + o.setServerUrl(randomURL()); + o.setPrivateKey(randomString()); + o.setMode(MODE_PUBLIC_KEY); + o.setSignType(AlipayPayClientConfig.SIGN_TYPE_DEFAULT); + o.setAppCertContent(""); + o.setAlipayPublicCertContent(""); + o.setRootCertContent(""); + }); + + @Mock + protected DefaultAlipayClient defaultAlipayClient; + + @Setter + private AbstractAlipayPayClient client; + + /** + * 子类需要实现该方法. 设置 client 的具体实现 + */ + @BeforeEach + public abstract void setUp(); + + @Test + @DisplayName("支付宝 Client 初始化") + public void testDoInit() { + // 调用 + client.doInit(); + // 断言 + DefaultAlipayClient realClient = client.getClient(); + assertNotSame(defaultAlipayClient, realClient); + assertInstanceOf(DefaultSigner.class, realClient.getSigner()); + assertEquals(config.getPrivateKey(), ((DefaultSigner) realClient.getSigner()).getPrivateKey()); + } + + @Test + @DisplayName("支付宝 Client 统一退款:成功") + public void testUnifiedRefund_success() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + Date refundTime = randomDate(); + String outRefundNo = randomString(); + String outTradeNo = randomString(); + Integer refundAmount = randomInteger(); + AlipayTradeRefundResponse response = randomPojo(AlipayTradeRefundResponse.class, o -> { + o.setSubCode(""); + o.setGmtRefundPay(refundTime); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradeRefundModel.class, request.getBizModel()); + AlipayTradeRefundModel bizModel = (AlipayTradeRefundModel) request.getBizModel(); + assertEquals(outRefundNo, bizModel.getOutRequestNo()); + assertEquals(outTradeNo, bizModel.getOutTradeNo()); + assertEquals(String.valueOf(refundAmount / 100.0), bizModel.getRefundAmount()); + return true; + }))).thenReturn(response); + // 准备请求参数 + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> { + o.setOutRefundNo(outRefundNo); + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + o.setRefundPrice(refundAmount); + }); + + // 调用 + PayRefundRespDTO resp = client.unifiedRefund(refundReqDTO); + // 断言 + Assertions.assertEquals(PayRefundStatusRespEnum.SUCCESS.getStatus(), resp.getStatus()); + assertEquals(outRefundNo, resp.getOutRefundNo()); + assertNull(resp.getChannelRefundNo()); + assertEquals(LocalDateTimeUtil.of(refundTime), resp.getSuccessTime()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 Client 统一退款:渠道返回失败") + public void test_unified_refund_channel_failed() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradeRefundResponse response = randomPojo(AlipayTradeRefundResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradeRefundModel.class, request.getBizModel()); + return true; + }))).thenReturn(response); + // 准备请求参数 + String outRefundNo = randomString(); + String outTradeNo = randomString(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> { + o.setOutRefundNo(outRefundNo); + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + }); + + // 调用 + PayRefundRespDTO resp = client.unifiedRefund(refundReqDTO); + // 断言 + assertEquals(PayRefundStatusRespEnum.FAILURE.getStatus(), resp.getStatus()); + assertEquals(outRefundNo, resp.getOutRefundNo()); + assertNull(resp.getChannelRefundNo()); + assertNull(resp.getSuccessTime()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 Client 统一退款:参数校验不通过") + public void testUnifiedRefund_paramInvalidate() { + // 准备请求参数 + String notifyUrl = randomURL(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> { + o.setOutTradeNo(""); + o.setNotifyUrl(notifyUrl); + }); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> client.unifiedRefund(refundReqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一退款:抛出业务异常") + public void testUnifiedRefund_throwServiceException() throws AlipayApiException { + // mock 方法 + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenThrow(ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR)); + // 准备请求参数 + String notifyUrl = randomURL(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> o.setNotifyUrl(notifyUrl)); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> client.unifiedRefund(refundReqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一退款:抛出系统异常") + public void testUnifiedRefund_throwPayException() throws AlipayApiException { + // mock 方法 + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenThrow(new RuntimeException("系统异常")); + // 准备请求参数 + String notifyUrl = randomURL(); + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> o.setNotifyUrl(notifyUrl)); + + // 调用,并断言 + assertThrows(PayException.class, () -> client.unifiedRefund(refundReqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一下单:参数校验不通过") + public void testUnifiedOrder_paramInvalidate() { + // 准备请求参数 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + PayOrderUnifiedReqDTO reqDTO = randomPojo(PayOrderUnifiedReqDTO.class, o -> { + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + }); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> client.unifiedOrder(reqDTO)); + } + + protected PayOrderUnifiedReqDTO buildOrderUnifiedReqDTO(String notifyUrl, String outTradeNo, Integer price) { + return randomPojo(PayOrderUnifiedReqDTO.class, o -> { + o.setOutTradeNo(outTradeNo); + o.setNotifyUrl(notifyUrl); + o.setPrice(price); + o.setSubject(RandomUtil.randomString(32)); + o.setBody(RandomUtil.randomString(32)); + }); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayBarPayClientTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayBarPayClientTest.java new file mode 100644 index 000000000..c12662672 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayBarPayClientTest.java @@ -0,0 +1,169 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.core.date.LocalDateTimeUtil; +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradePayModel; +import com.alipay.api.request.AlipayTradePayRequest; +import com.alipay.api.response.AlipayTradePayResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum.CLOSED; +import static com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum.WAITING; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayBarPayClient} 单元测试 + * + * @author jason + */ +public class AlipayBarPayClientTest extends AbstractAlipayClientTest { + + @InjectMocks + private AlipayBarPayClient client = new AlipayBarPayClient(randomLongId(), config); + + @Override + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝条码支付:非免密码支付下单成功") + public void testUnifiedOrder_success() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + Integer price = randomInteger(); + String authCode = randomString(); + AlipayTradePayResponse response = randomPojo(AlipayTradePayResponse.class, o -> o.setSubCode("")); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradePayModel.class, request.getBizModel()); + assertEquals(notifyUrl, request.getNotifyUrl()); + AlipayTradePayModel model = (AlipayTradePayModel) request.getBizModel(); + assertEquals(outTradeNo, model.getOutTradeNo()); + assertEquals(String.valueOf(price / 100.0), model.getTotalAmount()); + assertEquals(authCode, model.getAuthCode()); + return true; + }))).thenReturn(response); + // 准备请求参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + Map extraParam = new HashMap<>(); + extraParam.put("auth_code", authCode); + reqDTO.setChannelExtras(extraParam); + + // 调用方法 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.BAR_CODE.getMode(), resp.getDisplayMode()); + assertEquals("", resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝条码支付:免密码支付下单成功") + public void testUnifiedOrder_code10000Success() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String channelNo = randomString(); + String channelUserId = randomString(); + Date payTime = randomDate(); + AlipayTradePayResponse response = randomPojo(AlipayTradePayResponse.class, o -> { + o.setSubCode(""); + o.setCode("10000"); + o.setOutTradeNo(outTradeNo); + o.setTradeNo(channelNo); + o.setBuyerUserId(channelUserId); + o.setGmtPayment(payTime); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenReturn(response); + // 准备请求参数 + String authCode = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + Map extraParam = new HashMap<>(); + extraParam.put("auth_code", authCode); + reqDTO.setChannelExtras(extraParam); + + // 下单请求 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(PayOrderStatusRespEnum.SUCCESS.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertEquals(channelNo, resp.getChannelOrderNo()); + assertEquals(channelUserId, resp.getChannelUserId()); + assertEquals(LocalDateTimeUtil.of(payTime), resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.BAR_CODE.getMode(), resp.getDisplayMode()); + assertEquals("", resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝条码支付:没有传条码") + public void testUnifiedOrder_emptyAuthCode() { + // 准备参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), randomString(), randomInteger()); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> client.unifiedOrder(reqDTO)); + } + + @Test + @DisplayName("支付宝条码支付:渠道返回失败") + public void test_unified_order_channel_failed() throws AlipayApiException { + // mock 方法 + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradePayResponse response = randomPojo(AlipayTradePayResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> true))) + .thenReturn(response); + // 准备请求参数 + String authCode = randomString(); + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + Map extraParam = new HashMap<>(); + extraParam.put("auth_code", authCode); + reqDTO.setChannelExtras(extraParam); + + // 调用方法 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPcPayClientTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPcPayClientTest.java new file mode 100644 index 000000000..fbe8631ba --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayPcPayClientTest.java @@ -0,0 +1,129 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.http.Method; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.request.AlipayTradePagePayRequest; +import com.alipay.api.response.AlipayTradePagePayResponse; +import com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayPcPayClient} 单元测试 + * + * @author jason + */ +public class AlipayPcPayClientTest extends AbstractAlipayClientTest { + + @InjectMocks + private AlipayPcPayClient client = new AlipayPcPayClient(randomLongId(), config); + + @Override + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝 PC 网站支付:URL Display Mode 下单成功") + public void testUnifiedOrder_urlSuccess() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + AlipayTradePagePayResponse response = randomPojo(AlipayTradePagePayResponse.class, o -> o.setSubCode("")); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.GET.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + Integer price = randomInteger(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + reqDTO.setDisplayMode(null); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + Assertions.assertEquals(PayOrderStatusRespEnum.WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + Assertions.assertEquals(PayOrderDisplayModeEnum.URL.getMode(), resp.getDisplayMode()); + assertEquals(response.getBody(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 PC 网站支付:Form Display Mode 下单成功") + public void testUnifiedOrder_formSuccess() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + AlipayTradePagePayResponse response = randomPojo(AlipayTradePagePayResponse.class, o -> o.setSubCode("")); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.POST.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + Integer price = randomInteger(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + reqDTO.setDisplayMode(PayOrderDisplayModeEnum.FORM.getMode()); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + Assertions.assertEquals(PayOrderStatusRespEnum.WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.FORM.getMode(), resp.getDisplayMode()); + assertEquals(response.getBody(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 PC 网站支付:渠道返回失败") + public void testUnifiedOrder_channelFailed() throws AlipayApiException { + // mock 方法 + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradePagePayResponse response = randomPojo(AlipayTradePagePayResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.GET.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + reqDTO.setDisplayMode(PayOrderDisplayModeEnum.URL.getMode()); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + Assertions.assertEquals(PayOrderStatusRespEnum.CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayQrPayClientTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayQrPayClientTest.java new file mode 100644 index 000000000..3e24c8f94 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayQrPayClientTest.java @@ -0,0 +1,146 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.cf.imes.framework.common.exception.util.ServiceExceptionUtil; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.client.exception.PayException; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.request.AlipayTradePrecreateRequest; +import com.alipay.api.response.AlipayTradePrecreateResponse; +import com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayQrPayClient} 单元测试 + * + * @author jason + */ +public class AlipayQrPayClientTest extends AbstractAlipayClientTest { + + @InjectMocks + private AlipayQrPayClient client = new AlipayQrPayClient(randomLongId(), config); + + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝扫描支付:下单成功") + public void testUnifiedOrder_success() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + String qrCode = randomString(); + Integer price = randomInteger(); + AlipayTradePrecreateResponse response = randomPojo(AlipayTradePrecreateResponse.class, o -> { + o.setQrCode(qrCode); + o.setSubCode(""); + }); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + Assertions.assertEquals(PayOrderStatusRespEnum.WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + Assertions.assertEquals(PayOrderDisplayModeEnum.QR_CODE.getMode(), resp.getDisplayMode()); + assertEquals(response.getQrCode(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝扫描支付:渠道返回失败") + public void testUnifiedOrder_channelFailed() throws AlipayApiException { + // mock 方法 + String notifyUrl = randomURL(); + String subCode = randomString(); + String subMsg = randomString(); + Integer price = randomInteger(); + AlipayTradePrecreateResponse response = randomPojo(AlipayTradePrecreateResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + // mock + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + Assertions.assertEquals(PayOrderStatusRespEnum.CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝扫描支付, 抛出系统异常") + public void testUnifiedOrder_throwPayException() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + Integer price = randomInteger(); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenThrow(new RuntimeException("系统异常")); + // 准备请求参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo,price); + + // 调用,并断言 + assertThrows(PayException.class, () -> client.unifiedOrder(reqDTO)); + } + + @Test + @DisplayName("支付宝 Client 统一下单:抛出业务异常") + public void testUnifiedOrder_throwServiceException() throws AlipayApiException { + // mock 方法 + String outTradeNo = randomString(); + String notifyUrl = randomURL(); + Integer price = randomInteger(); + when(defaultAlipayClient.execute(argThat((ArgumentMatcher) request -> { + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }))).thenThrow(ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR)); + // 准备请求参数 + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> client.unifiedOrder(reqDTO)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayWapPayClientTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayWapPayClientTest.java new file mode 100644 index 000000000..8313a4d9f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/alipay/AlipayWapPayClientTest.java @@ -0,0 +1,109 @@ +package com.cf.imes.framework.pay.core.client.impl.alipay; + +import cn.hutool.http.Method; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderRespDTO; +import com.cf.imes.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import com.cf.imes.framework.pay.core.enums.order.PayOrderDisplayModeEnum; +import com.alipay.api.AlipayApiException; +import com.alipay.api.domain.AlipayTradeWapPayModel; +import com.alipay.api.request.AlipayTradeWapPayRequest; +import com.alipay.api.response.AlipayTradeWapPayResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum.CLOSED; +import static com.cf.imes.framework.pay.core.enums.order.PayOrderStatusRespEnum.WAITING; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link AlipayWapPayClient} 单元测试 + * + * @author jason + */ +public class AlipayWapPayClientTest extends AbstractAlipayClientTest { + + /** + * 支付宝 H5 支付 Client + */ + @InjectMocks + private AlipayWapPayClient client = new AlipayWapPayClient(randomLongId(), config); + + @BeforeEach + public void setUp() { + setClient(client); + } + + @Test + @DisplayName("支付宝 H5 支付:下单成功") + public void testUnifiedOrder_success() throws AlipayApiException { + // mock 方法 + String h5Body = randomString(); + Integer price = randomInteger(); + AlipayTradeWapPayResponse response = randomPojo(AlipayTradeWapPayResponse.class, o -> { + o.setSubCode(""); + o.setBody(h5Body); + }); + String notifyUrl = randomURL(); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> { + assertInstanceOf(AlipayTradeWapPayModel.class, request.getBizModel()); + AlipayTradeWapPayModel bizModel = (AlipayTradeWapPayModel) request.getBizModel(); + assertEquals(String.valueOf(price / 100.0), bizModel.getTotalAmount()); + assertEquals(notifyUrl, request.getNotifyUrl()); + return true; + }), eq(Method.GET.name()))).thenReturn(response); + // 准备请求参数 + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(notifyUrl, outTradeNo, price); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(WAITING.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertEquals(PayOrderDisplayModeEnum.URL.getMode(), resp.getDisplayMode()); + assertEquals(response.getBody(), resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertNull(resp.getChannelErrorCode()); + assertNull(resp.getChannelErrorMsg()); + } + + @Test + @DisplayName("支付宝 H5 支付:渠道返回失败") + public void test_unified_order_channel_failed() throws AlipayApiException { + // mock 方法 + String subCode = randomString(); + String subMsg = randomString(); + AlipayTradeWapPayResponse response = randomPojo(AlipayTradeWapPayResponse.class, o -> { + o.setSubCode(subCode); + o.setSubMsg(subMsg); + }); + when(defaultAlipayClient.pageExecute(argThat((ArgumentMatcher) request -> true), + eq(Method.GET.name()))).thenReturn(response); + String outTradeNo = randomString(); + PayOrderUnifiedReqDTO reqDTO = buildOrderUnifiedReqDTO(randomURL(), outTradeNo, randomInteger()); + + // 调用 + PayOrderRespDTO resp = client.unifiedOrder(reqDTO); + // 断言 + assertEquals(CLOSED.getStatus(), resp.getStatus()); + assertEquals(outTradeNo, resp.getOutTradeNo()); + assertNull(resp.getChannelOrderNo()); + assertNull(resp.getChannelUserId()); + assertNull(resp.getSuccessTime()); + assertNull(resp.getDisplayMode()); + assertNull(resp.getDisplayContent()); + assertSame(response, resp.getRawData()); + assertEquals(subCode, resp.getChannelErrorCode()); + assertEquals(subMsg, resp.getChannelErrorMsg()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxBarPayClientIntegrationTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxBarPayClientIntegrationTest.java new file mode 100644 index 000000000..ee38d68c8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxBarPayClientIntegrationTest.java @@ -0,0 +1,121 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import com.cf.imes.framework.common.util.date.LocalDateTimeUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.github.binarywang.wxpay.bean.notify.WxPayRefundNotifyResult; +import com.github.binarywang.wxpay.bean.request.WxPayMicropayRequest; +import com.github.binarywang.wxpay.bean.request.WxPayRefundRequest; +import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayMicropayResult; +import com.github.binarywang.wxpay.bean.result.WxPayRefundResult; +import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +/** + * {@link WxBarPayClient} 的集成测试,用于快速调试微信条码支付 + * + * @author 晨丰科技 + */ +@Disabled +public class WxBarPayClientIntegrationTest { + + @Test + public void testPayV2() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起支付 + WxPayMicropayRequest request = WxPayMicropayRequest.newBuilder() + .outTradeNo(String.valueOf(System.currentTimeMillis())) + .body("测试支付-body") + .detail("测试支付-detail") + .totalFee(1) // 单位分 + .timeExpire(AbstractWxPayClient.formatDateV2(LocalDateTimeUtils.addTime(Duration.ofMinutes(2)))) + .spbillCreateIp("127.0.0.1") + .authCode("134298744426278497") + .build(); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayMicropayResult response = client.micropay(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + @Test + public void testParseRefundNotifyV2() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行解析 + String xml = "SUCCESS"; + WxPayRefundNotifyResult response = client.parseRefundNotifyResult(xml); + System.out.println(response.getReqInfo()); + } + + @Test + public void testRefundV2() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起退款 + WxPayRefundRequest request = new WxPayRefundRequest() + .setOutTradeNo("1689545667276") + .setOutRefundNo(String.valueOf(System.currentTimeMillis())) + .setRefundFee(1) + .setRefundDesc("就是想退了") + .setTotalFee(1); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayRefundResult response = client.refund(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + @Test + public void testRefundV3() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV2(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起退款 + WxPayRefundV3Request request = new WxPayRefundV3Request() + .setOutTradeNo("1689506325635") + .setOutRefundNo(String.valueOf(System.currentTimeMillis())) + .setAmount(new WxPayRefundV3Request.Amount().setTotal(1).setRefund(1).setCurrency("CNY")) + .setReason("就是想退了"); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayRefundV3Result response = client.refundV3(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + private WxPayConfig buildWxPayConfigV2() { + WxPayConfig config = new WxPayConfig(); + config.setAppId("wx62056c0d5e8db250"); + config.setMchId("1545083881"); + config.setMchKey("dS1ngeN63JLr3NRbvPH9AJy3MyUxZdim"); +// config.setSignType(WxPayConstants.SignType.MD5); + config.setKeyPath("/Users/yunai/Downloads/wx_pay/apiclient_cert.p12"); + return config; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxNativePayClientIntegrationTest.java b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxNativePayClientIntegrationTest.java new file mode 100644 index 000000000..35141fab4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-pay/src/test/java/com/cf/imes/framework/pay/core/client/impl/weixin/WxNativePayClientIntegrationTest.java @@ -0,0 +1,81 @@ +package com.cf.imes.framework.pay.core.client.impl.weixin; + +import com.cf.imes.framework.common.util.date.LocalDateTimeUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderV3Request; +import com.github.binarywang.wxpay.bean.result.WxPayRefundV3Result; +import com.github.binarywang.wxpay.bean.result.enums.TradeTypeEnum; +import com.github.binarywang.wxpay.config.WxPayConfig; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +/** + * {@link WxNativePayClient} 的集成测试,用于快速调试微信扫码支付 + * + * @author 晨丰科技 + */ +@Disabled +public class WxNativePayClientIntegrationTest { + + @Test + public void testPayV3() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV3(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起支付 + WxPayUnifiedOrderV3Request request = new WxPayUnifiedOrderV3Request() + .setOutTradeNo(String.valueOf(System.currentTimeMillis())) + .setDescription("测试支付-body") + .setAmount(new WxPayUnifiedOrderV3Request.Amount().setTotal(1)) // 单位分 + .setTimeExpire(AbstractWxPayClient.formatDateV3(LocalDateTimeUtils.addTime(Duration.ofMinutes(2)))) + .setSceneInfo(new WxPayUnifiedOrderV3Request.SceneInfo().setPayerClientIp("127.0.0.1")) + .setNotifyUrl("http://127.0.0.1:48080"); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + String response = client.createOrderV3(TradeTypeEnum.NATIVE, request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + @Test + public void testRefundV3() throws WxPayException { + // 创建 config 配置 + WxPayConfig config = buildWxPayConfigV3(); + // 创建 WxPayService 客户端 + WxPayService client = new WxPayServiceImpl(); + client.setConfig(config); + + // 执行发起退款 + WxPayRefundV3Request request = new WxPayRefundV3Request() + .setOutTradeNo("1689545729695") + .setOutRefundNo(String.valueOf(System.currentTimeMillis())) + .setAmount(new WxPayRefundV3Request.Amount().setTotal(1).setRefund(1).setCurrency("CNY")) + .setReason("就是想退了"); + System.out.println("========= request =========="); + System.out.println(JsonUtils.toJsonPrettyString(request)); + WxPayRefundV3Result response = client.refundV3(request); + System.out.println("========= response =========="); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + + private WxPayConfig buildWxPayConfigV3() { + WxPayConfig config = new WxPayConfig(); + config.setAppId("wx62056c0d5e8db250"); + config.setMchId("1545083881"); + config.setApiV3Key("459arNsYHl1mgkiO6H9ZH5KkhFXSxaA4"); +// config.setCertSerialNo(serialNo); + config.setPrivateCertPath("/Users/yunai/Downloads/wx_pay/apiclient_cert.pem"); + config.setPrivateKeyPath("/Users/yunai/Downloads/wx_pay/apiclient_key.pem"); + return config; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/pom.xml b/cf-framework/cf-spring-boot-starter-biz-sms/pom.xml new file mode 100644 index 000000000..e0bb76ef8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/pom.xml @@ -0,0 +1,81 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-biz-sms + jar + + ${project.artifactId} + 短信拓展,支持阿里云、腾讯云 + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter + + + + + io.opentracing + opentracing-util + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + + + com.google.guava + guava + true + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + + jakarta.validation + jakarta.validation-api + + + + + + + com.aliyun + aliyun-java-sdk-core + + + com.aliyun + aliyun-java-sdk-dysmsapi + + + com.tencentcloudapi + tencentcloud-sdk-java-sms + + + + + diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/config/ChenfengSmsAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/config/ChenfengSmsAutoConfiguration.java new file mode 100644 index 000000000..cd1ad245f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/config/ChenfengSmsAutoConfiguration.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.sms.config; + +import com.cf.imes.framework.sms.core.client.SmsClientFactory; +import com.cf.imes.framework.sms.core.client.impl.SmsClientFactoryImpl; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * 短信配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +public class ChenfengSmsAutoConfiguration { + + @Bean + public SmsClientFactory smsClientFactory() { + return new SmsClientFactoryImpl(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/SmsClient.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/SmsClient.java new file mode 100644 index 000000000..ae9d8ba0c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/SmsClient.java @@ -0,0 +1,54 @@ +package com.cf.imes.framework.sms.core.client; + +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO; + +import java.util.List; + +/** + * 短信客户端,用于对接各短信平台的 SDK,实现短信发送等功能 + * + * @author zzf + * @since 2021/1/25 14:14 + */ +public interface SmsClient { + + /** + * 获得渠道编号 + * + * @return 渠道编号 + */ + Long getId(); + + /** + * 发送消息 + * + * @param logId 日志编号 + * @param mobile 手机号 + * @param apiTemplateId 短信 API 的模板编号 + * @param templateParams 短信模板参数。通过 List 数组,保证参数的顺序 + * @return 短信发送结果 + */ + SmsSendRespDTO sendSms(Long logId, String mobile, String apiTemplateId, + List> templateParams) throws Throwable; + + /** + * 解析接收短信的接收结果 + * + * @param text 结果 + * @return 结果内容 + * @throws Throwable 当解析 text 发生异常时,则会抛出异常 + */ + List parseSmsReceiveStatus(String text) throws Throwable; + + /** + * 查询指定的短信模板 + * + * @param apiTemplateId 短信 API 的模板编号 + * @return 短信模板 + */ + SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/SmsClientFactory.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/SmsClientFactory.java new file mode 100644 index 000000000..b6d6b04e9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/SmsClientFactory.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.sms.core.client; + +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; + +/** + * 短信客户端的工厂接口 + * + * @author zzf + * @since 2021/1/28 14:01 + */ +public interface SmsClientFactory { + + /** + * 获得短信 Client + * + * @param channelId 渠道编号 + * @return 短信 Client + */ + SmsClient getSmsClient(Long channelId); + + /** + * 获得短信 Client + * + * @param channelCode 渠道编码 + * @return 短信 Client + */ + SmsClient getSmsClient(String channelCode); + + /** + * 创建短信 Client + * + * @param properties 配置对象 + */ + void createOrUpdateSmsClient(SmsChannelProperties properties); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsReceiveRespDTO.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsReceiveRespDTO.java new file mode 100644 index 000000000..7c2fbf616 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsReceiveRespDTO.java @@ -0,0 +1,48 @@ +package com.cf.imes.framework.sms.core.client.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 消息接收 Response DTO + * + * @author 晨丰科技 + */ +@Data +public class SmsReceiveRespDTO { + + /** + * 是否接收成功 + */ + private Boolean success; + /** + * API 接收结果的编码 + */ + private String errorCode; + /** + * API 接收结果的说明 + */ + private String errorMsg; + + /** + * 手机号 + */ + private String mobile; + /** + * 用户接收时间 + */ + private LocalDateTime receiveTime; + + /** + * 短信 API 发送返回的序号 + */ + private String serialNo; + /** + * 短信日志编号 + * + * 对应 SysSmsLogDO 的编号 + */ + private Long logId; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsSendRespDTO.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsSendRespDTO.java new file mode 100644 index 000000000..6891cbdb1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsSendRespDTO.java @@ -0,0 +1,43 @@ +package com.cf.imes.framework.sms.core.client.dto; + +import lombok.Data; + +/** + * 短信发送 Response DTO + * + * @author 晨丰科技 + */ +@Data +public class SmsSendRespDTO { + + /** + * 是否成功 + */ + private Boolean success; + + /** + * API 请求编号 + */ + private String apiRequestId; + + // ==================== 成功时字段 ==================== + + /** + * 短信 API 发送返回的序号 + */ + private String serialNo; + + // ==================== 失败时字段 ==================== + + /** + * API 返回错误码 + * + * 由于第三方的错误码可能是字符串,所以使用 String 类型 + */ + private String apiCode; + /** + * API 返回提示 + */ + private String apiMsg; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsTemplateRespDTO.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsTemplateRespDTO.java new file mode 100644 index 000000000..6857de734 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/dto/SmsTemplateRespDTO.java @@ -0,0 +1,33 @@ +package com.cf.imes.framework.sms.core.client.dto; + +import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import lombok.Data; + +/** + * 短信模板 Response DTO + * + * @author 晨丰科技 + */ +@Data +public class SmsTemplateRespDTO { + + /** + * 模板编号 + */ + private String id; + /** + * 短信内容 + */ + private String content; + /** + * 审核状态 + * + * 枚举 {@link SmsTemplateAuditStatusEnum} + */ + private Integer auditStatus; + /** + * 审核未通过的理由 + */ + private String auditReason; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/AbstractSmsClient.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/AbstractSmsClient.java new file mode 100644 index 000000000..df1ff5bcb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/AbstractSmsClient.java @@ -0,0 +1,54 @@ +package com.cf.imes.framework.sms.core.client.impl; + +import com.cf.imes.framework.sms.core.client.SmsClient; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; +import lombok.extern.slf4j.Slf4j; + +/** + * 短信客户端的抽象类,提供模板方法,减少子类的冗余代码 + * + * @author zzf + * @since 2021/2/1 9:28 + */ +@Slf4j +public abstract class AbstractSmsClient implements SmsClient { + + /** + * 短信渠道配置 + */ + protected volatile SmsChannelProperties properties; + + public AbstractSmsClient(SmsChannelProperties properties) { + this.properties = properties; + } + + /** + * 初始化 + */ + public final void init() { + doInit(); + log.debug("[init][配置({}) 初始化完成]", properties); + } + + /** + * 自定义初始化 + */ + protected abstract void doInit(); + + public final void refresh(SmsChannelProperties properties) { + // 判断是否更新 + if (properties.equals(this.properties)) { + return; + } + log.info("[refresh][配置({})发生变化,重新初始化]", properties); + this.properties = properties; + // 初始化 + this.init(); + } + + @Override + public Long getId() { + return properties.getId(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/SmsClientFactoryImpl.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/SmsClientFactoryImpl.java new file mode 100644 index 000000000..a5779e0da --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/SmsClientFactoryImpl.java @@ -0,0 +1,90 @@ +package com.cf.imes.framework.sms.core.client.impl; + +import com.cf.imes.framework.sms.core.client.SmsClient; +import com.cf.imes.framework.sms.core.client.SmsClientFactory; +import com.cf.imes.framework.sms.core.client.impl.aliyun.AliyunSmsClient; +import com.cf.imes.framework.sms.core.client.impl.debug.DebugDingTalkSmsClient; +import com.cf.imes.framework.sms.core.client.impl.tencent.TencentSmsClient; +import com.cf.imes.framework.sms.core.enums.SmsChannelEnum; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.Assert; +import org.springframework.validation.annotation.Validated; + +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 短信客户端工厂接口 + * + * @author zzf + */ +@Validated +@Slf4j +public class SmsClientFactoryImpl implements SmsClientFactory { + + /** + * 短信客户端 Map + * key:渠道编号,使用 {@link SmsChannelProperties#getId()} + */ + private final ConcurrentMap channelIdClients = new ConcurrentHashMap<>(); + + /** + * 短信客户端 Map + * key:渠道编码,使用 {@link SmsChannelProperties#getCode()} ()} + * + * 注意,一些场景下,需要获得某个渠道类型的客户端,所以需要使用它。 + * 例如说,解析短信接收结果,是相对通用的,不需要使用某个渠道编号的 {@link #channelIdClients} + */ + private final ConcurrentMap channelCodeClients = new ConcurrentHashMap<>(); + + public SmsClientFactoryImpl() { + // 初始化 channelCodeClients 集合 + Arrays.stream(SmsChannelEnum.values()).forEach(channel -> { + // 创建一个空的 SmsChannelProperties 对象 + SmsChannelProperties properties = new SmsChannelProperties().setCode(channel.getCode()) + .setApiKey("default default").setApiSecret("default"); + // 创建 Sms 客户端 + AbstractSmsClient smsClient = createSmsClient(properties); + channelCodeClients.put(channel.getCode(), smsClient); + }); + } + + @Override + public SmsClient getSmsClient(Long channelId) { + return channelIdClients.get(channelId); + } + + @Override + public SmsClient getSmsClient(String channelCode) { + return channelCodeClients.get(channelCode); + } + + @Override + public void createOrUpdateSmsClient(SmsChannelProperties properties) { + AbstractSmsClient client = channelIdClients.get(properties.getId()); + if (client == null) { + client = this.createSmsClient(properties); + client.init(); + channelIdClients.put(client.getId(), client); + } else { + client.refresh(properties); + } + } + + private AbstractSmsClient createSmsClient(SmsChannelProperties properties) { + SmsChannelEnum channelEnum = SmsChannelEnum.getByCode(properties.getCode()); + Assert.notNull(channelEnum, String.format("渠道类型(%s) 为空", channelEnum)); + // 创建客户端 + switch (channelEnum) { + case ALIYUN: return new AliyunSmsClient(properties); + case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties); + case TENCENT: return new TencentSmsClient(properties); + } + // 创建失败,错误日志 + 抛出异常 + log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties); + throw new IllegalArgumentException(String.format("配置(%s) 找不到合适的客户端实现", properties)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/aliyun/AliyunSmsClient.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/aliyun/AliyunSmsClient.java new file mode 100644 index 000000000..2a4c063f1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/aliyun/AliyunSmsClient.java @@ -0,0 +1,183 @@ +package com.cf.imes.framework.sms.core.client.impl.aliyun; + +import cn.hutool.core.lang.Assert; +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.common.util.collection.MapUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO; +import com.cf.imes.framework.sms.core.client.impl.AbstractSmsClient; +import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; +import com.aliyuncs.DefaultAcsClient; +import com.aliyuncs.IAcsClient; +import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest; +import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateResponse; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; +import com.aliyuncs.profile.DefaultProfile; +import com.aliyuncs.profile.IClientProfile; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import static com.cf.imes.framework.common.util.collection.CollectionUtils.convertList; +import static com.cf.imes.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.cf.imes.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +/** + * 阿里短信客户端的实现类 + * + * @author zzf + * @since 2021/1/25 14:17 + */ +@Slf4j +public class AliyunSmsClient extends AbstractSmsClient { + + /** + * 调用成功 code + */ + public static final String API_CODE_SUCCESS = "OK"; + + /** + * REGION, 使用杭州 + */ + private static final String ENDPOINT = "cn-hangzhou"; + + /** + * 阿里云客户端 + */ + private volatile IAcsClient client; + + public AliyunSmsClient(SmsChannelProperties properties) { + super(properties); + Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空"); + Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空"); + } + + @Override + protected void doInit() { + IClientProfile profile = DefaultProfile.getProfile(ENDPOINT, properties.getApiKey(), properties.getApiSecret()); + client = new DefaultAcsClient(profile); + } + + @Override + public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId, + List> templateParams) throws Throwable { + // 构建请求 + SendSmsRequest request = new SendSmsRequest(); + request.setPhoneNumbers(mobile); + request.setSignName(properties.getSignature()); + request.setTemplateCode(apiTemplateId); + request.setTemplateParam(JsonUtils.toJsonString(MapUtils.convertMap(templateParams))); + request.setOutId(String.valueOf(sendLogId)); + // 执行请求 + SendSmsResponse response = client.getAcsResponse(request); + return new SmsSendRespDTO().setSuccess(Objects.equals(response.getCode(), API_CODE_SUCCESS)).setSerialNo(response.getBizId()) + .setApiRequestId(response.getRequestId()).setApiCode(response.getCode()).setApiMsg(response.getMessage()); + } + + @Override + public List parseSmsReceiveStatus(String text) { + List statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class); + return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(status.getSuccess()) + .setErrorCode(status.getErrCode()).setErrorMsg(status.getErrMsg()) + .setMobile(status.getPhoneNumber()).setReceiveTime(status.getReportTime()) + .setSerialNo(status.getBizId()).setLogId(Long.valueOf(status.getOutId()))); + } + + @Override + public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable { + // 构建请求 + QuerySmsTemplateRequest request = new QuerySmsTemplateRequest(); + request.setTemplateCode(apiTemplateId); + // 执行请求 + QuerySmsTemplateResponse response = client.getAcsResponse(request); + if (response.getTemplateStatus() == null) { + return null; + } + return new SmsTemplateRespDTO().setId(response.getTemplateCode()).setContent(response.getTemplateContent()) + .setAuditStatus(convertSmsTemplateAuditStatus(response.getTemplateStatus())).setAuditReason(response.getReason()); + } + + @VisibleForTesting + Integer convertSmsTemplateAuditStatus(Integer templateStatus) { + switch (templateStatus) { + case 0: return SmsTemplateAuditStatusEnum.CHECKING.getStatus(); + case 1: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus(); + case 2: return SmsTemplateAuditStatusEnum.FAIL.getStatus(); + default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus)); + } + } + + /** + * 短信接收状态 + * + * 参见 文档 + * + * @author 晨丰科技 + */ + @Data + public static class SmsReceiveStatus { + + /** + * 手机号 + */ + @JsonProperty("phone_number") + private String phoneNumber; + /** + * 发送时间 + */ + @JsonProperty("send_time") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime sendTime; + /** + * 状态报告时间 + */ + @JsonProperty("report_time") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime reportTime; + /** + * 是否接收成功 + */ + private Boolean success; + /** + * 状态报告说明 + */ + @JsonProperty("err_msg") + private String errMsg; + /** + * 状态报告编码 + */ + @JsonProperty("err_code") + private String errCode; + /** + * 发送序列号 + */ + @JsonProperty("biz_id") + private String bizId; + /** + * 用户序列号 + * + * 这里我们传递的是 SysSmsLogDO 的日志编号 + */ + @JsonProperty("out_id") + private String outId; + /** + * 短信长度,例如说 1、2、3 + * + * 140 字节算一条短信,短信长度超过 140 字节时会拆分成多条短信发送 + */ + @JsonProperty("sms_size") + private Integer smsSize; + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/debug/DebugDingTalkSmsClient.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/debug/DebugDingTalkSmsClient.java new file mode 100644 index 000000000..0144e54bc --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/debug/DebugDingTalkSmsClient.java @@ -0,0 +1,96 @@ +package com.cf.imes.framework.sms.core.client.impl.debug; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.crypto.digest.HmacAlgorithm; +import cn.hutool.http.HttpUtil; +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.common.util.collection.MapUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO; +import com.cf.imes.framework.sms.core.client.impl.AbstractSmsClient; +import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 基于钉钉 WebHook 实现的调试的短信客户端实现类 + * + * 考虑到省钱,我们使用钉钉 WebHook 模拟发送短信,方便调试。 + * + * @author 晨丰科技 + */ +public class DebugDingTalkSmsClient extends AbstractSmsClient { + + public DebugDingTalkSmsClient(SmsChannelProperties properties) { + super(properties); + Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空"); + Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空"); + } + + @Override + protected void doInit() { + } + + @Override + public SmsSendRespDTO sendSms(Long sendLogId, String mobile, + String apiTemplateId, List> templateParams) throws Throwable { + // 构建请求 + String url = buildUrl("robot/send"); + Map params = new HashMap<>(); + params.put("msgtype", "text"); + String content = String.format("【模拟短信】\n手机号:%s\n短信日志编号:%d\n模板参数:%s", + mobile, sendLogId, MapUtils.convertMap(templateParams)); + params.put("text", MapUtil.builder().put("content", content).build()); + // 执行请求 + String responseText = HttpUtil.post(url, JsonUtils.toJsonString(params)); + // 解析结果 + Map responseObj = JsonUtils.parseObject(responseText, Map.class); + String errorCode = MapUtil.getStr(responseObj, "errcode"); + return new SmsSendRespDTO().setSuccess(Objects.equals(errorCode, "0")).setSerialNo(StrUtil.uuid()) + .setApiCode(errorCode).setApiMsg(MapUtil.getStr(responseObj, "errorMsg")); + } + + /** + * 构建请求地址 + * + * 参见 文档 + * + * @param path 请求路径 + * @return 请求地址 + */ + @SuppressWarnings("SameParameterValue") + private String buildUrl(String path) { + // 生成 timestamp + long timestamp = System.currentTimeMillis(); + // 生成 sign + String secret = properties.getApiSecret(); + String stringToSign = timestamp + "\n" + secret; + byte[] signData = DigestUtil.hmac(HmacAlgorithm.HmacSHA256, StrUtil.bytes(secret)).digest(stringToSign); + String sign = Base64.encode(signData); + // 构建最终 URL + return String.format("https://oapi.dingtalk.com/%s?access_token=%s×tamp=%d&sign=%s", + path, properties.getApiKey(), timestamp, sign); + } + + @Override + public List parseSmsReceiveStatus(String text) { + throw new UnsupportedOperationException("模拟短信客户端,暂时无需解析回调"); + } + + @Override + public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) { + return new SmsTemplateRespDTO().setId(apiTemplateId).setContent("") + .setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(""); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/tencent/TencentSmsClient.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/tencent/TencentSmsClient.java new file mode 100644 index 000000000..98030b49d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/client/impl/tencent/TencentSmsClient.java @@ -0,0 +1,219 @@ +package com.cf.imes.framework.sms.core.client.impl.tencent; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.common.util.collection.ArrayUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO; +import com.cf.imes.framework.sms.core.client.impl.AbstractSmsClient; +import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.sms.v20210111.SmsClient; +import com.tencentcloudapi.sms.v20210111.models.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +import static com.cf.imes.framework.common.util.collection.CollectionUtils.convertList; +import static com.cf.imes.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.cf.imes.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +/** + * 腾讯云短信功能实现 + * + * 参见 文档 + * + * @author shiwp + */ +public class TencentSmsClient extends AbstractSmsClient { + + /** + * 调用成功 code + */ + public static final String API_CODE_SUCCESS = "Ok"; + + /** + * REGION,使用南京 + */ + private static final String ENDPOINT = "ap-nanjing"; + + /** + * 是否国际/港澳台短信: + * + * 0:表示国内短信。 + * 1:表示国际/港澳台短信。 + */ + private static final long INTERNATIONAL_CHINA = 0L; + + private SmsClient client; + + public TencentSmsClient(SmsChannelProperties properties) { + super(properties); + Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空"); + validateSdkAppId(properties); + } + + @Override + protected void doInit() { + // 实例化一个认证对象,入参需要传入腾讯云账户密钥对 secretId,secretKey + Credential credential = new Credential(getApiKey(), properties.getApiSecret()); + client = new SmsClient(credential, ENDPOINT); + } + + /** + * 参数校验腾讯云的 SDK AppId + * + * 原因是:腾讯云发放短信的时候,需要额外的参数 sdkAppId + * + * 解决方案:考虑到不破坏原有的 apiKey + apiSecret 的结构,所以将 secretId 拼接到 apiKey 字段中,格式为 "secretId sdkAppId"。 + * + * @param properties 配置 + */ + private static void validateSdkAppId(SmsChannelProperties properties) { + String combineKey = properties.getApiKey(); + Assert.notEmpty(combineKey, "apiKey 不能为空"); + String[] keys = combineKey.trim().split(" "); + Assert.isTrue(keys.length == 2, "腾讯云短信 apiKey 配置格式错误,请配置 为[secretId sdkAppId]"); + } + + private String getSdkAppId() { + return StrUtil.subAfter(properties.getApiKey(), " ", true); + } + + private String getApiKey() { + return StrUtil.subBefore(properties.getApiKey(), " ", true); + } + + @Override + public SmsSendRespDTO sendSms(Long sendLogId, String mobile, + String apiTemplateId, List> templateParams) throws Throwable { + // 构建请求 + SendSmsRequest request = new SendSmsRequest(); + request.setSmsSdkAppId(getSdkAppId()); + request.setPhoneNumberSet(new String[]{mobile}); + request.setSignName(properties.getSignature()); + request.setTemplateId(apiTemplateId); + request.setTemplateParamSet(ArrayUtils.toArray(templateParams, e -> String.valueOf(e.getValue()))); + request.setSessionContext(JsonUtils.toJsonString(new SessionContext().setLogId(sendLogId))); + // 执行请求 + SendSmsResponse response = client.SendSms(request); + SendStatus status = response.getSendStatusSet()[0]; + return new SmsSendRespDTO().setSuccess(Objects.equals(status.getCode(), API_CODE_SUCCESS)).setSerialNo(status.getSerialNo()) + .setApiRequestId(response.getRequestId()).setApiCode(status.getCode()).setApiMsg(status.getMessage()); + } + + @Override + public List parseSmsReceiveStatus(String text) { + List callback = JsonUtils.parseArray(text, SmsReceiveStatus.class); + return convertList(callback, status -> new SmsReceiveRespDTO() + .setSuccess(SmsReceiveStatus.SUCCESS_CODE.equalsIgnoreCase(status.getStatus())) + .setErrorCode(status.getErrCode()).setErrorMsg(status.getDescription()) + .setMobile(status.getMobile()).setReceiveTime(status.getReceiveTime()) + .setSerialNo(status.getSerialNo()).setLogId(status.getSessionContext().getLogId())); + } + + @Override + public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable { + // 构建请求 + DescribeSmsTemplateListRequest request = new DescribeSmsTemplateListRequest(); + request.setTemplateIdSet(new Long[]{Long.parseLong(apiTemplateId)}); + request.setInternational(INTERNATIONAL_CHINA); + // 执行请求 + DescribeSmsTemplateListResponse response = client.DescribeSmsTemplateList(request); + DescribeTemplateListStatus status = response.getDescribeTemplateStatusSet()[0]; + if (status == null || status.getStatusCode() == null) { + return null; + } + return new SmsTemplateRespDTO().setId(status.getTemplateId().toString()).setContent(status.getTemplateContent()) + .setAuditStatus(convertSmsTemplateAuditStatus(status.getStatusCode().intValue())).setAuditReason(status.getReviewReply()); + } + + @VisibleForTesting + Integer convertSmsTemplateAuditStatus(int templateStatus) { + switch (templateStatus) { + case 1: return SmsTemplateAuditStatusEnum.CHECKING.getStatus(); + case 0: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus(); + case -1: return SmsTemplateAuditStatusEnum.FAIL.getStatus(); + default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus)); + } + } + + @Data + private static class SmsReceiveStatus { + + /** + * 短信接受成功 code + */ + public static final String SUCCESS_CODE = "SUCCESS"; + + /** + * 用户实际接收到短信的时间 + */ + @JsonProperty("user_receive_time") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime receiveTime; + + /** + * 国家(或地区)码 + */ + @JsonProperty("nationcode") + private String nationCode; + + /** + * 手机号码 + */ + private String mobile; + + /** + * 实际是否收到短信接收状态,SUCCESS(成功)、FAIL(失败) + */ + @JsonProperty("report_status") + private String status; + + /** + * 用户接收短信状态码错误信息 + */ + @JsonProperty("errmsg") + private String errCode; + + /** + * 用户接收短信状态描述 + */ + @JsonProperty("description") + private String description; + + /** + * 本次发送标识 ID(与发送接口返回的SerialNo对应) + */ + @JsonProperty("sid") + private String serialNo; + + /** + * 用户的 session 内容(与发送接口的请求参数 SessionContext 一致) + */ + @JsonProperty("ext") + private SessionContext sessionContext; + + } + + @VisibleForTesting + @Data + static class SessionContext { + + /** + * 发送短信记录id + */ + private Long logId; + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsChannelEnum.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsChannelEnum.java new file mode 100644 index 000000000..14c87b5dc --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsChannelEnum.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.sms.core.enums; + +import cn.hutool.core.util.ArrayUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 短信渠道枚举 + * + * @author zzf + * @since 2021/1/25 10:56 + */ +@Getter +@AllArgsConstructor +public enum SmsChannelEnum { + + DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"), + ALIYUN("ALIYUN", "阿里云"), + TENCENT("TENCENT", "腾讯云"), +// HUA_WEI("HUA_WEI", "华为云"), + ; + + /** + * 编码 + */ + private final String code; + /** + * 名字 + */ + private final String name; + + public static SmsChannelEnum getByCode(String code) { + return ArrayUtil.firstMatch(o -> o.getCode().equals(code), values()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java new file mode 100644 index 000000000..0fb44f3e5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsFrameworkErrorCodeConstants.java @@ -0,0 +1,50 @@ +package com.cf.imes.framework.sms.core.enums; + +import com.cf.imes.framework.common.exception.ErrorCode; + +/** + * 短信框架的错误码枚举 + * + * 短信框架,使用 2-001-000-000 段 + * + * @author 晨丰科技 + */ +public interface SmsFrameworkErrorCodeConstants { + + ErrorCode SMS_UNKNOWN = new ErrorCode(2_001_000_000, "未知错误,需要解析"); + + // ========== 权限 / 限流等相关 2-001-000-100 ========== + + ErrorCode SMS_PERMISSION_DENY = new ErrorCode(2_001_000_100, "没有发送短信的权限"); + ErrorCode SMS_IP_DENY = new ErrorCode(2_001_000_100, "IP 不允许发送短信"); + + // 阿里云:将短信发送频率限制在正常的业务限流范围内。默认短信验证码:使用同一签名,对同一个手机号验证码,支持 1 条 / 分钟,5 条 / 小时,累计 10 条 / 天。 + ErrorCode SMS_SEND_BUSINESS_LIMIT_CONTROL = new ErrorCode(2_001_000_102, "指定手机的发送限流"); + // 阿里云:已经达到您在控制台设置的短信日发送量限额值。在国内消息设置 > 安全设置,修改发送总量阈值。 + ErrorCode SMS_SEND_DAY_LIMIT_CONTROL = new ErrorCode(2_001_000_103, "每天的发送限流"); + + ErrorCode SMS_SEND_CONTENT_INVALID = new ErrorCode(2_001_000_104, "短信内容有敏感词"); + + // 腾讯云:为避免骚扰用户,营销短信只允许在8点到22点发送。 + ErrorCode SMS_SEND_MARKET_LIMIT_CONTROL = new ErrorCode(2_001_000_105, "营销短信发送时间限制"); + + // ========== 模板相关 2-001-000-200 ========== + ErrorCode SMS_TEMPLATE_INVALID = new ErrorCode(2_001_000_200, "短信模板不合法"); // 包括短信模板不存在 + ErrorCode SMS_TEMPLATE_PARAM_ERROR = new ErrorCode(2_001_000_201, "模板参数不正确"); + + // ========== 签名相关 2-001-000-300 ========== + ErrorCode SMS_SIGN_INVALID = new ErrorCode(2_001_000_300, "短信签名不可用"); + + // ========== 账户相关 2-001-000-400 ========== + ErrorCode SMS_ACCOUNT_MONEY_NOT_ENOUGH = new ErrorCode(2_001_000_400, "账户余额不足"); + ErrorCode SMS_ACCOUNT_INVALID = new ErrorCode(2_001_000_401, "apiKey 不存在"); + + // ========== 其它相关 2-001-000-900 开头 ========== + ErrorCode SMS_API_PARAM_ERROR = new ErrorCode(2_001_000_900, "请求参数缺失"); + ErrorCode SMS_MOBILE_INVALID = new ErrorCode(2_001_000_901, "手机格式不正确"); + ErrorCode SMS_MOBILE_BLACK = new ErrorCode(2_001_000_902, "手机号在黑名单中"); + ErrorCode SMS_APP_ID_INVALID = new ErrorCode(2_001_000_903, "SdkAppId不合法"); + + ErrorCode EXCEPTION = new ErrorCode(2_001_000_999, "调用异常"); + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsTemplateAuditStatusEnum.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsTemplateAuditStatusEnum.java new file mode 100644 index 000000000..d18066f76 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/enums/SmsTemplateAuditStatusEnum.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.sms.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 短信模板的审核状态枚举 + * + * @author 晨丰科技 + */ +@AllArgsConstructor +@Getter +public enum SmsTemplateAuditStatusEnum { + + CHECKING(1), + SUCCESS(2), + FAIL(3); + + private final Integer status; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/property/SmsChannelProperties.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/property/SmsChannelProperties.java new file mode 100644 index 000000000..f329e095c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/java/com/cf/imes/framework/sms/core/property/SmsChannelProperties.java @@ -0,0 +1,52 @@ +package com.cf.imes.framework.sms.core.property; + +import com.cf.imes.framework.sms.core.enums.SmsChannelEnum; +import lombok.Data; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 短信渠道配置类 + * + * @author zzf + * @since 2021/1/25 17:01 + */ +@Data +@Validated +public class SmsChannelProperties { + + /** + * 渠道编号 + */ + @NotNull(message = "短信渠道 ID 不能为空") + private Long id; + /** + * 短信签名 + */ + @NotEmpty(message = "短信签名不能为空") + private String signature; + /** + * 渠道编码 + * + * 枚举 {@link SmsChannelEnum} + */ + @NotEmpty(message = "渠道编码不能为空") + private String code; + /** + * 短信 API 的账号 + */ + @NotEmpty(message = "短信 API 的账号不能为空") + private String apiKey; + /** + * 短信 API 的密钥 + */ + @NotEmpty(message = "短信 API 的密钥不能为空") + private String apiSecret; + /** + * 短信发送回调 URL + */ + private String callbackUrl; + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..eb054df1d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.sms.config.ChenfengSmsAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/test/java/com/cf/imes/framework/sms/core/client/impl/aliyun/AliyunSmsClientTest.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/test/java/com/cf/imes/framework/sms/core/client/impl/aliyun/AliyunSmsClientTest.java new file mode 100644 index 000000000..5952ee2d8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/test/java/com/cf/imes/framework/sms/core/client/impl/aliyun/AliyunSmsClientTest.java @@ -0,0 +1,188 @@ +package com.cf.imes.framework.sms.core.client.impl.aliyun; + +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.common.util.collection.MapUtils; +import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO; +import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import com.aliyuncs.IAcsClient; +import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest; +import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateResponse; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; +import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.cf.imes.framework.common.util.json.JsonUtils.*; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * {@link AliyunSmsClient} 的单元测试 + * + * @author 晨丰科技 + */ +public class AliyunSmsClientTest extends BaseMockitoUnitTest { + + private final SmsChannelProperties properties = new SmsChannelProperties() + .setApiKey(randomString()) // 随机一个 apiKey,避免构建报错 + .setApiSecret(randomString()) // 随机一个 apiSecret,避免构建报错 + .setSignature("晨丰科技"); + + @InjectMocks + private final AliyunSmsClient smsClient = new AliyunSmsClient(properties); + + @Mock + private IAcsClient client; + + @Test + public void testDoInit() { + // 准备参数 + // mock 方法 + + // 调用 + smsClient.doInit(); + // 断言 + assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "acsClient")); + } + + @Test + public void tesSendSms_success() throws Throwable { + // 准备参数 + Long sendLogId = randomLongId(); + String mobile = randomString(); + String apiTemplateId = randomString(); + List> templateParams = Lists.newArrayList( + new KeyValue<>("code", 1234), new KeyValue<>("op", "login")); + // mock 方法 + SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> o.setCode("OK")); + when(client.getAcsResponse(argThat((ArgumentMatcher) acsRequest -> { + assertEquals(mobile, acsRequest.getPhoneNumbers()); + assertEquals(properties.getSignature(), acsRequest.getSignName()); + assertEquals(apiTemplateId, acsRequest.getTemplateCode()); + assertEquals(toJsonString(MapUtils.convertMap(templateParams)), acsRequest.getTemplateParam()); + assertEquals(sendLogId.toString(), acsRequest.getOutId()); + return true; + }))).thenReturn(response); + + // 调用 + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, + apiTemplateId, templateParams); + // 断言 + assertTrue(result.getSuccess()); + assertEquals(response.getRequestId(), result.getApiRequestId()); + assertEquals(response.getCode(), result.getApiCode()); + assertEquals(response.getMessage(), result.getApiMsg()); + assertEquals(response.getBizId(), result.getSerialNo()); + } + + @Test + public void tesSendSms_fail() throws Throwable { + // 准备参数 + Long sendLogId = randomLongId(); + String mobile = randomString(); + String apiTemplateId = randomString(); + List> templateParams = Lists.newArrayList( + new KeyValue<>("code", 1234), new KeyValue<>("op", "login")); + // mock 方法 + SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> o.setCode("ERROR")); + when(client.getAcsResponse(argThat((ArgumentMatcher) acsRequest -> { + assertEquals(mobile, acsRequest.getPhoneNumbers()); + assertEquals(properties.getSignature(), acsRequest.getSignName()); + assertEquals(apiTemplateId, acsRequest.getTemplateCode()); + assertEquals(toJsonString(MapUtils.convertMap(templateParams)), acsRequest.getTemplateParam()); + assertEquals(sendLogId.toString(), acsRequest.getOutId()); + return true; + }))).thenReturn(response); + + // 调用 + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams); + // 断言 + assertFalse(result.getSuccess()); + assertEquals(response.getRequestId(), result.getApiRequestId()); + assertEquals(response.getCode(), result.getApiCode()); + assertEquals(response.getMessage(), result.getApiMsg()); + assertEquals(response.getBizId(), result.getSerialNo()); + } + + @Test + public void testParseSmsReceiveStatus() { + // 准备参数 + String text = "[\n" + + " {\n" + + " \"phone_number\" : \"13900000001\",\n" + + " \"send_time\" : \"2017-01-01 11:12:13\",\n" + + " \"report_time\" : \"2017-02-02 22:23:24\",\n" + + " \"success\" : true,\n" + + " \"err_code\" : \"DELIVERED\",\n" + + " \"err_msg\" : \"用户接收成功\",\n" + + " \"sms_size\" : \"1\",\n" + + " \"biz_id\" : \"12345\",\n" + + " \"out_id\" : \"67890\"\n" + + " }\n" + + "]"; + // mock 方法 + + // 调用 + List statuses = smsClient.parseSmsReceiveStatus(text); + // 断言 + assertEquals(1, statuses.size()); + assertTrue(statuses.get(0).getSuccess()); + assertEquals("DELIVERED", statuses.get(0).getErrorCode()); + assertEquals("用户接收成功", statuses.get(0).getErrorMsg()); + assertEquals("13900000001", statuses.get(0).getMobile()); + assertEquals(LocalDateTime.of(2017, 2, 2, 22, 23, 24), + statuses.get(0).getReceiveTime()); + assertEquals("12345", statuses.get(0).getSerialNo()); + assertEquals(67890L, statuses.get(0).getLogId()); + } + + @Test + public void testGetSmsTemplate() throws Throwable { + // 准备参数 + String apiTemplateId = randomString(); + // mock 方法 + QuerySmsTemplateResponse response = randomPojo(QuerySmsTemplateResponse.class, o -> { + o.setCode("OK"); + o.setTemplateStatus(1); // 设置模板通过 + }); + when(client.getAcsResponse(argThat((ArgumentMatcher) acsRequest -> { + assertEquals(apiTemplateId, acsRequest.getTemplateCode()); + return true; + }))).thenReturn(response); + + // 调用 + SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId); + // 断言 + assertEquals(response.getTemplateCode(), result.getId()); + assertEquals(response.getTemplateContent(), result.getContent()); + Assertions.assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus()); + assertEquals(response.getReason(), result.getAuditReason()); + } + + @Test + public void testConvertSmsTemplateAuditStatus() { + assertEquals(SmsTemplateAuditStatusEnum.CHECKING.getStatus(), + smsClient.convertSmsTemplateAuditStatus(0)); + assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), + smsClient.convertSmsTemplateAuditStatus(1)); + assertEquals(SmsTemplateAuditStatusEnum.FAIL.getStatus(), + smsClient.convertSmsTemplateAuditStatus(2)); + assertThrows(IllegalArgumentException.class, () -> smsClient.convertSmsTemplateAuditStatus(3), + "未知审核状态(3)"); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-biz-sms/src/test/java/com/cf/imes/framework/sms/core/client/impl/tencent/TencentSmsClientTest.java b/cf-framework/cf-spring-boot-starter-biz-sms/src/test/java/com/cf/imes/framework/sms/core/client/impl/tencent/TencentSmsClientTest.java new file mode 100644 index 000000000..e5ee12e69 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-biz-sms/src/test/java/com/cf/imes/framework/sms/core/client/impl/tencent/TencentSmsClientTest.java @@ -0,0 +1,231 @@ +package com.cf.imes.framework.sms.core.client.impl.tencent; + +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.common.core.KeyValue; +import com.cf.imes.framework.common.util.collection.ArrayUtils; +import com.cf.imes.framework.common.util.collection.MapUtils; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.sms.core.client.dto.SmsReceiveRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsSendRespDTO; +import com.cf.imes.framework.sms.core.client.dto.SmsTemplateRespDTO; +import com.cf.imes.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import com.cf.imes.framework.sms.core.property.SmsChannelProperties; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import com.google.common.collect.Lists; +import com.tencentcloudapi.sms.v20210111.SmsClient; +import com.tencentcloudapi.sms.v20210111.models.DescribeSmsTemplateListResponse; +import com.tencentcloudapi.sms.v20210111.models.DescribeTemplateListStatus; +import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse; +import com.tencentcloudapi.sms.v20210111.models.SendStatus; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static com.cf.imes.framework.common.util.json.JsonUtils.*; +import static com.cf.imes.framework.test.core.util.RandomUtils.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.when; + +/** + * {@link TencentSmsClient} 的单元测试 + * + * @author shiwp + */ +public class TencentSmsClientTest extends BaseMockitoUnitTest { + + private final SmsChannelProperties properties = new SmsChannelProperties() + .setApiKey(randomString() + " " + randomString()) // 随机一个 apiKey,避免构建报错 + .setApiSecret(randomString()) // 随机一个 apiSecret,避免构建报错 + .setSignature("晨丰科技"); + + @InjectMocks + private TencentSmsClient smsClient = new TencentSmsClient(properties); + + @Mock + private SmsClient client; + + @Test + public void testDoInit() { + // 准备参数 + // mock 方法 + + // 调用 + smsClient.doInit(); + // 断言 + assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "client")); + } + + @Test + public void testRefresh() { + // 准备参数 + SmsChannelProperties p = new SmsChannelProperties() + .setApiKey(randomString() + " " + randomString()) // 随机一个 apiKey,避免构建报错 + .setApiSecret(randomString()) // 随机一个 apiSecret,避免构建报错 + .setSignature("晨丰科技"); + // 调用 + smsClient.refresh(p); + // 断言 + assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "client")); + } + + @Test + public void testDoSendSms_success() throws Throwable { + // 准备参数 + Long sendLogId = randomLongId(); + String mobile = randomString(); + String apiTemplateId = randomString(); + List> templateParams = Lists.newArrayList( + new KeyValue<>("1", 1234), new KeyValue<>("2", "login")); + String requestId = randomString(); + String serialNo = randomString(); + // mock 方法 + SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> { + o.setRequestId(requestId); + SendStatus[] sendStatuses = new SendStatus[1]; + o.setSendStatusSet(sendStatuses); + SendStatus sendStatus = new SendStatus(); + sendStatuses[0] = sendStatus; + sendStatus.setCode(TencentSmsClient.API_CODE_SUCCESS); + sendStatus.setMessage("send success"); + sendStatus.setSerialNo(serialNo); + }); + when(client.SendSms(argThat(request -> { + assertEquals(mobile, request.getPhoneNumberSet()[0]); + assertEquals(properties.getSignature(), request.getSignName()); + assertEquals(apiTemplateId, request.getTemplateId()); + assertEquals(toJsonString(ArrayUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)), + toJsonString(request.getTemplateParamSet())); + assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId")); + return true; + }))).thenReturn(response); + + // 调用 + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams); + // 断言 + assertTrue(result.getSuccess()); + assertEquals(response.getRequestId(), result.getApiRequestId()); + assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode()); + assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg()); + assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getSerialNo()); + } + + @Test + public void testDoSendSms_fail() throws Throwable { + // 准备参数 + Long sendLogId = randomLongId(); + String mobile = randomString(); + String apiTemplateId = randomString(); + List> templateParams = Lists.newArrayList( + new KeyValue<>("1", 1234), new KeyValue<>("2", "login")); + String requestId = randomString(); + String serialNo = randomString(); + // mock 方法 + SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> { + o.setRequestId(requestId); + SendStatus[] sendStatuses = new SendStatus[1]; + o.setSendStatusSet(sendStatuses); + SendStatus sendStatus = new SendStatus(); + sendStatuses[0] = sendStatus; + sendStatus.setCode("ERROR"); + sendStatus.setMessage("send success"); + sendStatus.setSerialNo(serialNo); + }); + when(client.SendSms(argThat(request -> { + assertEquals(mobile, request.getPhoneNumberSet()[0]); + assertEquals(properties.getSignature(), request.getSignName()); + assertEquals(apiTemplateId, request.getTemplateId()); + assertEquals(toJsonString(ArrayUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)), + toJsonString(request.getTemplateParamSet())); + assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId")); + return true; + }))).thenReturn(response); + + // 调用 + SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams); + // 断言 + assertFalse(result.getSuccess()); + assertEquals(response.getRequestId(), result.getApiRequestId()); + assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode()); + assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg()); + assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getSerialNo()); + } + + @Test + public void testParseSmsReceiveStatus() { + // 准备参数 + String text = "[\n" + + " {\n" + + " \"user_receive_time\": \"2015-10-17 08:03:04\",\n" + + " \"nationcode\": \"86\",\n" + + " \"mobile\": \"13900000001\",\n" + + " \"report_status\": \"SUCCESS\",\n" + + " \"errmsg\": \"DELIVRD\",\n" + + " \"description\": \"用户短信送达成功\",\n" + + " \"sid\": \"12345\",\n" + + " \"ext\": {\"logId\":\"67890\"}\n" + + " }\n" + + "]"; + // mock 方法 + + // 调用 + List statuses = smsClient.parseSmsReceiveStatus(text); + // 断言 + assertEquals(1, statuses.size()); + assertTrue(statuses.get(0).getSuccess()); + assertEquals("DELIVRD", statuses.get(0).getErrorCode()); + assertEquals("用户短信送达成功", statuses.get(0).getErrorMsg()); + assertEquals("13900000001", statuses.get(0).getMobile()); + assertEquals(LocalDateTime.of(2015, 10, 17, 8, 3, 4), statuses.get(0).getReceiveTime()); + assertEquals("12345", statuses.get(0).getSerialNo()); + assertEquals(67890L, statuses.get(0).getLogId()); + } + + @Test + public void testGetSmsTemplate() throws Throwable { + // 准备参数 + Long apiTemplateId = randomLongId(); + String requestId = randomString(); + + // mock 方法 + DescribeSmsTemplateListResponse response = randomPojo(DescribeSmsTemplateListResponse.class, o -> { + DescribeTemplateListStatus[] describeTemplateListStatuses = new DescribeTemplateListStatus[1]; + DescribeTemplateListStatus templateStatus = new DescribeTemplateListStatus(); + templateStatus.setTemplateId(apiTemplateId); + templateStatus.setStatusCode(0L);// 设置模板通过 + describeTemplateListStatuses[0] = templateStatus; + o.setDescribeTemplateStatusSet(describeTemplateListStatuses); + o.setRequestId(requestId); + }); + when(client.DescribeSmsTemplateList(argThat(request -> { + assertEquals(apiTemplateId, request.getTemplateIdSet()[0]); + return true; + }))).thenReturn(response); + + // 调用 + SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId.toString()); + // 断言 + assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateId().toString(), result.getId()); + assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateContent(), result.getContent()); + Assertions.assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus()); + assertEquals(response.getDescribeTemplateStatusSet()[0].getReviewReply(), result.getAuditReason()); + } + + @Test + public void testConvertSmsTemplateAuditStatus() { + assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), + smsClient.convertSmsTemplateAuditStatus(0)); + assertEquals(SmsTemplateAuditStatusEnum.CHECKING.getStatus(), + smsClient.convertSmsTemplateAuditStatus(1)); + assertEquals(SmsTemplateAuditStatusEnum.FAIL.getStatus(), + smsClient.convertSmsTemplateAuditStatus(-1)); + assertThrows(IllegalArgumentException.class, () -> smsClient.convertSmsTemplateAuditStatus(3), + "未知审核状态(3)"); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-captcha/pom.xml b/cf-framework/cf-spring-boot-starter-captcha/pom.xml new file mode 100644 index 000000000..2520a3eeb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/pom.xml @@ -0,0 +1,39 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-captcha + jar + + ${project.artifactId} + 验证码拓展 + 1. 基于 aj-captcha 实现滑块验证码,文档:https://ajcaptcha.beliefteam.cn/captcha-doc/ + + + + + + com.xingyuv + spring-boot-starter-captcha-plus + + + + org.springframework.boot + spring-boot-starter + + + + + com.cf.imes + cf-spring-boot-starter-redis + + + + + diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/config/ChenfengCaptchaConfiguration.java b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/config/ChenfengCaptchaConfiguration.java new file mode 100644 index 000000000..0828ffa2c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/config/ChenfengCaptchaConfiguration.java @@ -0,0 +1,29 @@ +package com.cf.imes.framework.captcha.config; + +import com.cf.imes.framework.captcha.core.service.RedisCaptchaServiceImpl; +import com.xingyuv.captcha.properties.AjCaptchaProperties; +import com.xingyuv.captcha.service.CaptchaCacheService; +import com.xingyuv.captcha.service.impl.CaptchaServiceFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import javax.annotation.Resource; + +@AutoConfiguration +public class ChenfengCaptchaConfiguration { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Bean + public CaptchaCacheService captchaCacheService(AjCaptchaProperties config) { + // 缓存类型 redis/local/.... + CaptchaCacheService ret = CaptchaServiceFactory.getCache(config.getCacheType().name()); + if (ret instanceof RedisCaptchaServiceImpl) { + ((RedisCaptchaServiceImpl) ret).setStringRedisTemplate(stringRedisTemplate); + } + return ret; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/core/enums/CaptchaRedisKeyConstants.java b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/core/enums/CaptchaRedisKeyConstants.java new file mode 100644 index 000000000..2c4ac75d2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/core/enums/CaptchaRedisKeyConstants.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.captcha.core.enums; + +/** + * 验证码 Redis Key 枚举类 + * + * @author 晨丰科技 + */ +public interface CaptchaRedisKeyConstants { + + /** + * 验证码的请求限流 + * + * KEY 格式:AJ.CAPTCHA.REQ.LIMIT-%s-%s + * VALUE 数据类型:String // 例如说:验证失败 5 次,get 接口锁定 + * 过期时间:60 秒 + */ + String AJ_CAPTCHA_REQ_LIMIT = "AJ.CAPTCHA.REQ.LIMIT-%s-%s"; + + /** + * 验证码的坐标 + * + * KEY 格式:RUNNING:CAPTCHA:%s // AbstractCaptchaService.REDIS_CAPTCHA_KEY + * VALUE 数据类型:String // PointVO.class {"secretKey":"PP1w2Frr2KEejD2m","x":162,"y":5} + * 过期时间:120 秒 + */ + String AJ_CAPTCHA_RUNNING = "RUNNING:CAPTCHA:%s"; + +} diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/core/service/RedisCaptchaServiceImpl.java b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/core/service/RedisCaptchaServiceImpl.java new file mode 100644 index 000000000..6620213f2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/core/service/RedisCaptchaServiceImpl.java @@ -0,0 +1,57 @@ +package com.cf.imes.framework.captcha.core.service; + +import com.xingyuv.captcha.service.CaptchaCacheService; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; + +import javax.annotation.Resource; +import java.util.concurrent.TimeUnit; + +/** + * 基于 Redis 实现验证码的存储 + * + * @author 星语 + */ +@NoArgsConstructor // 保证 aj-captcha 的 SPI 创建 +@AllArgsConstructor +public class RedisCaptchaServiceImpl implements CaptchaCacheService { + + @Resource // 保证 aj-captcha 的 SPI 创建时的注入 + private StringRedisTemplate stringRedisTemplate; + + @Override + public String type() { + return "redis"; + } + + public void setStringRedisTemplate(StringRedisTemplate stringRedisTemplate) { + this.stringRedisTemplate = stringRedisTemplate; + } + + @Override + public void set(String key, String value, long expiresInSeconds) { + stringRedisTemplate.opsForValue().set(key, value, expiresInSeconds, TimeUnit.SECONDS); + } + + @Override + public boolean exists(String key) { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); + } + + @Override + public void delete(String key) { + stringRedisTemplate.delete(key); + } + + @Override + public String get(String key) { + return stringRedisTemplate.opsForValue().get(key); + } + + @Override + public Long increment(String key, long val) { + return stringRedisTemplate.opsForValue().increment(key,val); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/package-info.java b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/package-info.java new file mode 100644 index 000000000..5401e780f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/src/main/java/com/cf/imes/framework/captcha/package-info.java @@ -0,0 +1,7 @@ +/** + * 验证码拓展 + * 1. 基于 aj-captcha 实现滑块验证码,文档:https://ajcaptcha.beliefteam.cn/captcha-doc/ + * + * @author 星语 + */ +package com.cf.imes.framework.captcha; diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService new file mode 100644 index 000000000..742d55d0c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/META-INF/services/com.xingyuv.captcha.service.CaptchaCacheService @@ -0,0 +1 @@ +com.cf.imes.framework.captcha.core.service.RedisCaptchaServiceImpl diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..b784cf670 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.captcha.config.ChenfengCaptchaConfiguration diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg1.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg1.png new file mode 100644 index 000000000..c48145769 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg1.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg2.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg2.png new file mode 100644 index 000000000..bf8fb38ff Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg2.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg3.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg3.png new file mode 100644 index 000000000..f871d3d12 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg3.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg4.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg4.png new file mode 100644 index 000000000..2e3d87166 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg4.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg5.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg5.png new file mode 100644 index 000000000..fe383b720 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg5.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg6.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg6.png new file mode 100644 index 000000000..5024ceb22 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg6.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg7.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg7.png new file mode 100644 index 000000000..efe76f8de Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg7.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg8.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg8.png new file mode 100644 index 000000000..2727aa324 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg8.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg9.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg9.png new file mode 100644 index 000000000..4463aa2fb Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/original/bg9.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/1.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/1.png new file mode 100644 index 000000000..ef1132471 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/1.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/10.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/10.png new file mode 100644 index 000000000..297e44cf4 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/10.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/11.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/11.png new file mode 100644 index 000000000..d9b1da8d7 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/11.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/12.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/12.png new file mode 100644 index 000000000..07e7313b4 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/12.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/13.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/13.png new file mode 100644 index 000000000..82c3dd969 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/13.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/14.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/14.png new file mode 100644 index 000000000..0b9a86615 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/14.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/15.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/15.png new file mode 100644 index 000000000..86b0d1cf1 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/15.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/16.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/16.png new file mode 100644 index 000000000..e90a6e292 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/16.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/17.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/17.png new file mode 100644 index 000000000..a82cbc7c4 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/17.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/18.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/18.png new file mode 100644 index 000000000..d3f3cfd03 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/18.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/19.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/19.png new file mode 100644 index 000000000..eb2855bd8 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/19.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/8.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/8.png new file mode 100644 index 000000000..3cb5ce1c8 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/8.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/9.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/9.png new file mode 100644 index 000000000..384d35415 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/11/9.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/2.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/2.png new file mode 100644 index 000000000..baf3f06d7 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/2.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/3.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/3.png new file mode 100644 index 000000000..ccaf61723 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/3.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/4.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/4.png new file mode 100644 index 000000000..7dab16223 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/jigsaw/slidingBlock/4.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg1.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg1.png new file mode 100644 index 000000000..14e73454a Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg1.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg10.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg10.png new file mode 100644 index 000000000..1ea1d6d59 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg10.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg2.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg2.png new file mode 100644 index 000000000..0edb32937 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg2.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg3.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg3.png new file mode 100644 index 000000000..91679960f Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg3.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg4.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg4.png new file mode 100644 index 000000000..e8e8e6c0c Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg4.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg5.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg5.png new file mode 100644 index 000000000..66a3181e7 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg5.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg6.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg6.png new file mode 100644 index 000000000..9b0f5d8c1 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg6.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg7.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg7.png new file mode 100644 index 000000000..db41c74a0 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg7.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg8.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg8.png new file mode 100644 index 000000000..349681306 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg8.png differ diff --git a/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg9.png b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg9.png new file mode 100644 index 000000000..4e7b47752 Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-captcha/src/main/resources/images/pic-click/bg9.png differ diff --git a/cf-framework/cf-spring-boot-starter-desensitize/pom.xml b/cf-framework/cf-spring-boot-starter-desensitize/pom.xml new file mode 100644 index 000000000..0acc43e58 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + com.cf.imes + cf-framework + ${revision} + + + cf-spring-boot-starter-desensitize + 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 + + + + com.cf.imes + cf-common + + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/annotation/DesensitizeBy.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/annotation/DesensitizeBy.java new file mode 100644 index 000000000..3368c4950 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/annotation/DesensitizeBy.java @@ -0,0 +1,32 @@ +package com.cf.imes.framework.desensitize.core.base.annotation; + +import com.cf.imes.framework.desensitize.core.base.handler.DesensitizationHandler; +import com.cf.imes.framework.desensitize.core.base.serializer.StringDesensitizeSerializer; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 顶级脱敏注解,自定义注解需要使用此注解 + * + * @author gaibu + */ +@Documented +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside // 此注解是其他所有 jackson 注解的元注解,打上了此注解的注解表明是 jackson 注解的一部分 +@JsonSerialize(using = StringDesensitizeSerializer.class) // 指定序列化器 +public @interface DesensitizeBy { + + /** + * 脱敏处理器 + */ + @SuppressWarnings("rawtypes") + Class handler(); + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/handler/DesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/handler/DesensitizationHandler.java new file mode 100644 index 000000000..160eeac1c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/handler/DesensitizationHandler.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.desensitize.core.base.handler; + +import java.lang.annotation.Annotation; + +/** + * 脱敏处理器接口 + * + * @author gaibu + */ +public interface DesensitizationHandler { + + /** + * 脱敏 + * + * @param origin 原始字符串 + * @param annotation 注解信息 + * @return 脱敏后的字符串 + */ + String desensitize(String origin, T annotation); + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java new file mode 100644 index 000000000..a43329dce --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java @@ -0,0 +1,92 @@ +package com.cf.imes.framework.desensitize.core.base.serializer; + +import cn.hutool.core.annotation.AnnotationUtil; +import cn.hutool.core.lang.Singleton; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.base.handler.DesensitizationHandler; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import lombok.Getter; +import lombok.Setter; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; + +/** + * 脱敏序列化器 + * + * 实现 JSON 返回数据时,使用 {@link DesensitizationHandler} 对声明脱敏注解的字段,进行脱敏处理。 + * + * @author gaibu + */ +@SuppressWarnings("rawtypes") +public class StringDesensitizeSerializer extends StdSerializer implements ContextualSerializer { + + @Getter + @Setter + private DesensitizationHandler desensitizationHandler; + + protected StringDesensitizeSerializer() { + super(String.class); + } + + @Override + public JsonSerializer createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) { + DesensitizeBy annotation = beanProperty.getAnnotation(DesensitizeBy.class); + if (annotation == null) { + return this; + } + // 创建一个 StringDesensitizeSerializer 对象,使用 DesensitizeBy 对应的处理器 + StringDesensitizeSerializer serializer = new StringDesensitizeSerializer(); + serializer.setDesensitizationHandler(Singleton.get(annotation.handler())); + return serializer; + } + + @Override + @SuppressWarnings("unchecked") + public void serialize(String value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException { + if (StrUtil.isBlank(value)) { + gen.writeNull(); + return; + } + // 获取序列化字段 + Field field = getField(gen); + + // 自定义处理器 + DesensitizeBy[] annotations = AnnotationUtil.getCombinationAnnotations(field, DesensitizeBy.class); + if (ArrayUtil.isEmpty(annotations)) { + gen.writeString(value); + return; + } + for (Annotation annotation : field.getAnnotations()) { + if (AnnotationUtil.hasAnnotation(annotation.annotationType(), DesensitizeBy.class)) { + value = this.desensitizationHandler.desensitize(value, annotation); + gen.writeString(value); + return; + } + } + gen.writeString(value); + } + + /** + * 获取字段 + * + * @param generator JsonGenerator + * @return 字段 + */ + private Field getField(JsonGenerator generator) { + String currentName = generator.getOutputContext().getCurrentName(); + Object currentValue = generator.getCurrentValue(); + Class currentValueClass = currentValue.getClass(); + return ReflectUtil.getField(currentValueClass, currentName); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/package-info.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/package-info.java new file mode 100644 index 000000000..895b52d09 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 + */ +package com.cf.imes.framework.desensitize.core; diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/annotation/EmailDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/annotation/EmailDesensitize.java new file mode 100644 index 000000000..9978239c0 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/annotation/EmailDesensitize.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.desensitize.core.regex.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.regex.handler.EmailDesensitizationHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 邮箱脱敏注解 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = EmailDesensitizationHandler.class) +public @interface EmailDesensitize { + + /** + * 匹配的正则表达式 + */ + String regex() default "(^.)[^@]*(@.*$)"; + + /** + * 替换规则,邮箱; + * + * 比如:example@gmail.com 脱敏之后为 e****@gmail.com + */ + String replacer() default "$1****$2"; +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/annotation/RegexDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/annotation/RegexDesensitize.java new file mode 100644 index 000000000..7c3f80632 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/annotation/RegexDesensitize.java @@ -0,0 +1,38 @@ +package com.cf.imes.framework.desensitize.core.regex.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.regex.handler.DefaultRegexDesensitizationHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 正则脱敏注解 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = DefaultRegexDesensitizationHandler.class) +public @interface RegexDesensitize { + + /** + * 匹配的正则表达式(默认匹配所有) + */ + String regex() default "^[\\s\\S]*$"; + + /** + * 替换规则,会将匹配到的字符串全部替换成 replacer + * + * 例如:regex=123; replacer=****** + * 原始字符串 123456789 + * 脱敏后字符串 ******456789 + */ + String replacer() default "******"; +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java new file mode 100644 index 000000000..3d504725f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java @@ -0,0 +1,38 @@ +package com.cf.imes.framework.desensitize.core.regex.handler; + +import com.cf.imes.framework.desensitize.core.base.handler.DesensitizationHandler; + +import java.lang.annotation.Annotation; + +/** + * 正则表达式脱敏处理器抽象类,已实现通用的方法 + * + * @author gaibu + */ +public abstract class AbstractRegexDesensitizationHandler + implements DesensitizationHandler { + + @Override + public String desensitize(String origin, T annotation) { + String regex = getRegex(annotation); + String replacer = getReplacer(annotation); + return origin.replaceAll(regex, replacer); + } + + /** + * 获取注解上的 regex 参数 + * + * @param annotation 注解信息 + * @return 正则表达式 + */ + abstract String getRegex(T annotation); + + /** + * 获取注解上的 replacer 参数 + * + * @param annotation 注解信息 + * @return 待替换的字符串 + */ + abstract String getReplacer(T annotation); + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java new file mode 100644 index 000000000..bd61f3d50 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.desensitize.core.regex.handler; + +import com.cf.imes.framework.desensitize.core.regex.annotation.RegexDesensitize; + +/** + * {@link RegexDesensitize} 的正则脱敏处理器 + * + * @author gaibu + */ +public class DefaultRegexDesensitizationHandler extends AbstractRegexDesensitizationHandler { + + @Override + String getRegex(RegexDesensitize annotation) { + return annotation.regex(); + } + + @Override + String getReplacer(RegexDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java new file mode 100644 index 000000000..2494e5d21 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.desensitize.core.regex.handler; + +import com.cf.imes.framework.desensitize.core.regex.annotation.EmailDesensitize; + +/** + * {@link EmailDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class EmailDesensitizationHandler extends AbstractRegexDesensitizationHandler { + + @Override + String getRegex(EmailDesensitize annotation) { + return annotation.regex(); + } + + @Override + String getReplacer(EmailDesensitize annotation) { + return annotation.replacer(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/BankCardDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/BankCardDesensitize.java new file mode 100644 index 000000000..7c349542a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/BankCardDesensitize.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.BankCardDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 银行卡号 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = BankCardDesensitization.class) +public @interface BankCardDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 6; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 2; + + /** + * 替换规则,银行卡号; 比如:9988002866797031 脱敏之后为 998800********31 + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java new file mode 100644 index 000000000..c9408e3d3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.CarLicenseDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 车牌号 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = CarLicenseDesensitization.class) +public @interface CarLicenseDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 3; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 1; + + /** + * 替换规则,车牌号;比如:粤A66666 脱敏之后为粤A6***6 + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java new file mode 100644 index 000000000..bda754657 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.ChineseNameDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 中文名 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = ChineseNameDesensitization.class) +public @interface ChineseNameDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 1; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 0; + + /** + * 替换规则,中文名;比如:刘子豪脱敏之后为刘** + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java new file mode 100644 index 000000000..8adeb95b7 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.FixedPhoneDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 固定电话 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = FixedPhoneDesensitization.class) +public @interface FixedPhoneDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 4; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 2; + + /** + * 替换规则,固定电话;比如:01086551122 脱敏之后为 0108*****22 + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/IdCardDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/IdCardDesensitize.java new file mode 100644 index 000000000..10fb12b0f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/IdCardDesensitize.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.IdCardDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 身份证 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = IdCardDesensitization.class) +public @interface IdCardDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 6; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 2; + + /** + * 替换规则,身份证号码;比如:530321199204074611 脱敏之后为 530321**********11 + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/MobileDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/MobileDesensitize.java new file mode 100644 index 000000000..6a2bb16c8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/MobileDesensitize.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.MobileDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 手机号 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = MobileDesensitization.class) +public @interface MobileDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 3; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 4; + + /** + * 替换规则,手机号;比如:13248765917 脱敏之后为 132****5917 + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/PasswordDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/PasswordDesensitize.java new file mode 100644 index 000000000..cfef6fbf2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/PasswordDesensitize.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.PasswordDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 密码 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = PasswordDesensitization.class) +public @interface PasswordDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 0; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 0; + + /** + * 替换规则,密码; + * + * 比如:123456 脱敏之后为 ****** + */ + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/SliderDesensitize.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/SliderDesensitize.java new file mode 100644 index 000000000..142d1805b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/annotation/SliderDesensitize.java @@ -0,0 +1,43 @@ +package com.cf.imes.framework.desensitize.core.slider.annotation; + +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.slider.handler.DefaultDesensitizationHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 滑动脱敏注解 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = DefaultDesensitizationHandler.class) +public @interface SliderDesensitize { + + /** + * 后缀保留长度 + */ + int suffixKeep() default 0; + + /** + * 替换规则,会将前缀后缀保留后,全部替换成 replacer + * + * 例如:prefixKeep = 1; suffixKeep = 2; replacer = "*"; + * 原始字符串 123456 + * 脱敏后 1***56 + */ + String replacer() default "*"; + + /** + * 前缀保留长度 + */ + int prefixKeep() default 0; +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/AbstractDesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/AbstractDesensitizationHandler.java new file mode 100644 index 000000000..21877d770 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/AbstractDesensitizationHandler.java @@ -0,0 +1,78 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.base.handler.DesensitizationHandler; + +import java.lang.annotation.Annotation; + +/** + * 滑动脱敏处理器抽象类,已实现通用的方法 + * + * @author gaibu + */ +public abstract class AbstractDesensitizationHandler + implements DesensitizationHandler { + + @Override + public String desensitize(String origin, T annotation) { + int prefixKeep = getPrefixKeep(annotation); + int suffixKeep = getSuffixKeep(annotation); + String replacer = getReplacer(annotation); + int length = origin.length(); + + // 情况一:原始字符串长度小于等于保留长度,则原始字符串全部替换 + if (prefixKeep >= length || suffixKeep >= length) { + return buildReplacerByLength(replacer, length); + } + + // 情况二:原始字符串长度小于等于前后缀保留字符串长度,则原始字符串全部替换 + if ((prefixKeep + suffixKeep) >= length) { + return buildReplacerByLength(replacer, length); + } + + // 情况三:原始字符串长度大于前后缀保留字符串长度,则替换中间字符串 + int interval = length - prefixKeep - suffixKeep; + return origin.substring(0, prefixKeep) + + buildReplacerByLength(replacer, interval) + + origin.substring(prefixKeep + interval); + } + + /** + * 根据长度循环构建替换符 + * + * @param replacer 替换符 + * @param length 长度 + * @return 构建后的替换符 + */ + private String buildReplacerByLength(String replacer, int length) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < length; i++) { + builder.append(replacer); + } + return builder.toString(); + } + + /** + * 前缀保留长度 + * + * @param annotation 注解信息 + * @return 前缀保留长度 + */ + abstract Integer getPrefixKeep(T annotation); + + /** + * 后缀保留长度 + * + * @param annotation 注解信息 + * @return 后缀保留长度 + */ + abstract Integer getSuffixKeep(T annotation); + + /** + * 替换符 + * + * @param annotation 注解信息 + * @return 替换符 + */ + abstract String getReplacer(T annotation); + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java new file mode 100644 index 000000000..2c3bafd1d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java @@ -0,0 +1,78 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.base.handler.DesensitizationHandler; + +import java.lang.annotation.Annotation; + +/** + * 滑动脱敏处理器抽象类,已实现通用的方法 + * + * @author gaibu + */ +public abstract class AbstractSliderDesensitizationHandler + implements DesensitizationHandler { + + @Override + public String desensitize(String origin, T annotation) { + int prefixKeep = getPrefixKeep(annotation); + int suffixKeep = getSuffixKeep(annotation); + String replacer = getReplacer(annotation); + int length = origin.length(); + + // 情况一:原始字符串长度小于等于保留长度,则原始字符串全部替换 + if (prefixKeep >= length || suffixKeep >= length) { + return buildReplacerByLength(replacer, length); + } + + // 情况二:原始字符串长度小于等于前后缀保留字符串长度,则原始字符串全部替换 + if ((prefixKeep + suffixKeep) >= length) { + return buildReplacerByLength(replacer, length); + } + + // 情况三:原始字符串长度大于前后缀保留字符串长度,则替换中间字符串 + int interval = length - prefixKeep - suffixKeep; + return origin.substring(0, prefixKeep) + + buildReplacerByLength(replacer, interval) + + origin.substring(prefixKeep + interval); + } + + /** + * 根据长度循环构建替换符 + * + * @param replacer 替换符 + * @param length 长度 + * @return 构建后的替换符 + */ + private String buildReplacerByLength(String replacer, int length) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < length; i++) { + builder.append(replacer); + } + return builder.toString(); + } + + /** + * 前缀保留长度 + * + * @param annotation 注解信息 + * @return 前缀保留长度 + */ + abstract Integer getPrefixKeep(T annotation); + + /** + * 后缀保留长度 + * + * @param annotation 注解信息 + * @return 后缀保留长度 + */ + abstract Integer getSuffixKeep(T annotation); + + /** + * 替换符 + * + * @param annotation 注解信息 + * @return 替换符 + */ + abstract String getReplacer(T annotation); + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/BankCardDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/BankCardDesensitization.java new file mode 100644 index 000000000..0b9cae9eb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/BankCardDesensitization.java @@ -0,0 +1,27 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.BankCardDesensitize; + +/** + * {@link BankCardDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class BankCardDesensitization extends AbstractSliderDesensitizationHandler { + + @Override + Integer getPrefixKeep(BankCardDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(BankCardDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(BankCardDesensitize annotation) { + return annotation.replacer(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java new file mode 100644 index 000000000..a2097c924 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.CarLicenseDesensitize; + +/** + * {@link CarLicenseDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class CarLicenseDesensitization extends AbstractSliderDesensitizationHandler { + @Override + Integer getPrefixKeep(CarLicenseDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(CarLicenseDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(CarLicenseDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java new file mode 100644 index 000000000..5b1fde9e6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java @@ -0,0 +1,27 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.ChineseNameDesensitize; + +/** + * {@link ChineseNameDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class ChineseNameDesensitization extends AbstractSliderDesensitizationHandler { + + @Override + Integer getPrefixKeep(ChineseNameDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(ChineseNameDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(ChineseNameDesensitize annotation) { + return annotation.replacer(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java new file mode 100644 index 000000000..3f8318045 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.SliderDesensitize; + +/** + * {@link SliderDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class DefaultDesensitizationHandler extends AbstractSliderDesensitizationHandler { + @Override + Integer getPrefixKeep(SliderDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(SliderDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(SliderDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java new file mode 100644 index 000000000..a24938b01 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.FixedPhoneDesensitize; + +/** + * {@link FixedPhoneDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class FixedPhoneDesensitization extends AbstractSliderDesensitizationHandler { + @Override + Integer getPrefixKeep(FixedPhoneDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(FixedPhoneDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(FixedPhoneDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/IdCardDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/IdCardDesensitization.java new file mode 100644 index 000000000..84568210b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/IdCardDesensitization.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.IdCardDesensitize; + +/** + * {@link IdCardDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class IdCardDesensitization extends AbstractSliderDesensitizationHandler { + @Override + Integer getPrefixKeep(IdCardDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(IdCardDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(IdCardDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/MobileDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/MobileDesensitization.java new file mode 100644 index 000000000..9b3e12523 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/MobileDesensitization.java @@ -0,0 +1,26 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.MobileDesensitize; + +/** + * {@link MobileDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class MobileDesensitization extends AbstractSliderDesensitizationHandler { + + @Override + Integer getPrefixKeep(MobileDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(MobileDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(MobileDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/PasswordDesensitization.java b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/PasswordDesensitization.java new file mode 100644 index 000000000..e5ae36f44 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/main/java/com/cf/imes/framework/desensitize/core/slider/handler/PasswordDesensitization.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.desensitize.core.slider.handler; + +import com.cf.imes.framework.desensitize.core.slider.annotation.PasswordDesensitize; + +/** + * {@link PasswordDesensitize} 的码脱敏处理器 + * + * @author gaibu + */ +public class PasswordDesensitization extends AbstractSliderDesensitizationHandler { + @Override + Integer getPrefixKeep(PasswordDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(PasswordDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(PasswordDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/DesensitizeTest.java b/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/DesensitizeTest.java new file mode 100644 index 000000000..b0f0a6243 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/DesensitizeTest.java @@ -0,0 +1,98 @@ +package com.cf.imes.framework.desensitize.core; + +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.desensitize.core.regex.annotation.EmailDesensitize; +import com.cf.imes.framework.desensitize.core.regex.annotation.RegexDesensitize; +import com.cf.imes.framework.desensitize.core.annotation.Address; +import com.cf.imes.framework.desensitize.core.slider.annotation.BankCardDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.CarLicenseDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.ChineseNameDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.FixedPhoneDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.IdCardDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.PasswordDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.MobileDesensitize; +import com.cf.imes.framework.desensitize.core.slider.annotation.SliderDesensitize; +import com.cf.imes.framework.test.core.ut.BaseMockitoUnitTest; +import lombok.Data; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DesensitizeTest} 的单元测试 + */ +public class DesensitizeTest extends BaseMockitoUnitTest { + + @Test + public void test() { + // 准备参数 + DesensitizeDemo desensitizeDemo = new DesensitizeDemo(); + desensitizeDemo.setNickname("晨丰科技"); + desensitizeDemo.setBankCard("9988002866797031"); + desensitizeDemo.setCarLicense("粤A66666"); + desensitizeDemo.setFixedPhone("01086551122"); + desensitizeDemo.setIdCard("530321199204074611"); + desensitizeDemo.setPassword("123456"); + desensitizeDemo.setPhoneNumber("13248765917"); + desensitizeDemo.setSlider1("ABCDEFG"); + desensitizeDemo.setSlider2("ABCDEFG"); + desensitizeDemo.setSlider3("ABCDEFG"); + desensitizeDemo.setEmail("1@email.com"); + desensitizeDemo.setRegex("你好,我是晨丰科技"); + desensitizeDemo.setAddress("北京市海淀区上地十街10号"); + desensitizeDemo.setOrigin("晨丰科技"); + + // 调用 + DesensitizeDemo d = JsonUtils.parseObject(JsonUtils.toJsonString(desensitizeDemo), DesensitizeDemo.class); + // 断言 + assertNotNull(d); + assertEquals("芋***", d.getNickname()); + assertEquals("998800********31", d.getBankCard()); + assertEquals("粤A6***6", d.getCarLicense()); + assertEquals("0108*****22", d.getFixedPhone()); + assertEquals("530321**********11", d.getIdCard()); + assertEquals("******", d.getPassword()); + assertEquals("132****5917", d.getPhoneNumber()); + assertEquals("#######", d.getSlider1()); + assertEquals("ABC*EFG", d.getSlider2()); + assertEquals("*******", d.getSlider3()); + assertEquals("1****@email.com", d.getEmail()); + assertEquals("你好,我是*", d.getRegex()); + assertEquals("北京市海淀区上地十街10号*", d.getAddress()); + assertEquals("晨丰科技", d.getOrigin()); + } + + @Data + public static class DesensitizeDemo { + + @ChineseNameDesensitize + private String nickname; + @BankCardDesensitize + private String bankCard; + @CarLicenseDesensitize + private String carLicense; + @FixedPhoneDesensitize + private String fixedPhone; + @IdCardDesensitize + private String idCard; + @PasswordDesensitize + private String password; + @MobileDesensitize + private String phoneNumber; + @SliderDesensitize(prefixKeep = 6, suffixKeep = 1, replacer = "#") + private String slider1; + @SliderDesensitize(prefixKeep = 3, suffixKeep = 3) + private String slider2; + @SliderDesensitize(prefixKeep = 10) + private String slider3; + @EmailDesensitize + private String email; + @RegexDesensitize(regex = "晨丰科技", replacer = "*") + private String regex; + @Address + private String address; + private String origin; + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/annotation/Address.java b/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/annotation/Address.java new file mode 100644 index 000000000..591fd6a71 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/annotation/Address.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.desensitize.core.annotation; + +import com.cf.imes.framework.desensitize.core.DesensitizeTest; +import com.cf.imes.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.cf.imes.framework.desensitize.core.handler.AddressHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 地址 + * + * 用于 {@link DesensitizeTest} 测试使用 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = AddressHandler.class) +public @interface Address { + + String replacer() default "*"; + +} diff --git a/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/handler/AddressHandler.java b/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/handler/AddressHandler.java new file mode 100644 index 000000000..f2717ba53 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-desensitize/src/test/java/com/cf/imes/framework/desensitize/core/handler/AddressHandler.java @@ -0,0 +1,19 @@ +package com.cf.imes.framework.desensitize.core.handler; + +import com.cf.imes.framework.desensitize.core.DesensitizeTest; +import com.cf.imes.framework.desensitize.core.base.handler.DesensitizationHandler; +import com.cf.imes.framework.desensitize.core.annotation.Address; + +/** + * {@link Address} 的脱敏处理器 + * + * 用于 {@link DesensitizeTest} 测试使用 + */ +public class AddressHandler implements DesensitizationHandler

{ + + @Override + public String desensitize(String origin, Address annotation) { + return origin + annotation.replacer(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/pom.xml b/cf-framework/cf-spring-boot-starter-elasticsearch/pom.xml new file mode 100644 index 000000000..dd489e0c9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + com.cf.imes + cf-framework + ${revision} + + + jar + + cf-spring-boot-starter-elasticsearch + + + 17 + 17 + UTF-8 + 8.8.1 + 2.1.1 + + + + com.cf.imes + cf-common + provided + + + + org.springframework.boot + spring-boot-starter + + + jakarta.annotation + jakarta.annotation-api + + + + + + co.elastic.clients + elasticsearch-java + ${elasticsearch.version} + + + jakarta.json + jakarta.json-api + + + + + + jakarta.json + jakarta.json-api + ${jakarta-json.version} + + + com.fasterxml.jackson.core + jackson-annotations + + + com.cf.imes + cf-spring-boot-starter-security + + + + + + \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/ChenfengElasticsearchAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/ChenfengElasticsearchAutoConfiguration.java new file mode 100644 index 000000000..48a4b2a63 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/ChenfengElasticsearchAutoConfiguration.java @@ -0,0 +1,100 @@ +package com.cf.imes.framework.es.config; + +import co.elastic.clients.elasticsearch.ElasticsearchAsyncClient; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.cf.imes.framework.es.core.service.ESDocumentService; +import com.cf.imes.framework.es.core.service.ESDocumentServiceImpl; +import org.apache.http.HttpHost; +import org.elasticsearch.client.RestClient; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + + +/** + * @author there + */ +@AutoConfiguration +@ConditionalOnClass(ElasticsearchClient.class) +@EnableConfigurationProperties(EsProperties.class) +public class ChenfengElasticsearchAutoConfiguration { + + //超时时间设置 + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 10000; + public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 300000; + public static final int DEFAULT_CONNECT_REQUEST_TIMEOUT_MILLIS = 1000; + + /** + * 同步方式 + * + */ + @Bean + public ElasticsearchClient elasticsearchClient(EsProperties properties) { + return new ElasticsearchClient(getTransport(properties.getUris())); + } + + /** + * 异步方式 + * + */ + @Bean + public ElasticsearchAsyncClient elasticsearchAsyncClient(EsProperties properties) { + return new ElasticsearchAsyncClient(getTransport(properties.getUris())); + } + + + @Bean + private ESDocumentService esDocumentService(ElasticsearchClient elasticsearchClient, ElasticsearchAsyncClient elasticsearchAsyncClient) { + return new ESDocumentServiceImpl(elasticsearchClient, elasticsearchAsyncClient); + } + + /** + * 获取客户端 RestClientTransport + */ + private RestClientTransport getTransport(String hosts){ + HttpHost[] httpHosts = toHttpHost(hosts); + RestClient restClient = getRestClient(httpHosts); + return new RestClientTransport(restClient, new JacksonJsonpMapper()); + } + + /** + * 获取客户端RestClient + * @param httpHosts http数组 + */ + private RestClient getRestClient(HttpHost[] httpHosts){ + return RestClient.builder(httpHosts).setRequestConfigCallback(requestConfigBuilder -> { + requestConfigBuilder.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT_MILLIS); + requestConfigBuilder.setSocketTimeout(DEFAULT_SOCKET_TIMEOUT_MILLIS); + requestConfigBuilder.setConnectionRequestTimeout(DEFAULT_CONNECT_REQUEST_TIMEOUT_MILLIS); + return requestConfigBuilder; + }).build(); + } + + /** + * 解析配置的字符串hosts,转为HttpHost对象数组 + */ + private HttpHost[] toHttpHost(String hosts) { + if (!StringUtils.hasLength(hosts)) { + throw new RuntimeException("invalid elasticsearch configuration. elasticsearch.hosts不能为空!"); + } + // 多个IP逗号隔开 + String[] hostArray = hosts.split(","); + HttpHost[] httpHosts = new HttpHost[hostArray.length]; + HttpHost httpHost; + for (int i = 0; i < hostArray.length; i++) { + String[] strings = hostArray[i].split(":"); + httpHost = new HttpHost(strings[0], Integer.parseInt(strings[1]), "http"); + httpHosts[i] = httpHost; + } + + return httpHosts; + } + + + + +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/EsProperties.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/EsProperties.java new file mode 100644 index 000000000..8030fc5d3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/EsProperties.java @@ -0,0 +1,20 @@ +package com.cf.imes.framework.es.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * es环境配置 + * @author there + */ +@ConfigurationProperties(prefix = "spring.elasticsearch") +public class EsProperties { + public String getUris() { + return uris; + } + + public void setUris(String uris) { + this.uris = uris; + } + + private String uris; +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/package-info.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/package-info.java new file mode 100644 index 000000000..68a6a9d26 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/config/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.es.config; \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/LocalDateTimeEsConverter.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/LocalDateTimeEsConverter.java new file mode 100644 index 000000000..275cdfec5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/LocalDateTimeEsConverter.java @@ -0,0 +1,59 @@ +package com.cf.imes.framework.es.core.convert; + +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Date; + +/** + * @author there + */ +@Slf4j +public class LocalDateTimeEsConverter implements PropertyValueConverter { + /** + * Converts a property value to an elasticsearch value. If the converter cannot convert the value, it must return a + * String representation. + * + * @param value the value to convert, must not be {@literal null} + * @return The elasticsearch property value, must not be {@literal null} + */ + @Override + public Object write(Object value) { + if(value instanceof LocalDateTime) { + LocalDateTime localDateTime = (LocalDateTime)value; + ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, ZoneId.of("GMT+8")); + return zonedDateTime.toInstant().toString(); + } + return value; + } + + /** + * Converts an elasticsearch property value to a property value. + * + * @param value the elasticsearch property value to convert, must not be {@literal null} + * @return The converted value, must not be {@literal null} + */ + @Override + public Object read(Object value) { + try { + String strDateTime = value.toString(); + Instant instant = null; + if(value.getClass().getName().contains("Long")){ + long longTime = (Long) value; + Date date = new Date(longTime); + instant =date.toInstant(); + } + else{ + instant = Instant.parse(strDateTime); + } + LocalDateTime localDateTime = LocalDateTime.ofInstant(instant,ZoneId.of("GMT+8")); + return localDateTime; + }catch (Exception e){ + log.error(e.getMessage(),e); + return null; + } + } +} \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/PropertyValueConverter.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/PropertyValueConverter.java new file mode 100644 index 000000000..866a89c78 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/PropertyValueConverter.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.es.core.convert; + +public interface PropertyValueConverter { + + /** + * Converts a property value to an elasticsearch value. If the converter cannot convert the value, it must return a + * String representation. + * + * @param value the value to convert, must not be {@literal null} + * @return The elasticsearch property value, must not be {@literal null} + */ + Object write(Object value); + + /** + * Converts an elasticsearch property value to a property value. + * + * @param value the elasticsearch property value to convert, must not be {@literal null} + * @return The converted value, must not be {@literal null} + */ + Object read(Object value); +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/ValueConverter.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/ValueConverter.java new file mode 100644 index 000000000..cd57cec8c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/convert/ValueConverter.java @@ -0,0 +1,19 @@ +package com.cf.imes.framework.es.core.convert; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Documented +@Inherited +public @interface ValueConverter { + + /** + * Defines the class implementing the {@link PropertyValueConverter} interface. If this is a normal class, it must + * provide a default constructor with no arguments. If this is an enum and thus implementing a singleton by enum it + * must only have one enum value. + * + * @return the class to use for conversion + */ + Class value(); +} \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/dal/ESDocument.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/dal/ESDocument.java new file mode 100644 index 000000000..206e041e5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/dal/ESDocument.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.es.core.dal; + + +import com.cf.imes.framework.es.core.valid.UpdateGroup; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; +import javax.validation.constraints.NotBlank; +import java.util.Date; +import static com.cf.imes.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.cf.imes.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +/** + * @author there + */ +@Data +public class ESDocument { + @NotBlank(groups = UpdateGroup.class, message = "文档ID不能空") + public String id; + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private Date createTime; + private Long creator; + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private Date updateTime; + private Long updater; + private Long organId; + +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/package-info.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/package-info.java new file mode 100644 index 000000000..22a4f9fe9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.es.core; \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/service/ESDocumentService.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/service/ESDocumentService.java new file mode 100644 index 000000000..31902865f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/service/ESDocumentService.java @@ -0,0 +1,102 @@ +package com.cf.imes.framework.es.core.service; + +import cn.hutool.json.JSONObject; +import co.elastic.clients.elasticsearch._types.Result; +import co.elastic.clients.elasticsearch.core.BulkResponse; +import co.elastic.clients.elasticsearch.core.IndexResponse; +import com.cf.imes.framework.es.core.dal.ESDocument; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * 公用es服务接口 + */ +public interface ESDocumentService { + + /** + * 新增一个文档,此种方式若发现没有索引,会自动创建一个索引 + * @param idxName 索引名 + * @param idxId 索引id + * @param document 文档对象 + */ + IndexResponse createByFluentDSL(String idxName, String idxId, ESDocument document) throws Exception; + + /** + * 新增一个文档,此种方式若发现没有索引,会自动创建一个索引 + * @param idxName 索引名 + * @param idxId 索引id + * @param document 文档对象 + */ + IndexResponse createByBuilderPattern(String idxName, String idxId, ESDocument document) throws Exception; + + /** + * 用JSON字符串创建文档,此种方式若发现没有索引,会自动创建一个索引 + * @param idxName 索引名 + * @param idxId 索引id + * @param jsonContent json字符串 + */ + IndexResponse createByJson(String idxName, String idxId, String jsonContent) throws Exception; + + + /** + * 异步新增文档,此种方式若发现没有索引,会自动创建一个索引 + * @param idxName 索引名 + * @param idxId 索引id + * @param document 文档 + * @param action 操作 + */ + void createAsync(String idxName, String idxId, T document, BiConsumer action); + + /** + * 批量增加文档 + * @param idxName 索引名 + * @param documents 要增加的对象集合 + * @return 批量操作的结果 + */ + BulkResponse bulkCreate(String idxName, List documents) throws Exception; + + + /** + * 根据文档id查找文档 + * @param idxName 索引名 + * @param docId 文档id + * @return Object类型的查找结果 + */ + T getById(String idxName, String docId ,Class tClass) throws IOException; + + /** + * + * @param idxName 索引名称 + * @param docId 文档id + * @param tClass 返回的类型 + * @param map 修改内容的map + */ + Result updateById(String idxName, String docId, Class tClass, Map map) throws IOException; + + /** + * 根据文档id查找文档,返回类型是ObjectNode + * @param idxName 索引名 + * @param docId 文档id + * @return ObjectNode类型的查找结果 + */ + JSONObject getObjectNodeById(String idxName, String docId) throws IOException; + + /** + * 根据文档id删除文档 + * @param idxName 索引名 + * @param docId 文档id + * @return Object类型的查找结果 + */ + Boolean deleteById(String idxName, String docId) throws IOException; + + /** + * 批量删除文档 + * @param idxName 索引名 + * @param docIds 要删除的文档id集合 + */ + BulkResponse bulkDeleteByIds(String idxName, List docIds) throws Exception; + +} \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/service/ESDocumentServiceImpl.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/service/ESDocumentServiceImpl.java new file mode 100644 index 000000000..25ca4d658 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/service/ESDocumentServiceImpl.java @@ -0,0 +1,215 @@ +package com.cf.imes.framework.es.core.service; + +import cn.hutool.core.lang.Snowflake; +import cn.hutool.core.lang.UUID; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import co.elastic.clients.elasticsearch.ElasticsearchAsyncClient; +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.Result; +import co.elastic.clients.elasticsearch.core.*; +import com.cf.imes.framework.es.core.dal.ESDocument; +import com.cf.imes.framework.security.core.LoginUser; +import com.cf.imes.framework.security.core.util.SecurityFrameworkUtils; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * @author there + */ +public class ESDocumentServiceImpl implements ESDocumentService{ + //同步客户端 + private final ElasticsearchClient elasticsearchClient; + + // 异步客户端 + private final ElasticsearchAsyncClient elasticsearchAsyncClient; + + private Snowflake snowflake = IdUtil.getSnowflake(); + + public ESDocumentServiceImpl (ElasticsearchClient elasticsearchClient, ElasticsearchAsyncClient elasticsearchAsyncClient) { + this.elasticsearchClient = elasticsearchClient; + this.elasticsearchAsyncClient = elasticsearchAsyncClient; + } + + @Override + public IndexResponse createByFluentDSL(String idxName, String idxId, ESDocument document) throws Exception { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + document.setCreator(loginUser.getId()); + document.setUpdater(loginUser.getId()); + document.setOrganId(loginUser.getOrganId()); + document.setCreateTime(new Date()); + document.setUpdateTime(new Date()); + if(StrUtil.isBlank(document.getId())) { + document.setId(snowflake.nextIdStr()); + } + return elasticsearchClient.index(idx -> idx + .index(idxName) + .id(document.getId()) + .document(document)); + } + + /** + * BuilderPattern 方式创建文档 + * @param idxName 索引名 + * @param idxId 索引id + * @param document 文档对象 + */ + @Override + public IndexResponse createByBuilderPattern(String idxName, String idxId, ESDocument document) throws Exception { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + document.setCreator(loginUser.getId()); + document.setUpdater(loginUser.getId()); + document.setOrganId(loginUser.getOrganId()); + document.setCreateTime(new Date()); + document.setUpdateTime(new Date()); + IndexRequest.Builder indexReqBuilder = new IndexRequest.Builder<>(); + indexReqBuilder.index(idxName); + if(StrUtil.isBlank(idxId)) { + idxId = snowflake.nextIdStr(); + } + indexReqBuilder.id(idxId); + indexReqBuilder.document(document); + return elasticsearchClient.index(indexReqBuilder.build()); + } + + /** + * json方式创建文档 + * @param idxName 索引名 + * @param idxId 索引id + * @param jsonContent json字符串 + */ + @Override + public IndexResponse createByJson(String idxName, String idxId, String jsonContent) throws Exception { + if(StrUtil.isBlank(idxId)) { + idxId = snowflake.nextIdStr(); + } + String finalIdxId = idxId; + return elasticsearchClient.index(i -> i + .index(idxName) + .id(finalIdxId) + .withJson(new StringReader(jsonContent)) + ); + } + + /** + * 异步方式创建文档 + * @param idxName 索引名 + * @param idxId 索引id + * @param document 文档 + * @param action 操作 + */ + @Override + public void createAsync(String idxName, String idxId, T document, BiConsumer action) { + elasticsearchAsyncClient.index(idx -> idx + .index(idxName) + .id(idxId) + .document(document) + ).whenComplete(action); + } + + /** + * 批量方式创建文档 + * @param idxName 索引名 + * @param documents 要增加的对象集合 + */ + @Override + public BulkResponse bulkCreate(String idxName, List documents) throws Exception { + BulkRequest.Builder br = new BulkRequest.Builder(); + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + documents.forEach(document ->{ + ESDocument esDocument = (ESDocument) document; + if(StrUtil.isBlank(esDocument.getId())) { + esDocument.setId(snowflake.nextIdStr()); + } + esDocument.setUpdater(loginUser.getId()); + esDocument.setUpdateTime(new Date()); + br.operations(op -> op.index(idx -> idx + .index(idxName) + .id(esDocument.getId().toString()) + .document(esDocument))); + }); + return elasticsearchClient.bulk(br.build()); + } + + /** + * + * @param idxName 索引名称 + * @param docId 文档id + * @param tClass 返回的类型 + * @param map 修改内容的map + * Map map = new HashMap<>(); + * map.put("age", 35); + * 把年龄改成35 + */ + @Override + public Result updateById(String idxName, String docId, Class tClass, Map map) throws IOException { + UpdateResponse response = elasticsearchClient.update(e -> e.index(idxName).id(docId).doc(map), tClass); + return response.result(); + } + + /** + * 文档id查询信息 + * @param idxName 索引名 + * @param docId 文档id + */ + @Override + public T getById(String idxName, String docId,Class tClass) throws IOException { + GetResponse response = elasticsearchClient.get(g -> g + .index(idxName) + .id(docId), + tClass); + return response.found() ? response.source() : null; + } + + /** + * 根据索引名称和文档id查询ObjectNode + * @param idxName 索引名 + * @param docId 文档id + */ + @Override + public JSONObject getObjectNodeById(String idxName, String docId) throws IOException { + GetResponse response = elasticsearchClient.get(g -> g + .index(idxName) + .id(docId), + JSONObject.class); + + return response.found() ? response.source() : null; + } + + /** + * 单条输出 + * @param idxName 索引名 + * @param docId 文档id + */ + @Override + public Boolean deleteById(String idxName, String docId) throws IOException { + DeleteResponse delete = elasticsearchClient.delete(d -> d + .index(idxName) + .id(docId)); + return delete.forcedRefresh(); + } + + /** + * 批量删除 + * @param idxName 索引名 + * @param docIds 要删除的文档id集合 + */ + @Override + public BulkResponse bulkDeleteByIds(String idxName, List docIds) throws Exception { + BulkRequest.Builder br = new BulkRequest.Builder(); + // 将每一个对象都放入builder中 + docIds.forEach(id -> br + .operations(op -> op + .delete(d -> d + .index(idxName) + .id(id)))); + return elasticsearchClient.bulk(br.build()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/valid/CreateGroup.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/valid/CreateGroup.java new file mode 100644 index 000000000..f5137a2db --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/valid/CreateGroup.java @@ -0,0 +1,10 @@ +package com.cf.imes.framework.es.core.valid; + +import javax.validation.groups.Default; + +/** + * @author there + * 新增组 + */ +public interface CreateGroup extends Default { +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/valid/UpdateGroup.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/valid/UpdateGroup.java new file mode 100644 index 000000000..0b1f18380 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/core/valid/UpdateGroup.java @@ -0,0 +1,10 @@ +package com.cf.imes.framework.es.core.valid; + +import javax.validation.groups.Default; + +/** + * @author there + * 修改组 + */ +public interface UpdateGroup extends Default { +} diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/package-info.java b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/package-info.java new file mode 100644 index 000000000..f4a1bc927 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/java/com/cf/imes/framework/es/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.es; \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..95a9ddfe5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-elasticsearch/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.es.config.ChenfengElasticsearchAutoConfiguration \ No newline at end of file diff --git a/cf-framework/cf-spring-boot-starter-env/pom.xml b/cf-framework/cf-spring-boot-starter-env/pom.xml new file mode 100644 index 000000000..36641c98d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/pom.xml @@ -0,0 +1,66 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-env + jar + + ${project.artifactId} + + 开发环境拓展,实现类似阿里的特性环境的能力 + 1. https://segmentfault.com/a/1190000018022987 + + https://github.com/YunaiV/ruoyi-vue-pro + + + ${java.version} + ${java.version} + + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework + spring-web + + + + jakarta.servlet + jakarta.servlet-api + + + + + org.springframework.cloud + spring-cloud-loadbalancer + + + io.github.openfeign + feign-core + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/ChenfengEnvRpcAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/ChenfengEnvRpcAutoConfiguration.java new file mode 100644 index 000000000..fe16b5fa9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/ChenfengEnvRpcAutoConfiguration.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.env.config; + +import com.cf.imes.framework.env.core.fegin.EnvLoadBalancerClientFactory; +import com.cf.imes.framework.env.core.fegin.EnvRequestInterceptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientSpecification; +import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.context.annotation.Bean; + +import java.util.Collections; +import java.util.List; + +/** + * 多环境的 RPC 组件的自动配置 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@EnableConfigurationProperties(EnvProperties.class) +public class ChenfengEnvRpcAutoConfiguration { + + // ========== Feign 相关 ========== + + /** + * 创建 {@link EnvLoadBalancerClientFactory} Bean + * + * 参考 {@link LoadBalancerAutoConfiguration#loadBalancerClientFactory(LoadBalancerClientsProperties)} 方法 + */ + @Bean + public LoadBalancerClientFactory loadBalancerClientFactory(LoadBalancerClientsProperties properties, + ObjectProvider> configurations) { + EnvLoadBalancerClientFactory clientFactory = new EnvLoadBalancerClientFactory(properties); + clientFactory.setConfigurations(configurations.getIfAvailable(Collections::emptyList)); + return clientFactory; + } + + @Bean + public EnvRequestInterceptor envRequestInterceptor() { + return new EnvRequestInterceptor(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/ChenfengEnvWebAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/ChenfengEnvWebAutoConfiguration.java new file mode 100644 index 000000000..d05a5d124 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/ChenfengEnvWebAutoConfiguration.java @@ -0,0 +1,32 @@ +package com.cf.imes.framework.env.config; + +import com.cf.imes.framework.common.enums.WebFilterOrderEnum; +import com.cf.imes.framework.env.core.web.EnvWebFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * 多环境的 Web 组件的自动配置 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@EnableConfigurationProperties(EnvProperties.class) +public class ChenfengEnvWebAutoConfiguration { + + /** + * 创建 {@link EnvWebFilter} Bean + */ + @Bean + public FilterRegistrationBean envWebFilterFilter() { + EnvWebFilter filter = new EnvWebFilter(); + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.setOrder(WebFilterOrderEnum.ENV_TAG_FILTER); + return bean; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/EnvEnvironmentPostProcessor.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/EnvEnvironmentPostProcessor.java new file mode 100644 index 000000000..0874e1dfa --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/EnvEnvironmentPostProcessor.java @@ -0,0 +1,48 @@ +package com.cf.imes.framework.env.config; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.collection.SetUtils; +import com.cf.imes.framework.env.core.util.EnvUtils; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +import java.util.Set; + +/** + * 多环境的 {@link EnvEnvironmentPostProcessor} 实现类 + * 将 chenfeng.env.tag 设置到 nacos 等组件对应的 tag 配置项,当且仅当它们不存在时 + * + * @author 晨丰科技 + */ +public class EnvEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final Set TARGET_TAG_KEYS = SetUtils.asSet( + "spring.cloud.nacos.discovery.metadata.tag" // Nacos 注册中心 + // MQ TODO + ); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 0. 设置 ${HOST_NAME} 兜底的环境变量 + String hostNameKey = StrUtil.subBetween(EnvUtils.HOST_NAME_VALUE, "{", "}"); + if (!environment.containsProperty(hostNameKey)) { + environment.getSystemProperties().put(hostNameKey, EnvUtils.getHostName()); + } + + // 1.1 如果没有 chenfeng.env.tag 配置项,则不进行配置项的修改 + String tag = EnvUtils.getTag(environment); + if (StrUtil.isEmpty(tag)) { + return; + } + // 1.2 需要修改的配置项 + for (String targetTagKey : TARGET_TAG_KEYS) { + String targetTagValue = environment.getProperty(targetTagKey); + if (StrUtil.isNotEmpty(targetTagValue)) { + continue; + } + environment.getSystemProperties().put(targetTagKey, tag); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/EnvProperties.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/EnvProperties.java new file mode 100644 index 000000000..144eb304d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/config/EnvProperties.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.env.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 环境配置 + * + * @author 晨丰科技 + */ +@ConfigurationProperties(prefix = "chenfeng.env") +@Data +public class EnvProperties { + + public static final String TAG_KEY = "chenfeng.env.tag"; + + /** + * 环境标签 + */ + private String tag; + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/context/EnvContextHolder.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/context/EnvContextHolder.java new file mode 100644 index 000000000..46222e5b4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/context/EnvContextHolder.java @@ -0,0 +1,39 @@ +package com.cf.imes.framework.env.core.context; + +import cn.hutool.core.collection.CollUtil; +import com.alibaba.ttl.TransmittableThreadLocal; + +import java.util.ArrayList; +import java.util.List; + +/** + * 开发环境上下文 + * + * @author 晨丰科技 + */ +public class EnvContextHolder { + + /** + * 标签的上下文 + * + * 使用 {@link List} 的原因,可能存在多层设置或者清理 + */ + private static final ThreadLocal> TAG_CONTEXT = TransmittableThreadLocal.withInitial(ArrayList::new); + + public static void setTag(String tag) { + TAG_CONTEXT.get().add(tag); + } + + public static String getTag() { + return CollUtil.getLast(TAG_CONTEXT.get()); + } + + public static void removeTag() { + List tags = TAG_CONTEXT.get(); + if (CollUtil.isEmpty(tags)) { + return; + } + tags.remove(tags.size() - 1); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvLoadBalancerClient.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvLoadBalancerClient.java new file mode 100644 index 000000000..9a9246394 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvLoadBalancerClient.java @@ -0,0 +1,83 @@ +package com.cf.imes.framework.env.core.fegin; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.collection.CollectionUtils; +import com.cf.imes.framework.env.core.context.EnvContextHolder; +import com.cf.imes.framework.env.core.util.EnvUtils; +import com.alibaba.cloud.nacos.balancer.NacosBalancer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +import reactor.core.publisher.Mono; + +import java.util.List; + +/** + * 多环境的 {@link org.springframework.cloud.client.loadbalancer.LoadBalancerClient} 实现类 + * 在从服务实例列表选择时,优先选择 tag 匹配的服务实例 + * + * @author 晨丰科技 + */ +@RequiredArgsConstructor +@Slf4j +public class EnvLoadBalancerClient implements ReactorServiceInstanceLoadBalancer { + + /** + * 用于获取 serviceId 对应的服务实例的列表 + */ + private final ObjectProvider serviceInstanceListSupplierProvider; + /** + * 需要获取的服务实例名 + * + * 暂时用于打印 logger 日志 + */ + private final String serviceId; + /** + * 被代理的 ReactiveLoadBalancer 对象 + */ + private final ReactiveLoadBalancer reactiveLoadBalancer; + + @Override + public Mono> choose(Request request) { + // 情况一,没有 tag 时,使用默认的 reactiveLoadBalancer 实现负载均衡 + String tag = EnvContextHolder.getTag(); + if (StrUtil.isEmpty(tag)) { + return Mono.from(reactiveLoadBalancer.choose(request)); + } + + // 情况二,有 tag 时,使用 tag 匹配服务实例 + ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new); + return supplier.get(request).next().map(list -> getInstanceResponse(list, tag)); + } + + private Response getInstanceResponse(List instances, String tag) { + // 如果服务实例为空,则直接返回 + if (CollUtil.isEmpty(instances)) { + log.warn("[getInstanceResponse][serviceId({}) 服务实例列表为空]", serviceId); + return new EmptyResponse(); + } + + // 筛选满足条件的实例列表 + List chooseInstances = CollectionUtils.filterList(instances, instance -> tag.equals(EnvUtils.getTag(instance))); + if (CollUtil.isEmpty(chooseInstances)) { + log.warn("[getInstanceResponse][serviceId({}) 没有满足 tag({}) 的服务实例列表,直接使用所有服务实例列表]", serviceId, tag); + chooseInstances = instances; + } + + // TODO 晨丰:https://juejin.cn/post/7056770721858469896 想通网段 + + // 随机 + 权重获取实例列表 TODO 晨丰:目前直接使用 Nacos 提供的方法,如果替换注册中心,需要重新失败该方法 + return new DefaultResponse(NacosBalancer.getHostByRandomWeight3(chooseInstances)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvLoadBalancerClientFactory.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvLoadBalancerClientFactory.java new file mode 100644 index 000000000..98ec49f46 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvLoadBalancerClientFactory.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.env.core.fegin; + + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; + +/** + * 多环境的 {@link LoadBalancerClientFactory} 实现类 + * 目的:在创建 {@link ReactiveLoadBalancer} 时,会额外增加 {@link EnvLoadBalancerClient} 代理,用于 tag 过滤服务实例 + * + * @author 晨丰科技 + */ +public class EnvLoadBalancerClientFactory extends LoadBalancerClientFactory { + + public EnvLoadBalancerClientFactory(LoadBalancerClientsProperties properties) { + super(properties); + } + + @Override + public ReactiveLoadBalancer getInstance(String serviceId) { + ReactiveLoadBalancer reactiveLoadBalancer = super.getInstance(serviceId); + // 参考 {@link com.alibaba.cloud.nacos.loadbalancer.NacosLoadBalancerClientConfiguration#nacosLoadBalancer(Environment, LoadBalancerClientFactory, NacosDiscoveryProperties)} 方法 + return new EnvLoadBalancerClient(super.getLazyProvider(serviceId, ServiceInstanceListSupplier.class), + serviceId, reactiveLoadBalancer); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvRequestInterceptor.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvRequestInterceptor.java new file mode 100644 index 000000000..e53175c64 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/fegin/EnvRequestInterceptor.java @@ -0,0 +1,24 @@ +package com.cf.imes.framework.env.core.fegin; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.env.core.context.EnvContextHolder; +import com.cf.imes.framework.env.core.util.EnvUtils; +import feign.RequestInterceptor; +import feign.RequestTemplate; + +/** + * 多环境的 {@link RequestInterceptor} 实现类:Feign 请求时,将 tag 设置到 header 中,继续透传给被调用的服务 + * + * @author 晨丰科技 + */ +public class EnvRequestInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate requestTemplate) { + String tag = EnvContextHolder.getTag(); + if (StrUtil.isNotEmpty(tag)) { + EnvUtils.setTag(requestTemplate, tag); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/package-info.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/package-info.java new file mode 100644 index 000000000..54b38066e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.env.core; diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/util/EnvUtils.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/util/EnvUtils.java new file mode 100644 index 000000000..2f5b82693 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/util/EnvUtils.java @@ -0,0 +1,56 @@ +package com.cf.imes.framework.env.core.util; + +import com.cf.imes.framework.env.config.EnvProperties; +import feign.RequestTemplate; +import lombok.SneakyThrows; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.core.env.Environment; + +import javax.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.util.Objects; + +/** + * 环境 Utils + * + * @author 晨丰科技 + */ +public class EnvUtils { + + private static final String HEADER_TAG = "tag"; + + public static final String HOST_NAME_VALUE = "${HOSTNAME}"; + + public static String getTag(HttpServletRequest request) { + String tag = request.getHeader(HEADER_TAG); + // 如果请求的是 "${HOSTNAME}",则解析成对应的本地主机名 + // 目的:特殊逻辑,解决 IDEA Rest Client 不支持环境变量的读取,所以就服务器来做 + return Objects.equals(tag, HOST_NAME_VALUE) ? getHostName() : tag; + } + + public static String getTag(ServiceInstance instance) { + return instance.getMetadata().get(HEADER_TAG); + } + + public static String getTag(Environment environment) { + String tag = environment.getProperty(EnvProperties.TAG_KEY); + // 如果请求的是 "${HOSTNAME}",则解析成对应的本地主机名 + // 目的:特殊逻辑,解决 IDEA Rest Client 不支持环境变量的读取,所以就服务器来做 + return Objects.equals(tag, HOST_NAME_VALUE) ? getHostName() : tag; + } + + public static void setTag(RequestTemplate requestTemplate, String tag) { + requestTemplate.header(HEADER_TAG, tag); + } + + /** + * 获得 hostname 主机名 + * + * @return 主机名 + */ + @SneakyThrows + public static String getHostName() { + return InetAddress.getLocalHost().getHostName(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/web/EnvWebFilter.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/web/EnvWebFilter.java new file mode 100644 index 000000000..e3a1b554a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/core/web/EnvWebFilter.java @@ -0,0 +1,41 @@ +package com.cf.imes.framework.env.core.web; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.env.core.context.EnvContextHolder; +import com.cf.imes.framework.env.core.util.EnvUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 环境的 {@link javax.servlet.Filter} 实现类 + * 当有 tag 请求头时,设置到 {@link EnvContextHolder} 的标签上下文 + * + * @author 晨丰科技 + */ +public class EnvWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 如果没有 tag,则走默认的流程 + String tag = EnvUtils.getTag(request); + if (StrUtil.isEmpty(tag)) { + chain.doFilter(request, response); + return; + } + + // 如果有 tag,则设置到上下文 + EnvContextHolder.setTag(tag); + try { + chain.doFilter(request, response); + } finally { + EnvContextHolder.removeTag(); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/package-info.java b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/package-info.java new file mode 100644 index 000000000..73c722b3b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/java/com/cf/imes/framework/env/package-info.java @@ -0,0 +1,7 @@ +/** + * 开发环境拓展,实现类似阿里的特性环境的能力 + * 1. https://segmentfault.com/a/1190000018022987 + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.env; diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/resources/META-INF/spring.factories b/cf-framework/cf-spring-boot-starter-env/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..69ad87bd9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + com.cf.imes.framework.env.config.EnvEnvironmentPostProcessor diff --git a/cf-framework/cf-spring-boot-starter-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..da4bb1087 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.env.config.ChenfengEnvWebAutoConfiguration +com.cf.imes.framework.env.config.ChenfengEnvRpcAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-excel/pom.xml b/cf-framework/cf-spring-boot-starter-excel/pom.xml new file mode 100644 index 000000000..aad401643 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/pom.xml @@ -0,0 +1,50 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-excel + jar + + ${project.artifactId} + Excel 拓展 + + + + com.cf.imes + cf-common + + + + + com.cf.imes + cf-spring-boot-starter-biz-dict + true + + + + + org.springframework + spring-web + provided + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + + com.alibaba + easyexcel + + + + diff --git a/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/annotations/DictFormat.java b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/annotations/DictFormat.java new file mode 100644 index 000000000..82de2165a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/annotations/DictFormat.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.excel.core.annotations; + +import java.lang.annotation.*; + +/** + * 字典格式化 + * + * 实现将字典数据的值,格式化成字典数据的标签 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface DictFormat { + + /** + * 例如说,SysDictTypeConstants、InfDictTypeConstants + * + * @return 字典类型 + */ + String value(); + +} diff --git a/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/DictConvert.java b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/DictConvert.java new file mode 100644 index 000000000..4ad843a35 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/DictConvert.java @@ -0,0 +1,72 @@ +package com.cf.imes.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import com.cf.imes.framework.dict.core.util.DictFrameworkUtils; +import com.cf.imes.framework.excel.core.annotations.DictFormat; +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.ReadCellData; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * Excel 数据字典转换器 + * + * @author 晨丰科技 + */ +@Slf4j +public class DictConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public Object convertToJavaData(ReadCellData readCellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 使用字典解析 + String type = getType(contentProperty); + String label = readCellData.getStringValue(); + String value = DictFrameworkUtils.parseDictDataValue(type, label); + if (value == null) { + log.error("[convertToJavaData][type({}) 解析不掉 label({})]", type, label); + return null; + } + // 将 String 的 value 转换成对应的属性 + Class fieldClazz = contentProperty.getField().getType(); + return Convert.convert(fieldClazz, value); + } + + @Override + public WriteCellData convertToExcelData(Object object, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 空时,返回空 + if (object == null) { + return new WriteCellData<>(""); + } + + // 使用字典格式化 + String type = getType(contentProperty); + String value = String.valueOf(object); + String label = DictFrameworkUtils.getDictDataLabel(type, value); + if (label == null) { + log.error("[convertToExcelData][type({}) 转换不了 label({})]", type, value); + return new WriteCellData<>(""); + } + // 生成 Excel 小表格 + return new WriteCellData<>(label); + } + + private static String getType(ExcelContentProperty contentProperty) { + return contentProperty.getField().getAnnotation(DictFormat.class).value(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/JsonConvert.java b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/JsonConvert.java new file mode 100644 index 000000000..d620487b4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/JsonConvert.java @@ -0,0 +1,34 @@ +package com.cf.imes.framework.excel.core.convert; + +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; + +/** + * Excel Json 转换器 + * + * @author 晨丰科技 + */ +public class JsonConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public WriteCellData convertToExcelData(Object value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 生成 Excel 小表格 + return new WriteCellData<>(JsonUtils.toJsonString(value)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/MoneyConvert.java b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/MoneyConvert.java new file mode 100644 index 000000000..6fce79359 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/convert/MoneyConvert.java @@ -0,0 +1,39 @@ +package com.cf.imes.framework.excel.core.convert; + +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 金额转换器 + * + * 金额单位:分 + * + * @author 晨丰科技 + */ +public class MoneyConvert implements Converter { + + @Override + public Class supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public WriteCellData convertToExcelData(Integer value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + BigDecimal result = BigDecimal.valueOf(value) + .divide(new BigDecimal(100), 2, RoundingMode.HALF_UP); + return new WriteCellData<>(result.toString()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/util/ExcelUtils.java b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/util/ExcelUtils.java new file mode 100644 index 000000000..420bfa514 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/core/util/ExcelUtils.java @@ -0,0 +1,51 @@ +package com.cf.imes.framework.excel.core.util; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.converters.longconverter.LongStringConverter; +import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Excel 工具类 + * + * @author 晨丰科技 + */ +public class ExcelUtils { + + /** + * 将列表以 Excel 响应给前端 + * + * @param response 响应 + * @param filename 文件名 + * @param sheetName Excel sheet 名 + * @param head Excel head 头 + * @param data 数据列表哦 + * @param 泛型,保证 head 和 data 类型的一致性 + * @throws IOException 写入失败的情况 + */ + public static void write(HttpServletResponse response, String filename, String sheetName, + Class head, List data) throws IOException { + // 输出 Excel + EasyExcel.write(response.getOutputStream(), head) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度 + .registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度 + .sheet(sheetName).doWrite(data); + // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 + response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8.name())); + response.setContentType("application/vnd.ms-excel;charset=UTF-8"); + } + + public static List read(MultipartFile file, Class head) throws IOException { + return EasyExcel.read(file.getInputStream(), head, null) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .doReadAllSync(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/package-info.java b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/package-info.java new file mode 100644 index 000000000..15172da45 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-excel/src/main/java/com/cf/imes/framework/excel/package-info.java @@ -0,0 +1,4 @@ +/** + * 基于 EasyExcel 实现 Excel 相关的操作 + */ +package com.cf.imes.framework.excel; diff --git a/cf-framework/cf-spring-boot-starter-file/pom.xml b/cf-framework/cf-spring-boot-starter-file/pom.xml new file mode 100644 index 000000000..5b73b002b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/pom.xml @@ -0,0 +1,82 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + cf-spring-boot-starter-file + + ${project.artifactId} + 文件客户端,支持多种存储器 + 1. file:本地磁盘 + 2. ftp:FTP 服务器 + 2. sftp:SFTP 服务器 + 4. db:数据库 + 5. s3:支持 S3 协议的云存储服务,例如说 MinIO、阿里云、华为云、腾讯云、七牛云等等 + + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.slf4j + slf4j-api + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + + commons-net + commons-net + + + com.jcraft + jsch + + + + org.apache.tika + tika-core + + + + + io.minio + minio + + + + + com.cf.imes + cf-spring-boot-starter-test + test + + + + diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/config/ChenfengFileAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/config/ChenfengFileAutoConfiguration.java new file mode 100644 index 000000000..f582a223c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/config/ChenfengFileAutoConfiguration.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.file.config; + +import com.cf.imes.framework.file.core.client.FileClientFactory; +import com.cf.imes.framework.file.core.client.FileClientFactoryImpl; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * 文件配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +public class ChenfengFileAutoConfiguration { + + @Bean + public FileClientFactory fileClientFactory() { + return new FileClientFactoryImpl(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/AbstractFileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/AbstractFileClient.java new file mode 100644 index 000000000..4fd88e1f2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/AbstractFileClient.java @@ -0,0 +1,69 @@ +package com.cf.imes.framework.file.core.client; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; + +/** + * 文件客户端的抽象类,提供模板方法,减少子类的冗余代码 + * + * @author 晨丰科技 + */ +@Slf4j +public abstract class AbstractFileClient implements FileClient { + + /** + * 配置编号 + */ + private final Long id; + /** + * 文件配置 + */ + protected Config config; + + public AbstractFileClient(Long id, Config config) { + this.id = id; + this.config = config; + } + + /** + * 初始化 + */ + public final void init() { + doInit(); + log.debug("[init][配置({}) 初始化完成]", config); + } + + /** + * 自定义初始化 + */ + protected abstract void doInit(); + + public final void refresh(Config config) { + // 判断是否更新 + if (config.equals(this.config)) { + return; + } + log.info("[refresh][配置({})发生变化,重新初始化]", config); + this.config = config; + // 初始化 + this.init(); + } + + @Override + public Long getId() { + return id; + } + + /** + * 格式化文件的 URL 访问地址 + * 使用场景:local、ftp、db,通过 FileController 的 getFile 来获取文件内容 + * + * @param domain 自定义域名 + * @param path 文件路径 + * @return URL 访问地址 + */ + protected String formatFileUrl(String domain, String path) { + return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClient.java new file mode 100644 index 000000000..109a97eed --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClient.java @@ -0,0 +1,43 @@ +package com.cf.imes.framework.file.core.client; + +/** + * 文件客户端 + * + * @author 晨丰科技 + */ +public interface FileClient { + + /** + * 获得客户端编号 + * + * @return 客户端编号 + */ + Long getId(); + + /** + * 上传文件 + * + * @param content 文件流 + * @param path 相对路径 + * @return 完整路径,即 HTTP 访问地址 + * @throws Exception 上传文件时,抛出 Exception 异常 + */ + String upload(byte[] content, String path, String type) throws Exception; + + /** + * 删除文件 + * + * @param path 相对路径 + * @throws Exception 删除文件时,抛出 Exception 异常 + */ + void delete(String path) throws Exception; + + /** + * 获得文件的内容 + * + * @param path 相对路径 + * @return 文件的内容 + */ + byte[] getContent(String path) throws Exception; + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientConfig.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientConfig.java new file mode 100644 index 000000000..f45ad11ec --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientConfig.java @@ -0,0 +1,16 @@ +package com.cf.imes.framework.file.core.client; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * 文件客户端的配置 + * 不同实现的客户端,需要不同的配置,通过子类来定义 + * + * @author 晨丰科技 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +// @JsonTypeInfo 注解的作用,Jackson 多态 +// 1. 序列化到时数据库时,增加 @class 属性。 +// 2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型 +public interface FileClientConfig { +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientFactory.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientFactory.java new file mode 100644 index 000000000..fae6f87c2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientFactory.java @@ -0,0 +1,24 @@ +package com.cf.imes.framework.file.core.client; + +import com.cf.imes.framework.file.core.enums.FileStorageEnum; + +public interface FileClientFactory { + + /** + * 获得文件客户端 + * + * @param configId 配置编号 + * @return 文件客户端 + */ + FileClient getFileClient(Long configId); + + /** + * 创建文件客户端 + * + * @param configId 配置编号 + * @param storage 存储器的枚举 {@link FileStorageEnum} + * @param config 文件配置 + */ + void createOrUpdateFileClient(Long configId, Integer storage, Config config); + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientFactoryImpl.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientFactoryImpl.java new file mode 100644 index 000000000..8cf5ffbab --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/FileClientFactoryImpl.java @@ -0,0 +1,56 @@ +package com.cf.imes.framework.file.core.client; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.file.core.enums.FileStorageEnum; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 文件客户端的工厂实现类 + * + * @author 晨丰科技 + */ +@Slf4j +public class FileClientFactoryImpl implements FileClientFactory { + + /** + * 文件客户端 Map + * key:配置编号 + */ + private final ConcurrentMap> clients = new ConcurrentHashMap<>(); + + @Override + public FileClient getFileClient(Long configId) { + AbstractFileClient client = clients.get(configId); + if (client == null) { + log.error("[getFileClient][配置编号({}) 找不到客户端]", configId); + } + return client; + } + + @Override + @SuppressWarnings("unchecked") + public void createOrUpdateFileClient(Long configId, Integer storage, Config config) { + AbstractFileClient client = (AbstractFileClient) clients.get(configId); + if (client == null) { + client = this.createFileClient(configId, storage, config); + client.init(); + clients.put(client.getId(), client); + } else { + client.refresh(config); + } + } + + @SuppressWarnings("unchecked") + private AbstractFileClient createFileClient( + Long configId, Integer storage, Config config) { + FileStorageEnum storageEnum = FileStorageEnum.getByStorage(storage); + Assert.notNull(storageEnum, String.format("文件配置(%s) 为空", storageEnum)); + // 创建客户端 + return (AbstractFileClient) ReflectUtil.newInstance(storageEnum.getClientClass(), configId, config); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileClient.java new file mode 100644 index 000000000..14cbf542c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileClient.java @@ -0,0 +1,48 @@ +package com.cf.imes.framework.file.core.client.db; + +import cn.hutool.extra.spring.SpringUtil; +import com.cf.imes.framework.file.core.client.AbstractFileClient; + +/** + * 基于 DB 存储的文件客户端的配置类 + * + * @author 晨丰科技 + */ +public class DBFileClient extends AbstractFileClient { + + private DBFileContentFrameworkDAO dao; + + public DBFileClient(Long id, DBFileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + } + + @Override + public String upload(byte[] content, String path, String type) { + getDao().insert(getId(), path, content); + // 拼接返回路径 + return super.formatFileUrl(config.getDomain(), path); + } + + @Override + public void delete(String path) { + getDao().delete(getId(), path); + } + + @Override + public byte[] getContent(String path) { + return getDao().selectContent(getId(), path); + } + + private DBFileContentFrameworkDAO getDao() { + // 延迟获取,因为 SpringUtil 初始化太慢 + if (dao == null) { + dao = SpringUtil.getBean(DBFileContentFrameworkDAO.class); + } + return dao; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileClientConfig.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileClientConfig.java new file mode 100644 index 000000000..4757a2c09 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileClientConfig.java @@ -0,0 +1,24 @@ +package com.cf.imes.framework.file.core.client.db; + +import com.cf.imes.framework.file.core.client.FileClientConfig; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.NotEmpty; + +/** + * 基于 DB 存储的文件客户端的配置类 + * + * @author 晨丰科技 + */ +@Data +public class DBFileClientConfig implements FileClientConfig { + + /** + * 自定义域名 + */ + @NotEmpty(message = "domain 不能为空") + @URL(message = "domain 必须是 URL 格式") + private String domain; + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileContentFrameworkDAO.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileContentFrameworkDAO.java new file mode 100644 index 000000000..3eafeeaf3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/db/DBFileContentFrameworkDAO.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.file.core.client.db; + +/** + * 文件内容 Framework DAO 接口 + * + * @author 晨丰科技 + */ +public interface DBFileContentFrameworkDAO { + + /** + * 插入文件内容 + * + * @param configId 配置编号 + * @param path 路径 + * @param content 内容 + */ + void insert(Long configId, String path, byte[] content); + + /** + * 删除文件内容 + * + * @param configId 配置编号 + * @param path 路径 + */ + void delete(Long configId, String path); + + /** + * 获得文件内容 + * + * @param configId 配置编号 + * @param path 路径 + * @return 内容 + */ + byte[] selectContent(Long configId, String path); + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClient.java new file mode 100644 index 000000000..fe21d0f19 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClient.java @@ -0,0 +1,77 @@ +package com.cf.imes.framework.file.core.client.ftp; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.CharsetUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.ftp.Ftp; +import cn.hutool.extra.ftp.FtpException; +import cn.hutool.extra.ftp.FtpMode; +import com.cf.imes.framework.file.core.client.AbstractFileClient; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +/** + * Ftp 文件客户端 + * + * @author 晨丰科技 + */ +public class FtpFileClient extends AbstractFileClient { + + private Ftp ftp; + + public FtpFileClient(Long id, FtpFileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + // 把配置的 \ 替换成 /, 如果路径配置 \a\test, 替换成 /a/test, 替换方法已经处理 null 情况 + config.setBasePath(StrUtil.replace(config.getBasePath(), StrUtil.BACKSLASH, StrUtil.SLASH)); + // ftp的路径是 / 结尾 + if (!config.getBasePath().endsWith(StrUtil.SLASH)) { + config.setBasePath(config.getBasePath() + StrUtil.SLASH); + } + // 初始化 Ftp 对象 + this.ftp = new Ftp(config.getHost(), config.getPort(), config.getUsername(), config.getPassword(), + CharsetUtil.CHARSET_UTF_8, null, null, FtpMode.valueOf(config.getMode())); + } + + @Override + public String upload(byte[] content, String path, String type) { + // 执行写入 + String filePath = getFilePath(path); + String fileName = FileUtil.getName(filePath); + String dir = StrUtil.removeSuffix(filePath, fileName); + ftp.reconnectIfTimeout(); + boolean success = ftp.upload(dir, fileName, new ByteArrayInputStream(content)); + if (!success) { + throw new FtpException(StrUtil.format("上传文件到目标目录 ({}) 失败", filePath)); + } + // 拼接返回路径 + return super.formatFileUrl(config.getDomain(), path); + } + + @Override + public void delete(String path) { + String filePath = getFilePath(path); + ftp.reconnectIfTimeout(); + ftp.delFile(filePath); + } + + @Override + public byte[] getContent(String path) { + String filePath = getFilePath(path); + String fileName = FileUtil.getName(filePath); + String dir = StrUtil.removeSuffix(filePath, fileName); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftp.reconnectIfTimeout(); + ftp.download(dir, fileName, out); + return out.toByteArray(); + } + + private String getFilePath(String path) { + return config.getBasePath() + path; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClientConfig.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClientConfig.java new file mode 100644 index 000000000..a36706a52 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClientConfig.java @@ -0,0 +1,59 @@ +package com.cf.imes.framework.file.core.client.ftp; + +import com.cf.imes.framework.file.core.client.FileClientConfig; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * Ftp 文件客户端的配置类 + * + * @author 晨丰科技 + */ +@Data +public class FtpFileClientConfig implements FileClientConfig { + + /** + * 基础路径 + */ + @NotEmpty(message = "基础路径不能为空") + private String basePath; + + /** + * 自定义域名 + */ + @NotEmpty(message = "domain 不能为空") + @URL(message = "domain 必须是 URL 格式") + private String domain; + + /** + * 主机地址 + */ + @NotEmpty(message = "host 不能为空") + private String host; + /** + * 主机端口 + */ + @NotNull(message = "port 不能为空") + private Integer port; + /** + * 用户名 + */ + @NotEmpty(message = "用户名不能为空") + private String username; + /** + * 密码 + */ + @NotEmpty(message = "密码不能为空") + private String password; + /** + * 连接模式 + * + * 使用 {@link cn.hutool.extra.ftp.FtpMode} 对应的字符串 + */ + @NotEmpty(message = "连接模式不能为空") + private String mode; + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/local/LocalFileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/local/LocalFileClient.java new file mode 100644 index 000000000..9cd4d9aa9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/local/LocalFileClient.java @@ -0,0 +1,52 @@ +package com.cf.imes.framework.file.core.client.local; + +import cn.hutool.core.io.FileUtil; +import com.cf.imes.framework.file.core.client.AbstractFileClient; + +import java.io.File; + +/** + * 本地文件客户端 + * + * @author 晨丰科技 + */ +public class LocalFileClient extends AbstractFileClient { + + public LocalFileClient(Long id, LocalFileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + // 补全风格。例如说 Linux 是 /,Windows 是 \ + if (!config.getBasePath().endsWith(File.separator)) { + config.setBasePath(config.getBasePath() + File.separator); + } + } + + @Override + public String upload(byte[] content, String path, String type) { + // 执行写入 + String filePath = getFilePath(path); + FileUtil.writeBytes(content, filePath); + // 拼接返回路径 + return super.formatFileUrl(config.getDomain(), path); + } + + @Override + public void delete(String path) { + String filePath = getFilePath(path); + FileUtil.del(filePath); + } + + @Override + public byte[] getContent(String path) { + String filePath = getFilePath(path); + return FileUtil.readBytes(filePath); + } + + private String getFilePath(String path) { + return config.getBasePath() + path; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/local/LocalFileClientConfig.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/local/LocalFileClientConfig.java new file mode 100644 index 000000000..13d8d7663 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/local/LocalFileClientConfig.java @@ -0,0 +1,30 @@ +package com.cf.imes.framework.file.core.client.local; + +import com.cf.imes.framework.file.core.client.FileClientConfig; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.NotEmpty; + +/** + * 本地文件客户端的配置类 + * + * @author 晨丰科技 + */ +@Data +public class LocalFileClientConfig implements FileClientConfig { + + /** + * 基础路径 + */ + @NotEmpty(message = "基础路径不能为空") + private String basePath; + + /** + * 自定义域名 + */ + @NotEmpty(message = "domain 不能为空") + @URL(message = "domain 必须是 URL 格式") + private String domain; + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/s3/S3FileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/s3/S3FileClient.java new file mode 100644 index 000000000..520a88d99 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/s3/S3FileClient.java @@ -0,0 +1,120 @@ +package com.cf.imes.framework.file.core.client.s3; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import com.cf.imes.framework.file.core.client.AbstractFileClient; +import io.minio.*; + +import java.io.ByteArrayInputStream; + +import static com.cf.imes.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_ALIYUN; +import static com.cf.imes.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_TENCENT; + +/** + * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 + *

+ * S3 协议的客户端,采用亚马逊提供的 software.amazon.awssdk.s3 库 + * + * @author 晨丰科技 + */ +public class S3FileClient extends AbstractFileClient { + + private MinioClient client; + + public S3FileClient(Long id, S3FileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + // 补全 domain + if (StrUtil.isEmpty(config.getDomain())) { + config.setDomain(buildDomain()); + } + // 初始化客户端 + client = MinioClient.builder() + .endpoint(buildEndpointURL()) // Endpoint URL + .region(buildRegion()) // Region + .credentials(config.getAccessKey(), config.getAccessSecret()) // 认证密钥 + .build(); + } + + /** + * 基于 endpoint 构建调用云服务的 URL 地址 + * + * @return URI 地址 + */ + private String buildEndpointURL() { + // 如果已经是 http 或者 https,则不进行拼接.主要适配 MinIO + if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { + return config.getEndpoint(); + } + return StrUtil.format("https://{}", config.getEndpoint()); + } + + /** + * 基于 bucket + endpoint 构建访问的 Domain 地址 + * + * @return Domain 地址 + */ + private String buildDomain() { + // 如果已经是 http 或者 https,则不进行拼接.主要适配 MinIO + if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { + return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket()); + } + // 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名 + return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint()); + } + + /** + * 基于 bucket 构建 region 地区 + * + * @return region 地区 + */ + private String buildRegion() { + // 阿里云必须有 region,否则会报错 + if (config.getEndpoint().contains(ENDPOINT_ALIYUN)) { + return StrUtil.subBefore(config.getEndpoint(), '.', false) + .replaceAll("-internal", "")// 去除内网 Endpoint 的后缀 + .replaceAll("https://", ""); + } + // 腾讯云必须有 region,否则会报错 + if (config.getEndpoint().contains(ENDPOINT_TENCENT)) { + return StrUtil.subAfter(config.getEndpoint(), "cos.", false) + .replaceAll("." + ENDPOINT_TENCENT, ""); // 去除 Endpoint + } + return null; + } + + @Override + public String upload(byte[] content, String path, String type) throws Exception { + // 执行上传 + client.putObject(PutObjectArgs.builder() + .bucket(config.getBucket()) // bucket 必须传递 + .contentType(type) + .object(path) // 相对路径作为 key + .stream(new ByteArrayInputStream(content), content.length, -1) // 文件内容 + .build()); + // 拼接返回路径 + return config.getDomain() + "/" + path; + } + + @Override + public void delete(String path) throws Exception { + client.removeObject(RemoveObjectArgs.builder() + .bucket(config.getBucket()) // bucket 必须传递 + .object(path) // 相对路径作为 key + .build()); + } + + @Override + public byte[] getContent(String path) throws Exception { + GetObjectResponse response = client.getObject(GetObjectArgs.builder() + .bucket(config.getBucket()) // bucket 必须传递 + .object(path) // 相对路径作为 key + .build()); + return IoUtil.readBytes(response); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/s3/S3FileClientConfig.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/s3/S3FileClientConfig.java new file mode 100644 index 000000000..c038888de --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/s3/S3FileClientConfig.java @@ -0,0 +1,77 @@ +package com.cf.imes.framework.file.core.client.s3; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.file.core.client.FileClientConfig; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotNull; + +/** + * S3 文件客户端的配置类 + * + * @author 晨丰科技 + */ +@Data +public class S3FileClientConfig implements FileClientConfig { + + public static final String ENDPOINT_QINIU = "qiniucs.com"; + public static final String ENDPOINT_ALIYUN = "aliyuncs.com"; + public static final String ENDPOINT_TENCENT = "myqcloud.com"; + + /** + * 节点地址 + * 1. MinIO:https://www.cf.com/Spring-Boot/MinIO 。例如说,http://127.0.0.1:9000 + * 2. 阿里云:https://help.aliyun.com/document_detail/31837.html + * 3. 腾讯云:https://cloud.tencent.com/document/product/436/6224 + * 4. 七牛云:https://developer.qiniu.com/kodo/4088/s3-access-domainname + * 5. 华为云:https://developer.huaweicloud.com/endpoint?OBS + */ + @NotNull(message = "endpoint 不能为空") + private String endpoint; + /** + * 自定义域名 + * 1. MinIO:通过 Nginx 配置 + * 2. 阿里云:https://help.aliyun.com/document_detail/31836.html + * 3. 腾讯云:https://cloud.tencent.com/document/product/436/11142 + * 4. 七牛云:https://developer.qiniu.com/kodo/8556/set-the-custom-source-domain-name + * 5. 华为云:https://support.huaweicloud.com/usermanual-obs/obs_03_0032.html + */ + @URL(message = "domain 必须是 URL 格式") + private String domain; + /** + * 存储 Bucket + */ + @NotNull(message = "bucket 不能为空") + private String bucket; + + /** + * 访问 Key + * 1. MinIO:https://www.cf.com/Spring-Boot/MinIO + * 2. 阿里云:https://ram.console.aliyun.com/manage/ak + * 3. 腾讯云:https://console.cloud.tencent.com/cam/capi + * 4. 七牛云:https://portal.qiniu.com/user/key + * 5. 华为云:https://support.huaweicloud.com/qs-obs/obs_qs_0005.html + */ + @NotNull(message = "accessKey 不能为空") + private String accessKey; + /** + * 访问 Secret + */ + @NotNull(message = "accessSecret 不能为空") + private String accessSecret; + + @SuppressWarnings("RedundantIfStatement") + @AssertTrue(message = "domain 不能为空") + @JsonIgnore + public boolean isDomainValid() { + // 如果是七牛,必须带有 domain + if (StrUtil.contains(endpoint, ENDPOINT_QINIU) && StrUtil.isEmpty(domain)) { + return false; + } + return true; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClient.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClient.java new file mode 100644 index 000000000..9b77dacd2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClient.java @@ -0,0 +1,61 @@ +package com.cf.imes.framework.file.core.client.sftp; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.extra.ssh.Sftp; +import com.cf.imes.framework.common.util.io.FileUtils; +import com.cf.imes.framework.file.core.client.AbstractFileClient; + +import java.io.File; + +/** + * Sftp 文件客户端 + * + * @author 晨丰科技 + */ +public class SftpFileClient extends AbstractFileClient { + + private Sftp sftp; + + public SftpFileClient(Long id, SftpFileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + // 补全风格。例如说 Linux 是 /,Windows 是 \ + if (!config.getBasePath().endsWith(File.separator)) { + config.setBasePath(config.getBasePath() + File.separator); + } + // 初始化 Ftp 对象 + this.sftp = new Sftp(config.getHost(), config.getPort(), config.getUsername(), config.getPassword()); + } + + @Override + public String upload(byte[] content, String path, String type) { + // 执行写入 + String filePath = getFilePath(path); + File file = FileUtils.createTempFile(content); + sftp.upload(filePath, file); + // 拼接返回路径 + return super.formatFileUrl(config.getDomain(), path); + } + + @Override + public void delete(String path) { + String filePath = getFilePath(path); + sftp.delFile(filePath); + } + + @Override + public byte[] getContent(String path) { + String filePath = getFilePath(path); + File destFile = FileUtils.createTempFile(); + sftp.download(filePath, destFile); + return FileUtil.readBytes(destFile); + } + + private String getFilePath(String path) { + return config.getBasePath() + path; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClientConfig.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClientConfig.java new file mode 100644 index 000000000..653bd3aed --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClientConfig.java @@ -0,0 +1,52 @@ +package com.cf.imes.framework.file.core.client.sftp; + +import com.cf.imes.framework.file.core.client.FileClientConfig; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * Sftp 文件客户端的配置类 + * + * @author 晨丰科技 + */ +@Data +public class SftpFileClientConfig implements FileClientConfig { + + /** + * 基础路径 + */ + @NotEmpty(message = "基础路径不能为空") + private String basePath; + + /** + * 自定义域名 + */ + @NotEmpty(message = "domain 不能为空") + @URL(message = "domain 必须是 URL 格式") + private String domain; + + /** + * 主机地址 + */ + @NotEmpty(message = "host 不能为空") + private String host; + /** + * 主机端口 + */ + @NotNull(message = "port 不能为空") + private Integer port; + /** + * 用户名 + */ + @NotEmpty(message = "用户名不能为空") + private String username; + /** + * 密码 + */ + @NotEmpty(message = "密码不能为空") + private String password; + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/enums/FileStorageEnum.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/enums/FileStorageEnum.java new file mode 100644 index 000000000..e0fa9f409 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/enums/FileStorageEnum.java @@ -0,0 +1,55 @@ +package com.cf.imes.framework.file.core.enums; + +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.file.core.client.FileClient; +import com.cf.imes.framework.file.core.client.FileClientConfig; +import com.cf.imes.framework.file.core.client.db.DBFileClient; +import com.cf.imes.framework.file.core.client.db.DBFileClientConfig; +import com.cf.imes.framework.file.core.client.ftp.FtpFileClient; +import com.cf.imes.framework.file.core.client.ftp.FtpFileClientConfig; +import com.cf.imes.framework.file.core.client.local.LocalFileClient; +import com.cf.imes.framework.file.core.client.local.LocalFileClientConfig; +import com.cf.imes.framework.file.core.client.s3.S3FileClient; +import com.cf.imes.framework.file.core.client.s3.S3FileClientConfig; +import com.cf.imes.framework.file.core.client.sftp.SftpFileClient; +import com.cf.imes.framework.file.core.client.sftp.SftpFileClientConfig; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件存储器枚举 + * + * @author 晨丰科技 + */ +@AllArgsConstructor +@Getter +public enum FileStorageEnum { + + DB(1, DBFileClientConfig.class, DBFileClient.class), + + LOCAL(10, LocalFileClientConfig.class, LocalFileClient.class), + FTP(11, FtpFileClientConfig.class, FtpFileClient.class), + SFTP(12, SftpFileClientConfig.class, SftpFileClient.class), + + S3(20, S3FileClientConfig.class, S3FileClient.class), + ; + + /** + * 存储器 + */ + private final Integer storage; + + /** + * 配置类 + */ + private final Class configClass; + /** + * 客户端类 + */ + private final Class clientClass; + + public static FileStorageEnum getByStorage(Integer storage) { + return ArrayUtil.firstMatch(o -> o.getStorage().equals(storage), values()); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/utils/FileTypeUtils.java b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/utils/FileTypeUtils.java new file mode 100644 index 000000000..8a0bc6bbd --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/java/com/cf/imes/framework/file/core/utils/FileTypeUtils.java @@ -0,0 +1,48 @@ +package com.cf.imes.framework.file.core.utils; + +import com.alibaba.ttl.TransmittableThreadLocal; +import lombok.SneakyThrows; +import org.apache.tika.Tika; + +/** + * 文件类型 Utils + * + * @author 晨丰科技 + */ +public class FileTypeUtils { + + private static final ThreadLocal TIKA = TransmittableThreadLocal.withInitial(Tika::new); + + /** + * 获得文件的 mineType,对于doc,jar等文件会有误差 + * + * @param data 文件内容 + * @return mineType 无法识别时会返回“application/octet-stream” + */ + @SneakyThrows + public static String getMineType(byte[] data) { + return TIKA.get().detect(data); + } + + /** + * 已知文件名,获取文件类型,在某些情况下比通过字节数组准确,例如使用jar文件时,通过名字更为准确 + * + * @param name 文件名 + * @return mineType 无法识别时会返回“application/octet-stream” + */ + public static String getMineType(String name) { + return TIKA.get().detect(name); + } + + /** + * 在拥有文件和数据的情况下,最好使用此方法,最为准确 + * + * @param data 文件内容 + * @param name 文件名 + * @return mineType 无法识别时会返回“application/octet-stream” + */ + public static String getMineType(byte[] data, String name) { + return TIKA.get().detect(data, name); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-file/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..e34179de1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.file.config.ChenfengFileAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/config/package-info.java b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/config/package-info.java new file mode 100644 index 000000000..946b43414 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/config/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,避免 package 无法提交到 Git 仓库 + */ +package com.cf.imes.framework.file.config; diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClientTest.java b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClientTest.java new file mode 100644 index 000000000..9ef045297 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/ftp/FtpFileClientTest.java @@ -0,0 +1,39 @@ +package com.cf.imes.framework.file.core.client.ftp; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.extra.ftp.FtpMode; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class FtpFileClientTest { + + @Test + @Disabled + public void test() { + // 创建客户端 + FtpFileClientConfig config = new FtpFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath("/home/ftp"); + config.setHost("kanchai.club"); + config.setPort(221); + config.setUsername(""); + config.setPassword(""); + config.setMode(FtpMode.Passive.name()); + FtpFileClient client = new FtpFileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + if (false) { + byte[] bytes = client.getContent(path); + System.out.println("文件内容:" + bytes); + } + if (false) { + client.delete(path); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/local/LocalFileClientTest.java b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/local/LocalFileClientTest.java new file mode 100644 index 000000000..cca90a1b0 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/local/LocalFileClientTest.java @@ -0,0 +1,27 @@ +package com.cf.imes.framework.file.core.client.local; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class LocalFileClientTest { + + @Test + @Disabled + public void test() { + // 创建客户端 + LocalFileClientConfig config = new LocalFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath("/Users/yunai/file_test"); + LocalFileClient client = new LocalFileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + client.delete(path); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/s3/S3FileClientTest.java b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/s3/S3FileClientTest.java new file mode 100644 index 000000000..c6b5f1a56 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/s3/S3FileClientTest.java @@ -0,0 +1,117 @@ +package com.cf.imes.framework.file.core.client.s3; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import com.cf.imes.framework.common.util.validation.ValidationUtils; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import javax.validation.Validation; + +public class S3FileClientTest { + + @Test + @Disabled // MinIO,如果要集成测试,可以注释本行 + public void testMinIO() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 + config.setAccessKey("admin"); + config.setAccessSecret("password"); + config.setBucket("chenfengyuanma"); + config.setDomain(null); + // 默认 9000 endpoint + config.setEndpoint("http://127.0.0.1:9000"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 阿里云 OSS,如果要集成测试,可以注释本行 + public void testAliyun() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 + config.setAccessKey(System.getenv("ALIYUN_ACCESS_KEY")); + config.setAccessSecret(System.getenv("ALIYUN_SECRET_KEY")); + config.setBucket("yunai-aoteman"); + config.setDomain(null); // 如果有自定义域名,则可以设置。http://ali-oss.iocoder.cn + // 默认北京的 endpoint + config.setEndpoint("oss-cn-beijing.aliyuncs.com"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 腾讯云 COS,如果要集成测试,可以注释本行 + public void testQCloud() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 + config.setAccessKey(System.getenv("QCLOUD_ACCESS_KEY")); + config.setAccessSecret(System.getenv("QCLOUD_SECRET_KEY")); + config.setBucket("aoteman-1255880240"); + config.setDomain(null); // 如果有自定义域名,则可以设置。http://tengxun-oss.iocoder.cn + // 默认上海的 endpoint + config.setEndpoint("cos.ap-shanghai.myqcloud.com"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 七牛云存储,如果要集成测试,可以注释本行 + public void testQiniu() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 +// config.setAccessKey(System.getenv("QINIU_ACCESS_KEY")); +// config.setAccessSecret(System.getenv("QINIU_SECRET_KEY")); + config.setAccessKey("b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8"); + config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP"); + config.setBucket("ruoyi-vue-pro"); + config.setDomain("http://test.chenfeng.iocoder.cn"); // 如果有自定义域名,则可以设置。http://static.chenfeng.iocoder.cn + // 默认上海的 endpoint + config.setEndpoint("s3-cn-south-1.qiniucs.com"); + + // 执行上传 + testExecuteUpload(config); + } + + @Test + @Disabled // 华为云存储,如果要集成测试,可以注释本行 + public void testHuaweiCloud() throws Exception { + S3FileClientConfig config = new S3FileClientConfig(); + // 配置成你自己的 +// config.setAccessKey(System.getenv("HUAWEI_CLOUD_ACCESS_KEY")); +// config.setAccessSecret(System.getenv("HUAWEI_CLOUD_SECRET_KEY")); + config.setBucket("chenfeng"); + config.setDomain(null); // 如果有自定义域名,则可以设置。 + // 默认上海的 endpoint + config.setEndpoint("obs.cn-east-3.myhuaweicloud.com"); + + // 执行上传 + testExecuteUpload(config); + } + + private void testExecuteUpload(S3FileClientConfig config) throws Exception { + // 校验配置 + ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config); + // 创建 Client + S3FileClient client = new S3FileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + // 读取文件 + if (true) { + byte[] bytes = client.getContent(path); + System.out.println("文件内容:" + bytes.length); + } + // 删除文件 + if (false) { + client.delete(path); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClientTest.java b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClientTest.java new file mode 100644 index 000000000..e5cbe9d8d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/client/sftp/SftpFileClientTest.java @@ -0,0 +1,37 @@ +package com.cf.imes.framework.file.core.client.sftp; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.IdUtil; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class SftpFileClientTest { + + @Test + @Disabled + public void test() { + // 创建客户端 + SftpFileClientConfig config = new SftpFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath("/home/ftp"); + config.setHost("kanchai.club"); + config.setPort(222); + config.setUsername(""); + config.setPassword(""); + SftpFileClient client = new SftpFileClient(0L, config); + client.init(); + // 上传文件 + String path = IdUtil.fastSimpleUUID() + ".jpg"; + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + String fullPath = client.upload(content, path, "image/jpeg"); + System.out.println("访问地址:" + fullPath); + if (false) { + byte[] bytes = client.getContent(path); + System.out.println("文件内容:" + bytes); + } + if (false) { + client.delete(path); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/enums/package-info.java b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/enums/package-info.java new file mode 100644 index 000000000..338bea540 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-file/src/test/java/com/cf/imes/framework/file/core/enums/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,避免 package 无法提交到 Git 仓库 + */ +package com.cf.imes.framework.file.core.enums; diff --git a/cf-framework/cf-spring-boot-starter-file/src/test/resources/file/erweima.jpg b/cf-framework/cf-spring-boot-starter-file/src/test/resources/file/erweima.jpg new file mode 100644 index 000000000..1447283cd Binary files /dev/null and b/cf-framework/cf-spring-boot-starter-file/src/test/resources/file/erweima.jpg differ diff --git a/cf-framework/cf-spring-boot-starter-flowable/pom.xml b/cf-framework/cf-spring-boot-starter-flowable/pom.xml new file mode 100644 index 000000000..8f6a99458 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/pom.xml @@ -0,0 +1,37 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + + cf-spring-boot-starter-flowable + + + + com.cf.imes + cf-common + + + + + com.cf.imes + cf-spring-boot-starter-security + + + + + org.flowable + flowable-spring-boot-starter-process + + + org.flowable + flowable-spring-boot-starter-actuator + + + + diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/config/ChenfengFlowableConfiguration.java b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/config/ChenfengFlowableConfiguration.java new file mode 100644 index 000000000..ed0dbb423 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/config/ChenfengFlowableConfiguration.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.flowable.config; + +import com.cf.imes.framework.common.enums.WebFilterOrderEnum; +import com.cf.imes.framework.flowable.core.web.FlowableWebFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.core.task.AsyncListenableTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@AutoConfiguration +public class ChenfengFlowableConfiguration { + + /** + * 参考 {@link org.flowable.spring.boot.FlowableJobConfiguration} 类,创建对应的 AsyncListenableTaskExecutor Bean + * + * 如果不创建,会导致项目启动时,Flowable 报错的问题 + */ + @Bean(name = "applicationTaskExecutor") + @ConditionalOnMissingBean(name = "applicationTaskExecutor") + public AsyncListenableTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("flowable-task-Executor-"); + executor.setAwaitTerminationSeconds(30); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAllowCoreThreadTimeOut(true); + executor.initialize(); + return executor; + } + + /** + * 配置 flowable Web 过滤器 + */ + @Bean + public FilterRegistrationBean flowableWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new FlowableWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.FLOWABLE_FILTER); + return registrationBean; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/package-info.java b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/package-info.java new file mode 100644 index 000000000..ddb2be94a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.flowable.core; diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/util/BpmnModelUtils.java b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/util/BpmnModelUtils.java new file mode 100644 index 000000000..ad3fdbe57 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/util/BpmnModelUtils.java @@ -0,0 +1,309 @@ +package com.cf.imes.framework.flowable.core.util; + +import cn.hutool.core.collection.CollUtil; +import org.flowable.bpmn.converter.BpmnXMLConverter; +import org.flowable.bpmn.model.Process; +import org.flowable.bpmn.model.*; + +import java.util.*; + +/** + * 流程模型转操作工具类 + */ +public class BpmnModelUtils { + + /** + * 根据节点,获取入口连线 + * + * @param source 起始节点 + * @return 入口连线列表 + */ + public static List getElementIncomingFlows(FlowElement source) { + if (source instanceof FlowNode) { + return ((FlowNode) source).getIncomingFlows(); + } + return new ArrayList<>(); + } + + /** + * 根据节点,获取出口连线 + * + * @param source 起始节点 + * @return 出口连线列表 + */ + public static List getElementOutgoingFlows(FlowElement source) { + if (source instanceof FlowNode) { + return ((FlowNode) source).getOutgoingFlows(); + } + return new ArrayList<>(); + } + + /** + * 获取流程元素信息 + * + * @param model bpmnModel 对象 + * @param flowElementId 元素 ID + * @return 元素信息 + */ + public static FlowElement getFlowElementById(BpmnModel model, String flowElementId) { + Process process = model.getMainProcess(); + return process.getFlowElement(flowElementId); + } + + /** + * 获得 BPMN 流程中,指定的元素们 + * + * @param model + * @param clazz 指定元素。例如说,{@link UserTask}、{@link Gateway} 等等 + * @return 元素们 + */ + public static List getBpmnModelElements(BpmnModel model, Class clazz) { + List result = new ArrayList<>(); + model.getProcesses().forEach(process -> { + process.getFlowElements().forEach(flowElement -> { + if (flowElement.getClass().isAssignableFrom(clazz)) { + result.add((T) flowElement); + } + }); + }); + return result; + } + + /** + * 比较 两个bpmnModel 是否相同 + * @param oldModel 老的bpmn model + * @param newModel 新的bpmn model + */ + public static boolean equals(BpmnModel oldModel, BpmnModel newModel) { + // 由于 BpmnModel 未提供 equals 方法,所以只能转成字节数组,进行比较 + return Arrays.equals(getBpmnBytes(oldModel), getBpmnBytes(newModel)); + } + + /** + * 把 bpmnModel 转换成 byte[] + * @param model bpmnModel + */ + public static byte[] getBpmnBytes(BpmnModel model) { + if (model == null) { + return new byte[0]; + } + BpmnXMLConverter converter = new BpmnXMLConverter(); + return converter.convertToXML(model); + } + + // ========== 遍历相关的方法 ========== + + /** + * 找到 source 节点之前的所有用户任务节点 + * + * @param source 起始节点 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复 + * @param userTaskList 已找到的用户任务节点 + * @return 用户任务节点 数组 + */ + public static List getPreviousUserTaskList(FlowElement source, Set hasSequenceFlow, List userTaskList) { + userTaskList = userTaskList == null ? new ArrayList<>() : userTaskList; + hasSequenceFlow = hasSequenceFlow == null ? new HashSet<>() : hasSequenceFlow; + // 如果该节点为开始节点,且存在上级子节点,则顺着上级子节点继续迭代 + if (source instanceof StartEvent && source.getSubProcess() != null) { + userTaskList = getPreviousUserTaskList(source.getSubProcess(), hasSequenceFlow, userTaskList); + } + + // 根据类型,获取入口连线 + List sequenceFlows = getElementIncomingFlows(source); + if (sequenceFlows == null) { + return userTaskList; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (hasSequenceFlow.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + hasSequenceFlow.add(sequenceFlow.getId()); + // 类型为用户节点,则新增父级节点 + if (sequenceFlow.getSourceFlowElement() instanceof UserTask) { + userTaskList.add((UserTask) sequenceFlow.getSourceFlowElement()); + } + // 类型为子流程,则添加子流程开始节点出口处相连的节点 + if (sequenceFlow.getSourceFlowElement() instanceof SubProcess) { + // 获取子流程用户任务节点 + List childUserTaskList = findChildProcessUserTaskList((StartEvent) ((SubProcess) sequenceFlow.getSourceFlowElement()).getFlowElements().toArray()[0], null, null); + // 如果找到节点,则说明该线路找到节点,不继续向下找,反之继续 + if (CollUtil.isNotEmpty(childUserTaskList)) { + userTaskList.addAll(childUserTaskList); + } + } + // 继续迭代 + userTaskList = getPreviousUserTaskList(sequenceFlow.getSourceFlowElement(), hasSequenceFlow, userTaskList); + } + return userTaskList; + } + + /** + * 迭代获取子流程用户任务节点 + * + * @param source 起始节点 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复 + * @param userTaskList 需要撤回的用户任务列表 + * @return 用户任务节点 + */ + public static List findChildProcessUserTaskList(FlowElement source, Set hasSequenceFlow, List userTaskList) { + hasSequenceFlow = hasSequenceFlow == null ? new HashSet<>() : hasSequenceFlow; + userTaskList = userTaskList == null ? new ArrayList<>() : userTaskList; + + // 根据类型,获取出口连线 + List sequenceFlows = getElementOutgoingFlows(source); + if (sequenceFlows == null) { + return userTaskList; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (hasSequenceFlow.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + hasSequenceFlow.add(sequenceFlow.getId()); + // 如果为用户任务类型,且任务节点的 Key 正在运行的任务中存在,添加 + if (sequenceFlow.getTargetFlowElement() instanceof UserTask) { + userTaskList.add((UserTask) sequenceFlow.getTargetFlowElement()); + continue; + } + // 如果节点为子流程节点情况,则从节点中的第一个节点开始获取 + if (sequenceFlow.getTargetFlowElement() instanceof SubProcess) { + List childUserTaskList = findChildProcessUserTaskList((FlowElement) (((SubProcess) sequenceFlow.getTargetFlowElement()).getFlowElements().toArray()[0]), hasSequenceFlow, null); + // 如果找到节点,则说明该线路找到节点,不继续向下找,反之继续 + if (CollUtil.isNotEmpty(childUserTaskList)) { + userTaskList.addAll(childUserTaskList); + continue; + } + } + // 继续迭代 + userTaskList = findChildProcessUserTaskList(sequenceFlow.getTargetFlowElement(), hasSequenceFlow, userTaskList); + } + return userTaskList; + } + + + /** + * 迭代从后向前扫描,判断目标节点相对于当前节点是否是串行 + * 不存在直接回退到子流程中的情况,但存在从子流程出去到父流程情况 + * + * @param source 起始节点 + * @param target 目标节点 + * @param visitedElements 已经经过的连线的 ID,用于判断线路是否重复 + * @return 结果 + */ + public static boolean isSequentialReachable(FlowElement source, FlowElement target, Set visitedElements) { + visitedElements = visitedElements == null ? new HashSet<>() : visitedElements; + // 不能是开始事件和子流程 + if (source instanceof StartEvent && isInEventSubprocess(source)) { + return false; + } + + // 根据类型,获取入口连线 + List sequenceFlows = getElementIncomingFlows(source); + if (CollUtil.isEmpty(sequenceFlows)) { + return true; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (visitedElements.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + visitedElements.add(sequenceFlow.getId()); + // 这条线路存在目标节点,这条线路完成,进入下个线路 + FlowElement sourceFlowElement = sequenceFlow.getSourceFlowElement(); + if (target.getId().equals(sourceFlowElement.getId())) { + continue; + } + // 如果目标节点为并行网关,则不继续 + if (sourceFlowElement instanceof ParallelGateway) { + return false; + } + // 否则就继续迭代 + if (!isSequentialReachable(sourceFlowElement, target, visitedElements)) { + return false; + } + } + return true; + } + + /** + * 判断当前节点是否属于不同的子流程 + * + * @param flowElement 被判断的节点 + * @return true 表示属于子流程 + */ + private static boolean isInEventSubprocess(FlowElement flowElement) { + FlowElementsContainer flowElementsContainer = flowElement.getParentContainer(); + while (flowElementsContainer != null) { + if (flowElementsContainer instanceof EventSubProcess) { + return true; + } + + if (flowElementsContainer instanceof FlowElement) { + flowElementsContainer = ((FlowElement) flowElementsContainer).getParentContainer(); + } else { + flowElementsContainer = null; + } + } + return false; + } + + /** + * 根据正在运行的任务节点,迭代获取子级任务节点列表,向后找 + * + * @param source 起始节点 + * @param runTaskKeyList 正在运行的任务 Key,用于校验任务节点是否是正在运行的节点 + * @param hasSequenceFlow 已经经过的连线的 ID,用于判断线路是否重复 + * @param userTaskList 需要撤回的用户任务列表 + * @return 子级任务节点列表 + */ + public static List iteratorFindChildUserTasks(FlowElement source, List runTaskKeyList, + Set hasSequenceFlow, List userTaskList) { + hasSequenceFlow = hasSequenceFlow == null ? new HashSet<>() : hasSequenceFlow; + userTaskList = userTaskList == null ? new ArrayList<>() : userTaskList; + // 如果该节点为开始节点,且存在上级子节点,则顺着上级子节点继续迭代 + if (source instanceof StartEvent && source.getSubProcess() != null) { + userTaskList = iteratorFindChildUserTasks(source.getSubProcess(), runTaskKeyList, hasSequenceFlow, userTaskList); + } + + // 根据类型,获取出口连线 + List sequenceFlows = getElementOutgoingFlows(source); + if (sequenceFlows == null) { + return userTaskList; + } + // 循环找到目标元素 + for (SequenceFlow sequenceFlow : sequenceFlows) { + // 如果发现连线重复,说明循环了,跳过这个循环 + if (hasSequenceFlow.contains(sequenceFlow.getId())) { + continue; + } + // 添加已经走过的连线 + hasSequenceFlow.add(sequenceFlow.getId()); + // 如果为用户任务类型,且任务节点的 Key 正在运行的任务中存在,添加 + if (sequenceFlow.getTargetFlowElement() instanceof UserTask && runTaskKeyList.contains((sequenceFlow.getTargetFlowElement()).getId())) { + userTaskList.add((UserTask) sequenceFlow.getTargetFlowElement()); + continue; + } + // 如果节点为子流程节点情况,则从节点中的第一个节点开始获取 + if (sequenceFlow.getTargetFlowElement() instanceof SubProcess) { + List childUserTaskList = iteratorFindChildUserTasks((FlowElement) (((SubProcess) sequenceFlow.getTargetFlowElement()).getFlowElements().toArray()[0]), runTaskKeyList, hasSequenceFlow, null); + // 如果找到节点,则说明该线路找到节点,不继续向下找,反之继续 + if (CollUtil.isNotEmpty(childUserTaskList)) { + userTaskList.addAll(childUserTaskList); + continue; + } + } + // 继续迭代 + userTaskList = iteratorFindChildUserTasks(sequenceFlow.getTargetFlowElement(), runTaskKeyList, hasSequenceFlow, userTaskList); + } + return userTaskList; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/util/FlowableUtils.java b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/util/FlowableUtils.java new file mode 100644 index 000000000..284d973ca --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/util/FlowableUtils.java @@ -0,0 +1,32 @@ +package com.cf.imes.framework.flowable.core.util; + +import org.flowable.common.engine.impl.identity.Authentication; + +/** + * Flowable 相关的工具方法 + * + * @author 晨丰科技 + */ +public class FlowableUtils { + + // ========== User 相关的工具方法 ========== + + public static void setAuthenticatedUserId(Long userId) { + Authentication.setAuthenticatedUserId(String.valueOf(userId)); + } + + public static void clearAuthenticatedUserId() { + Authentication.setAuthenticatedUserId(null); + } + + // ========== Execution 相关的工具方法 ========== + + public static String formatCollectionVariable(String activityId) { + return activityId + "_assignees"; + } + + public static String formatCollectionElementVariable(String activityId) { + return activityId + "_assignee"; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/web/FlowableWebFilter.java b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/web/FlowableWebFilter.java new file mode 100644 index 000000000..e5d0df579 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/core/web/FlowableWebFilter.java @@ -0,0 +1,35 @@ +package com.cf.imes.framework.flowable.core.web; + +import com.cf.imes.framework.flowable.core.util.FlowableUtils; +import com.cf.imes.framework.security.core.util.SecurityFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +/** + * flowable Web 过滤器,将 userId 设置到 {@link org.flowable.common.engine.impl.identity.Authentication} 中 + * + * @author jason + */ +public class FlowableWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + try { + // 设置工作流的用户 + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (userId != null) { + FlowableUtils.setAuthenticatedUserId(userId); + } + // 过滤 + chain.doFilter(request, response); + } finally { + // 清理 + FlowableUtils.clearAuthenticatedUserId(); + } + } +} diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/package-info.java b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/package-info.java new file mode 100644 index 000000000..46923e86d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/java/com/cf/imes/framework/flowable/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework.flowable; diff --git a/cf-framework/cf-spring-boot-starter-flowable/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-flowable/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..8355aa92a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-flowable/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.cf.imes.framework.flowable.config.ChenfengFlowableConfiguration diff --git a/cf-framework/cf-spring-boot-starter-job/pom.xml b/cf-framework/cf-spring-boot-starter-job/pom.xml new file mode 100644 index 000000000..e43edded2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-job/pom.xml @@ -0,0 +1,49 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-job + jar + + ${project.artifactId} + 任务拓展,基于 XXL-Job 实现 + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter + true + + + + + com.xuxueli + xxl-job-core + + + + + jakarta.validation + jakarta.validation-api + + + + + diff --git a/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/ChenfengAsyncAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/ChenfengAsyncAutoConfiguration.java new file mode 100644 index 000000000..261560ff3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/ChenfengAsyncAutoConfiguration.java @@ -0,0 +1,37 @@ +package com.cf.imes.framework.quartz.config; + +import com.alibaba.ttl.TtlRunnable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * 异步任务 Configuration + */ +@AutoConfiguration +@EnableAsync +public class ChenfengAsyncAutoConfiguration { + + @Bean + public BeanPostProcessor threadPoolTaskExecutorBeanPostProcessor() { + return new BeanPostProcessor() { + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + if (!(bean instanceof ThreadPoolTaskExecutor)) { + return bean; + } + // 修改提交的任务,接入 TransmittableThreadLocal + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean; + executor.setTaskDecorator(TtlRunnable::get); + return executor; + } + + }; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/ChenfengXxlJobAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/ChenfengXxlJobAutoConfiguration.java new file mode 100644 index 000000000..9863e33d9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/ChenfengXxlJobAutoConfiguration.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.quartz.config; + +import com.xxl.job.core.executor.XxlJobExecutor; +import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; +import lombok.extern.slf4j.Slf4j; +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.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * XXL-Job 自动配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@ConditionalOnClass(XxlJobSpringExecutor.class) +@ConditionalOnProperty(prefix = "xxl.job", name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties({XxlJobProperties.class}) +@EnableScheduling // 开启 Spring 自带的定时任务 +@Slf4j +public class ChenfengXxlJobAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public XxlJobExecutor xxlJobExecutor(XxlJobProperties properties) { + log.info("[xxlJobExecutor][初始化 XXL-Job 执行器的配置]"); + XxlJobProperties.AdminProperties admin = properties.getAdmin(); + XxlJobProperties.ExecutorProperties executor = properties.getExecutor(); + + // 初始化执行器 + XxlJobExecutor xxlJobExecutor = new XxlJobSpringExecutor(); + xxlJobExecutor.setIp(executor.getIp()); + xxlJobExecutor.setPort(executor.getPort()); + xxlJobExecutor.setAppname(executor.getAppName()); + xxlJobExecutor.setLogPath(executor.getLogPath()); + xxlJobExecutor.setLogRetentionDays(executor.getLogRetentionDays()); + xxlJobExecutor.setAdminAddresses(admin.getAddresses()); + xxlJobExecutor.setAccessToken(properties.getAccessToken()); + return xxlJobExecutor; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/XxlJobProperties.java b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/XxlJobProperties.java new file mode 100644 index 000000000..a07c402fb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/config/XxlJobProperties.java @@ -0,0 +1,99 @@ +package com.cf.imes.framework.quartz.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * XXL-Job 配置类 + */ +@ConfigurationProperties("xxl.job") +@Validated +@Data +public class XxlJobProperties { + + /** + * 是否开启,默认为 true 关闭 + */ + private Boolean enabled = true; + /** + * 访问令牌 + */ + private String accessToken; + /** + * 控制器配置 + */ + @NotNull(message = "控制器配置不能为空") + private AdminProperties admin; + /** + * 执行器配置 + */ + @NotNull(message = "执行器配置不能为空") + private ExecutorProperties executor; + + /** + * XXL-Job 调度器配置类 + */ + @Data + @Valid + public static class AdminProperties { + + /** + * 调度器地址 + */ + @NotEmpty(message = "调度器地址不能为空") + private String addresses; + + } + + /** + * XXL-Job 执行器配置类 + */ + @Data + @Valid + public static class ExecutorProperties { + + /** + * 默认端口 + * + * 这里使用 -1 表示随机 + */ + private static final Integer PORT_DEFAULT = -1; + + /** + * 默认日志保留天数 + * + * 如果想永久保留,则设置为 -1 + */ + private static final Integer LOG_RETENTION_DAYS_DEFAULT = 30; + + /** + * 应用名 + */ + @NotEmpty(message = "应用名不能为空") + private String appName; + /** + * 执行器的 IP + */ + private String ip; + /** + * 执行器的 Port + */ + private Integer port = PORT_DEFAULT; + /** + * 日志地址 + */ + @NotEmpty(message = "日志地址不能为空") + private String logPath; + /** + * 日志保留天数 + */ + private Integer logRetentionDays = LOG_RETENTION_DAYS_DEFAULT; + + } + +} diff --git a/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/package-info.java b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/package-info.java new file mode 100644 index 000000000..e87eb0fc0 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-job/src/main/java/com/cf/imes/framework/quartz/package-info.java @@ -0,0 +1,5 @@ +/** + * 1. 定时任务,基于 XXL-Job 实现。 + * 2. 异步任务,采用 Spring Async 异步执行。 + */ +package com.cf.imes.framework.quartz; diff --git a/cf-framework/cf-spring-boot-starter-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..3bd633b75 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.quartz.config.ChenfengXxlJobAutoConfiguration +com.cf.imes.framework.quartz.config.ChenfengAsyncAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-monitor/pom.xml b/cf-framework/cf-spring-boot-starter-monitor/pom.xml new file mode 100644 index 000000000..2d67befb6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/pom.xml @@ -0,0 +1,72 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-monitor + jar + + ${project.artifactId} + 服务监控,提供链路追踪、日志服务、指标收集等等功能 + + + + com.cf.imes + cf-common + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springframework + spring-web + provided + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + + io.opentracing + opentracing-util + + + org.apache.skywalking + apm-toolkit-trace + + + org.apache.skywalking + apm-toolkit-logback-1.x + + + org.apache.skywalking + apm-toolkit-opentracing + + + + + io.micrometer + micrometer-registry-prometheus + + + + de.codecentric + spring-boot-admin-starter-client + + + + diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/ChenfengMetricsAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/ChenfengMetricsAutoConfiguration.java new file mode 100644 index 000000000..26467018d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/ChenfengMetricsAutoConfiguration.java @@ -0,0 +1,28 @@ +package com.cf.imes.framework.tracer.config; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Metrics 配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@ConditionalOnClass({MeterRegistryCustomizer.class}) +@ConditionalOnProperty(prefix = "chenfeng.metrics", value = "enable", matchIfMissing = true) // 允许使用 chenfeng.metrics.enable=false 禁用 Metrics +public class ChenfengMetricsAutoConfiguration { + + @Bean + public MeterRegistryCustomizer metricsCommonTags( + @Value("${spring.application.name}") String applicationName) { + return registry -> registry.config().commonTags("application", applicationName); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/ChenfengTracerAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/ChenfengTracerAutoConfiguration.java new file mode 100644 index 000000000..2566c30e4 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/ChenfengTracerAutoConfiguration.java @@ -0,0 +1,55 @@ +package com.cf.imes.framework.tracer.config; + +import com.cf.imes.framework.common.enums.WebFilterOrderEnum; +import com.cf.imes.framework.tracer.core.aop.BizTraceAspect; +import com.cf.imes.framework.tracer.core.filter.TraceFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * Tracer 配置类 + * + * @author mashu + */ +@AutoConfiguration +@ConditionalOnClass({BizTraceAspect.class}) +@EnableConfigurationProperties(TracerProperties.class) +@ConditionalOnProperty(prefix = "chenfeng.tracer", value = "enable", matchIfMissing = true) +public class ChenfengTracerAutoConfiguration { + + // TODO @晨丰:重要。目前 opentracing 版本存在冲突,要么保证 skywalking,要么保证阿里云短信 sdk +// @Bean +// public TracerProperties bizTracerProperties() { +// return new TracerProperties(); +// } +// +// @Bean +// public BizTraceAspect bizTracingAop() { +// return new BizTraceAspect(tracer()); +// } +// +// @Bean +// public Tracer tracer() { +// // 创建 SkywalkingTracer 对象 +// SkywalkingTracer tracer = new SkywalkingTracer(); +// // 设置为 GlobalTracer 的追踪器 +// GlobalTracer.register(tracer); +// return tracer; +// } + + /** + * 创建 TraceFilter 过滤器,响应 header 设置 traceId + */ + @Bean + public FilterRegistrationBean traceFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TraceFilter()); + registrationBean.setOrder(WebFilterOrderEnum.TRACE_FILTER); + return registrationBean; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/TracerProperties.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/TracerProperties.java new file mode 100644 index 000000000..3fd27209c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/config/TracerProperties.java @@ -0,0 +1,14 @@ +package com.cf.imes.framework.tracer.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * BizTracer配置类 + * + * @author 麻薯 + */ +@ConfigurationProperties("chenfeng.tracer") +@Data +public class TracerProperties { +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/annotation/BizTrace.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/annotation/BizTrace.java new file mode 100644 index 000000000..db870bda2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/annotation/BizTrace.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.tracer.core.annotation; + +import java.lang.annotation.*; + +/** + * 打印业务编号 / 业务类型注解 + * + * 使用时,需要设置 SkyWalking OAP Server 的 application.yaml 配置文件,修改 SW_SEARCHABLE_TAG_KEYS 配置项, + * 增加 biz.type 和 biz.id 两值,然后重启 SkyWalking OAP Server 服务器。 + * + * @author 麻薯 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface BizTrace { + + /** + * 业务编号 tag 名 + */ + String ID_TAG = "biz.id"; + /** + * 业务类型 tag 名 + */ + String TYPE_TAG = "biz.type"; + + /** + * @return 操作名 + */ + String operationName() default ""; + + /** + * @return 业务编号 + */ + String id(); + + /** + * @return 业务类型 + */ + String type(); + +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/aop/BizTraceAspect.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/aop/BizTraceAspect.java new file mode 100644 index 000000000..a80718e21 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/aop/BizTraceAspect.java @@ -0,0 +1,77 @@ +package com.cf.imes.framework.tracer.core.aop; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.tracer.core.annotation.BizTrace; +import com.cf.imes.framework.common.util.spring.SpringExpressionUtils; +import com.cf.imes.framework.tracer.core.util.TracerFrameworkUtils; +import io.opentracing.Span; +import io.opentracing.Tracer; +import io.opentracing.tag.Tags; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import java.util.Map; + +import static java.util.Arrays.asList; + +/** + * {@link BizTrace} 切面,记录业务链路 + * + * @author mashu + */ +@Aspect +@AllArgsConstructor +@Slf4j +public class BizTraceAspect { + + private static final String BIZ_OPERATION_NAME_PREFIX = "Biz/"; + + private final Tracer tracer; + + @Around(value = "@annotation(trace)") + public Object around(ProceedingJoinPoint joinPoint, BizTrace trace) throws Throwable { + // 创建 span + String operationName = getOperationName(joinPoint, trace); + Span span = tracer.buildSpan(operationName) + .withTag(Tags.COMPONENT.getKey(), "biz") + .start(); + try { + // 执行原有方法 + return joinPoint.proceed(); + } catch (Throwable throwable) { + TracerFrameworkUtils.onError(throwable, span); + throw throwable; + } finally { + // 设置 Span 的 biz 属性 + setBizTag(span, joinPoint, trace); + // 完成 Span + span.finish(); + } + } + + private String getOperationName(ProceedingJoinPoint joinPoint, BizTrace trace) { + // 自定义操作名 + if (StrUtil.isNotEmpty(trace.operationName())) { + return BIZ_OPERATION_NAME_PREFIX + trace.operationName(); + } + // 默认操作名,使用方法名 + return BIZ_OPERATION_NAME_PREFIX + + joinPoint.getSignature().getDeclaringType().getSimpleName() + + "/" + joinPoint.getSignature().getName(); + } + + private void setBizTag(Span span, ProceedingJoinPoint joinPoint, BizTrace trace) { + try { + Map result = SpringExpressionUtils.parseExpressions(joinPoint, asList(trace.type(), trace.id())); + span.setTag(BizTrace.TYPE_TAG, MapUtil.getStr(result, trace.type())); + span.setTag(BizTrace.ID_TAG, MapUtil.getStr(result, trace.id())); + } catch (Exception ex) { + log.error("[setBizTag][解析 bizType 与 bizId 发生异常]", ex); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/filter/TraceFilter.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/filter/TraceFilter.java new file mode 100644 index 000000000..a39af427a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/filter/TraceFilter.java @@ -0,0 +1,33 @@ +package com.cf.imes.framework.tracer.core.filter; + +import com.cf.imes.framework.common.util.monitor.TracerUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Trace 过滤器,打印 traceId 到 header 中返回 + * + * @author 晨丰科技 + */ +public class TraceFilter extends OncePerRequestFilter { + + /** + * Header 名 - 链路追踪编号 + */ + private static final String HEADER_NAME_TRACE_ID = "trace-id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + // 设置响应 traceId + response.addHeader(HEADER_NAME_TRACE_ID, TracerUtils.getTraceId()); + // 继续过滤 + chain.doFilter(request, response); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/util/TracerFrameworkUtils.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/util/TracerFrameworkUtils.java new file mode 100644 index 000000000..d833b48d0 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/core/util/TracerFrameworkUtils.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.tracer.core.util; + +import io.opentracing.Span; +import io.opentracing.tag.Tags; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +/** + * 链路追踪 Util + * + * @author 晨丰科技 + */ +public class TracerFrameworkUtils { + + /** + * 将异常记录到 Span 中,参考自 com.aliyuncs.utils.TraceUtils + * + * @param throwable 异常 + * @param span Span + */ + public static void onError(Throwable throwable, Span span) { + Tags.ERROR.set(span, Boolean.TRUE); + if (throwable != null) { + span.log(errorLogs(throwable)); + } + } + + private static Map errorLogs(Throwable throwable) { + Map errorLogs = new HashMap(10); + errorLogs.put("event", Tags.ERROR.getKey()); + errorLogs.put("error.object", throwable); + errorLogs.put("error.kind", throwable.getClass().getName()); + String message = throwable.getCause() != null ? throwable.getCause().getMessage() : throwable.getMessage(); + if (message != null) { + errorLogs.put("message", message); + } + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + errorLogs.put("stack", sw.toString()); + return errorLogs; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/package-info.java b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/package-info.java new file mode 100644 index 000000000..1d665818e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/java/com/cf/imes/framework/tracer/package-info.java @@ -0,0 +1,6 @@ +/** + * 使用 SkyWalking 组件,作为链路追踪、日志中心。 + * + * @author 晨丰科技 + */ +package com.cf.imes.framework.tracer; diff --git a/cf-framework/cf-spring-boot-starter-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..b397a1f7a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.tracer.config.ChenfengTracerAutoConfiguration +com.cf.imes.framework.tracer.config.ChenfengMetricsAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-mq/pom.xml b/cf-framework/cf-spring-boot-starter-mq/pom.xml new file mode 100644 index 000000000..3ced5489a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/pom.xml @@ -0,0 +1,42 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-mq + jar + + ${project.artifactId} + 消息队列,支持 Redis、RocketMQ、RabbitMQ、Kafka 四种 + + + + + com.cf.imes + cf-spring-boot-starter-redis + + + + + org.springframework.kafka + spring-kafka + true + + + org.springframework.amqp + spring-rabbit + true + + + org.apache.rocketmq + rocketmq-spring-boot-starter + true + + + + diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/package-info.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/package-info.java new file mode 100644 index 000000000..d5d02ce64 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/package-info.java @@ -0,0 +1,4 @@ +/** + * 消息队列,支持 Redis、RocketMQ、RabbitMQ、Kafka 四种 + */ +package com.cf.imes.framework.mq; diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/config/ChenfengRabbitMQAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/config/ChenfengRabbitMQAutoConfiguration.java new file mode 100644 index 000000000..62bd489cf --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/config/ChenfengRabbitMQAutoConfiguration.java @@ -0,0 +1,29 @@ +package com.cf.imes.framework.mq.rabbitmq.config; + +import cn.hutool.core.util.ReflectUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.utils.SerializationUtils; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; + +import java.lang.reflect.Field; + +/** + * RabbitMQ 消息队列配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@Slf4j +@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") +public class ChenfengRabbitMQAutoConfiguration { + + static { + // 强制设置 SerializationUtils 的 TRUST_ALL 为 true,避免 RabbitMQ Consumer 反序列化消息报错 + // 为什么不通过设置 spring.amqp.deserialization.trust.all 呢?因为可能在 SerializationUtils static 初始化后 + Field trustAllField = ReflectUtil.getField(SerializationUtils.class, "TRUST_ALL"); + ReflectUtil.removeFinalModify(trustAllField); + ReflectUtil.setFieldValue(SerializationUtils.class, trustAllField, true); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/core/package-info.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/core/package-info.java new file mode 100644 index 000000000..dfdfa129e --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位符,无特殊逻辑 + */ +package com.cf.imes.framework.mq.rabbitmq.core; diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/package-info.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/package-info.java new file mode 100644 index 000000000..dee15940b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/rabbitmq/package-info.java @@ -0,0 +1,4 @@ +/** + * 消息队列,基于 RabbitMQ 提供 + */ +package com.cf.imes.framework.mq.rabbitmq; diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/config/ChenfengRedisMQConsumerAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/config/ChenfengRedisMQConsumerAutoConfiguration.java new file mode 100644 index 000000000..e6d7d5229 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/config/ChenfengRedisMQConsumerAutoConfiguration.java @@ -0,0 +1,151 @@ +package com.cf.imes.framework.mq.redis.config; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.system.SystemUtil; +import com.cf.imes.framework.common.enums.DocumentEnum; +import com.cf.imes.framework.mq.redis.core.RedisMQTemplate; +import com.cf.imes.framework.mq.redis.core.job.RedisPendingMessageResendJob; +import com.cf.imes.framework.mq.redis.core.pubsub.AbstractRedisChannelMessageListener; +import com.cf.imes.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; +import com.cf.imes.framework.redis.config.ChenfengRedisAutoConfiguration; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisServerCommands; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.List; +import java.util.Properties; + +/** + * Redis 消息队列 Consumer 配置类 + * + * @author 晨丰科技 + */ +@Slf4j +@EnableScheduling // 启用定时任务,用于 RedisPendingMessageResendJob 重发消息 +@AutoConfiguration(after = ChenfengRedisAutoConfiguration.class) +public class ChenfengRedisMQConsumerAutoConfiguration { + + /** + * 创建 Redis Pub/Sub 广播消费的容器 + */ + @Bean + @ConditionalOnBean(AbstractRedisChannelMessageListener.class) // 只有 AbstractChannelMessageListener 存在的时候,才需要注册 Redis pubsub 监听 + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisMQTemplate redisMQTemplate, List> listeners) { + // 创建 RedisMessageListenerContainer 对象 + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + // 设置 RedisConnection 工厂。 + container.setConnectionFactory(redisMQTemplate.getRedisTemplate().getRequiredConnectionFactory()); + // 添加监听器 + listeners.forEach(listener -> { + listener.setRedisMQTemplate(redisMQTemplate); + container.addMessageListener(listener, new ChannelTopic(listener.getChannel())); + log.info("[redisMessageListenerContainer][注册 Channel({}) 对应的监听器({})]", + listener.getChannel(), listener.getClass().getName()); + }); + return container; + } + + /** + * 创建 Redis Stream 重新消费的任务 + */ + @Bean + @ConditionalOnBean(AbstractRedisStreamMessageListener.class) // 只有 AbstractStreamMessageListener 存在的时候,才需要注册 Redis pubsub 监听 + public RedisPendingMessageResendJob redisPendingMessageResendJob(List> listeners, + RedisMQTemplate redisTemplate, + @Value("${spring.application.name}") String groupName, + RedissonClient redissonClient) { + return new RedisPendingMessageResendJob(listeners, redisTemplate, groupName, redissonClient); + } + + /** + * 创建 Redis Stream 集群消费的容器 + * + * 基础知识:Redis Stream 的 xreadgroup 命令 + */ + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnBean(AbstractRedisStreamMessageListener.class) // 只有 AbstractStreamMessageListener 存在的时候,才需要注册 Redis pubsub 监听 + public StreamMessageListenerContainer> redisStreamMessageListenerContainer( + RedisMQTemplate redisMQTemplate, List> listeners) { + RedisTemplate redisTemplate = redisMQTemplate.getRedisTemplate(); + checkRedisVersion(redisTemplate); + // 第一步,创建 StreamMessageListenerContainer 容器 + // 创建 options 配置 + StreamMessageListenerContainer.StreamMessageListenerContainerOptions> containerOptions = + StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() + .batchSize(10) // 一次性最多拉取多少条消息 + .targetType(String.class) // 目标类型。统一使用 String,通过自己封装的 AbstractStreamMessageListener 去反序列化 + .build(); + // 创建 container 对象 + StreamMessageListenerContainer> container = + StreamMessageListenerContainer.create(redisMQTemplate.getRedisTemplate().getRequiredConnectionFactory(), containerOptions); + + // 第二步,注册监听器,消费对应的 Stream 主题 + String consumerName = buildConsumerName(); + listeners.parallelStream().forEach(listener -> { + log.info("[redisStreamMessageListenerContainer][开始注册 StreamKey({}) 对应的监听器({})]", + listener.getStreamKey(), listener.getClass().getName()); + // 创建 listener 对应的消费者分组 + try { + redisTemplate.opsForStream().createGroup(listener.getStreamKey(), listener.getGroup()); + } catch (Exception ignore) { + } + // 设置 listener 对应的 redisTemplate + listener.setRedisMQTemplate(redisMQTemplate); + // 创建 Consumer 对象 + Consumer consumer = Consumer.from(listener.getGroup(), consumerName); + // 设置 Consumer 消费进度,以最小消费进度为准 + StreamOffset streamOffset = StreamOffset.create(listener.getStreamKey(), ReadOffset.lastConsumed()); + // 设置 Consumer 监听 + StreamMessageListenerContainer.StreamReadRequestBuilder builder = StreamMessageListenerContainer.StreamReadRequest + .builder(streamOffset).consumer(consumer) + .autoAcknowledge(false) // 不自动 ack + .cancelOnError(throwable -> false); // 默认配置,发生异常就取消消费,显然不符合预期;因此,我们设置为 false + container.register(builder.build(), listener); + log.info("[redisStreamMessageListenerContainer][完成注册 StreamKey({}) 对应的监听器({})]", + listener.getStreamKey(), listener.getClass().getName()); + }); + return container; + } + + /** + * 构建消费者名字,使用本地 IP + 进程编号的方式。 + * 参考自 RocketMQ clientId 的实现 + * + * @return 消费者名字 + */ + private static String buildConsumerName() { + return String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); + } + + /** + * 校验 Redis 版本号,是否满足最低的版本号要求! + */ + private static void checkRedisVersion(RedisTemplate redisTemplate) { + // 获得 Redis 版本 + Properties info = redisTemplate.execute((RedisCallback) RedisServerCommands::info); + String version = MapUtil.getStr(info, "redis_version"); + // 校验最低版本必须大于等于 5.0.0 + int majorVersion = Integer.parseInt(StrUtil.subBefore(version, '.', false)); + if (majorVersion < 5) { + throw new IllegalStateException(StrUtil.format("您当前的 Redis 版本为 {},小于最低要求的 5.0.0 版本!" + + "请参考 {} 文档进行安装。", version, DocumentEnum.REDIS_INSTALL.getUrl())); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/config/ChenfengRedisMQProducerAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/config/ChenfengRedisMQProducerAutoConfiguration.java new file mode 100644 index 000000000..4975d0578 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/config/ChenfengRedisMQProducerAutoConfiguration.java @@ -0,0 +1,31 @@ +package com.cf.imes.framework.mq.redis.config; + +import com.cf.imes.framework.mq.redis.core.RedisMQTemplate; +import com.cf.imes.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.cf.imes.framework.redis.config.ChenfengRedisAutoConfiguration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +/** + * Redis 消息队列 Producer 配置类 + * + * @author 晨丰科技 + */ +@Slf4j +@AutoConfiguration(after = ChenfengRedisAutoConfiguration.class) +public class ChenfengRedisMQProducerAutoConfiguration { + + @Bean + public RedisMQTemplate redisMQTemplate(StringRedisTemplate redisTemplate, + List interceptors) { + RedisMQTemplate redisMQTemplate = new RedisMQTemplate(redisTemplate); + // 添加拦截器 + interceptors.forEach(redisMQTemplate::addInterceptor); + return redisMQTemplate; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/RedisMQTemplate.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/RedisMQTemplate.java new file mode 100644 index 000000000..a3319751c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/RedisMQTemplate.java @@ -0,0 +1,87 @@ +package com.cf.imes.framework.mq.redis.core; + +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; +import com.cf.imes.framework.mq.redis.core.pubsub.AbstractRedisChannelMessage; +import com.cf.imes.framework.mq.redis.core.stream.AbstractRedisStreamMessage; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.ArrayList; +import java.util.List; + +/** + * Redis MQ 操作模板类 + * + * @author 晨丰科技 + */ +@AllArgsConstructor +public class RedisMQTemplate { + + @Getter + private final RedisTemplate redisTemplate; + /** + * 拦截器数组 + */ + @Getter + private final List interceptors = new ArrayList<>(); + + /** + * 发送 Redis 消息,基于 Redis pub/sub 实现 + * + * @param message 消息 + */ + public void send(T message) { + try { + sendMessageBefore(message); + // 发送消息 + redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message)); + } finally { + sendMessageAfter(message); + } + } + + /** + * 发送 Redis 消息,基于 Redis Stream 实现 + * + * @param message 消息 + * @return 消息记录的编号对象 + */ + public RecordId send(T message) { + try { + sendMessageBefore(message); + // 发送消息 + return redisTemplate.opsForStream().add(StreamRecords.newRecord() + .ofObject(JsonUtils.toJsonString(message)) // 设置内容 + .withStreamKey(message.getStreamKey())); // 设置 stream key + } finally { + sendMessageAfter(message); + } + } + + /** + * 添加拦截器 + * + * @param interceptor 拦截器 + */ + public void addInterceptor(RedisMessageInterceptor interceptor) { + interceptors.add(interceptor); + } + + private void sendMessageBefore(AbstractRedisMessage message) { + // 正序 + interceptors.forEach(interceptor -> interceptor.sendMessageBefore(message)); + } + + private void sendMessageAfter(AbstractRedisMessage message) { + // 倒序 + for (int i = interceptors.size() - 1; i >= 0; i--) { + interceptors.get(i).sendMessageAfter(message); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java new file mode 100644 index 000000000..cd523a3a1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java @@ -0,0 +1,26 @@ +package com.cf.imes.framework.mq.redis.core.interceptor; + +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; + +/** + * {@link AbstractRedisMessage} 消息拦截器 + * 通过拦截器,作为插件机制,实现拓展。 + * 例如说,多组织场景下的 MQ 消息处理 + * + * @author 晨丰科技 + */ +public interface RedisMessageInterceptor { + + default void sendMessageBefore(AbstractRedisMessage message) { + } + + default void sendMessageAfter(AbstractRedisMessage message) { + } + + default void consumeMessageBefore(AbstractRedisMessage message) { + } + + default void consumeMessageAfter(AbstractRedisMessage message) { + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/job/RedisPendingMessageResendJob.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/job/RedisPendingMessageResendJob.java new file mode 100644 index 000000000..bd2183f50 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/job/RedisPendingMessageResendJob.java @@ -0,0 +1,100 @@ +package com.cf.imes.framework.mq.redis.core.job; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.mq.redis.core.RedisMQTemplate; +import com.cf.imes.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 这个任务用于处理,crash 之后的消费者未消费完的消息 + */ +@Slf4j +@AllArgsConstructor +public class RedisPendingMessageResendJob { + + private static final String LOCK_KEY = "redis:pending:msg:lock"; + + /** + * 消息超时时间,默认 5 分钟 + * + * 1. 超时的消息才会被重新投递 + * 2. 由于定时任务 1 分钟一次,消息超时后不会被立即重投,极端情况下消息5分钟过期后,再等 1 分钟才会被扫瞄到 + */ + private static final int EXPIRE_TIME = 5 * 60; + + private final List> listeners; + private final RedisMQTemplate redisTemplate; + private final String groupName; + private final RedissonClient redissonClient; + + /** + * 一分钟执行一次,这里选择每分钟的35秒执行,是为了避免整点任务过多的问题 + */ + @Scheduled(cron = "35 * * * * ?") + public void messageResend() { + RLock lock = redissonClient.getLock(LOCK_KEY); + // 尝试加锁 + if (lock.tryLock()) { + try { + execute(); + } catch (Exception ex) { + log.error("[messageResend][执行异常]", ex); + } finally { + lock.unlock(); + } + } + } + + /** + * 执行清理逻辑 + * + * @see 讨论 + */ + private void execute() { + StreamOperations ops = redisTemplate.getRedisTemplate().opsForStream(); + listeners.forEach(listener -> { + PendingMessagesSummary pendingMessagesSummary = Objects.requireNonNull(ops.pending(listener.getStreamKey(), groupName)); + // 每个消费者的 pending 队列消息数量 + Map pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); + pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { + log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount); + // 每个消费者的 pending消息的详情信息 + PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(groupName, consumerName), Range.unbounded(), pendingMessageCount); + if (pendingMessages.isEmpty()) { + return; + } + pendingMessages.forEach(pendingMessage -> { + // 获取消息上一次传递到 consumer 的时间, + long lastDelivery = pendingMessage.getElapsedTimeSinceLastDelivery().getSeconds(); + if (lastDelivery < EXPIRE_TIME){ + return; + } + // 获取指定 id 的消息体 + List> records = ops.range(listener.getStreamKey(), + Range.of(Range.Bound.inclusive(pendingMessage.getIdAsString()), Range.Bound.inclusive(pendingMessage.getIdAsString()))); + if (CollUtil.isEmpty(records)) { + return; + } + // 重新投递消息 + redisTemplate.getRedisTemplate().opsForStream().add(StreamRecords.newRecord() + .ofObject(records.get(0).getValue()) // 设置内容 + .withStreamKey(listener.getStreamKey())); + // ack 消息消费完成 + redisTemplate.getRedisTemplate().opsForStream().acknowledge(groupName, records.get(0)); + log.info("[processPendingMessage][消息({})重新投递成功]", records.get(0).getId()); + }); + }); + }); + } +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/message/AbstractRedisMessage.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/message/AbstractRedisMessage.java new file mode 100644 index 000000000..186d21b3a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/message/AbstractRedisMessage.java @@ -0,0 +1,29 @@ +package com.cf.imes.framework.mq.redis.core.message; + +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +/** + * Redis 消息抽象基类 + * + * @author 晨丰科技 + */ +@Data +public abstract class AbstractRedisMessage { + + /** + * 头 + */ + private Map headers = new HashMap<>(); + + public String getHeader(String key) { + return headers.get(key); + } + + public void addHeader(String key, String value) { + headers.put(key, value); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java new file mode 100644 index 000000000..cb9a65dd3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java @@ -0,0 +1,23 @@ +package com.cf.imes.framework.mq.redis.core.pubsub; + +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Redis Channel Message 抽象类 + * + * @author 晨丰科技 + */ +public abstract class AbstractRedisChannelMessage extends AbstractRedisMessage { + + /** + * 获得 Redis Channel,默认使用类名 + * + * @return Channel + */ + @JsonIgnore // 避免序列化。原因是,Redis 发布 Channel 消息的时候,已经会指定。 + public String getChannel() { + return getClass().getSimpleName(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java new file mode 100644 index 000000000..62cc07303 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java @@ -0,0 +1,103 @@ +package com.cf.imes.framework.mq.redis.core.pubsub; + +import cn.hutool.core.util.TypeUtil; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.mq.redis.core.RedisMQTemplate; +import com.cf.imes.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; +import lombok.Setter; +import lombok.SneakyThrows; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * Redis Pub/Sub 监听器抽象类,用于实现广播消费 + * + * @param 消息类型。一定要填写噢,不然会报错 + * + * @author 晨丰科技 + */ +public abstract class AbstractRedisChannelMessageListener implements MessageListener { + + /** + * 消息类型 + */ + private final Class messageType; + /** + * Redis Channel + */ + private final String channel; + /** + * RedisMQTemplate + */ + @Setter + private RedisMQTemplate redisMQTemplate; + + @SneakyThrows + protected AbstractRedisChannelMessageListener() { + this.messageType = getMessageClass(); + this.channel = messageType.getDeclaredConstructor().newInstance().getChannel(); + } + + /** + * 获得 Sub 订阅的 Redis Channel 通道 + * + * @return channel + */ + public final String getChannel() { + return channel; + } + + @Override + public final void onMessage(Message message, byte[] bytes) { + T messageObj = JsonUtils.parseObject(message.getBody(), messageType); + try { + consumeMessageBefore(messageObj); + // 消费消息 + this.onMessage(messageObj); + } finally { + consumeMessageAfter(messageObj); + } + } + + /** + * 处理消息 + * + * @param message 消息 + */ + public abstract void onMessage(T message); + + /** + * 通过解析类上的泛型,获得消息类型 + * + * @return 消息类型 + */ + @SuppressWarnings("unchecked") + private Class getMessageClass() { + Type type = TypeUtil.getTypeArgument(getClass(), 0); + if (type == null) { + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); + } + return (Class) type; + } + + private void consumeMessageBefore(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List interceptors = redisMQTemplate.getInterceptors(); + // 正序 + interceptors.forEach(interceptor -> interceptor.consumeMessageBefore(message)); + } + + private void consumeMessageAfter(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List interceptors = redisMQTemplate.getInterceptors(); + // 倒序 + for (int i = interceptors.size() - 1; i >= 0; i--) { + interceptors.get(i).consumeMessageAfter(message); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java new file mode 100644 index 000000000..8e9900caf --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java @@ -0,0 +1,23 @@ +package com.cf.imes.framework.mq.redis.core.stream; + +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Redis Stream Message 抽象类 + * + * @author 晨丰科技 + */ +public abstract class AbstractRedisStreamMessage extends AbstractRedisMessage { + + /** + * 获得 Redis Stream Key,默认使用类名 + * + * @return Channel + */ + @JsonIgnore // 避免序列化 + public String getStreamKey() { + return getClass().getSimpleName(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java new file mode 100644 index 000000000..6d7d388cb --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java @@ -0,0 +1,113 @@ +package com.cf.imes.framework.mq.redis.core.stream; + +import cn.hutool.core.util.TypeUtil; +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.cf.imes.framework.mq.redis.core.RedisMQTemplate; +import com.cf.imes.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.cf.imes.framework.mq.redis.core.message.AbstractRedisMessage; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.stream.StreamListener; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * Redis Stream 监听器抽象类,用于实现集群消费 + * + * @param 消息类型。一定要填写噢,不然会报错 + * + * @author 晨丰科技 + */ +public abstract class AbstractRedisStreamMessageListener + implements StreamListener> { + + /** + * 消息类型 + */ + private final Class messageType; + /** + * Redis Channel + */ + @Getter + private final String streamKey; + + /** + * Redis 消费者分组,默认使用 spring.application.name 名字 + */ + @Value("${spring.application.name}") + @Getter + private String group; + /** + * RedisMQTemplate + */ + @Setter + private RedisMQTemplate redisMQTemplate; + + @SneakyThrows + protected AbstractRedisStreamMessageListener() { + this.messageType = getMessageClass(); + this.streamKey = messageType.getDeclaredConstructor().newInstance().getStreamKey(); + } + + @Override + public void onMessage(ObjectRecord message) { + // 消费消息 + T messageObj = JsonUtils.parseObject(message.getValue(), messageType); + try { + consumeMessageBefore(messageObj); + // 消费消息 + this.onMessage(messageObj); + // ack 消息消费完成 + redisMQTemplate.getRedisTemplate().opsForStream().acknowledge(group, message); + // TODO 晨丰:需要额外考虑以下几个点: + // 1. 处理异常的情况 + // 2. 发送日志;以及事务的结合 + // 3. 消费日志;以及通用的幂等性 + // 4. 消费失败的重试,https://zhuanlan.zhihu.com/p/60501638 + } finally { + consumeMessageAfter(messageObj); + } + } + + /** + * 处理消息 + * + * @param message 消息 + */ + public abstract void onMessage(T message); + + /** + * 通过解析类上的泛型,获得消息类型 + * + * @return 消息类型 + */ + @SuppressWarnings("unchecked") + private Class getMessageClass() { + Type type = TypeUtil.getTypeArgument(getClass(), 0); + if (type == null) { + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); + } + return (Class) type; + } + + private void consumeMessageBefore(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List interceptors = redisMQTemplate.getInterceptors(); + // 正序 + interceptors.forEach(interceptor -> interceptor.consumeMessageBefore(message)); + } + + private void consumeMessageAfter(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List interceptors = redisMQTemplate.getInterceptors(); + // 倒序 + for (int i = interceptors.size() - 1; i >= 0; i--) { + interceptors.get(i).consumeMessageAfter(message); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/package-info.java b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/package-info.java new file mode 100644 index 000000000..78129c746 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/java/com/cf/imes/framework/mq/redis/package-info.java @@ -0,0 +1,6 @@ +/** + * 消息队列,基于 Redis 提供: + * 1. 基于 Pub/Sub 实现广播消费 + * 2. 基于 Stream 实现集群消费 + */ +package com.cf.imes.framework.mq.redis; diff --git a/cf-framework/cf-spring-boot-starter-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..652aa08b8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +com.cf.imes.framework.mq.redis.config.ChenfengRedisMQProducerAutoConfiguration +com.cf.imes.framework.mq.redis.config.ChenfengRedisMQConsumerAutoConfiguration +com.cf.imes.framework.mq.rabbitmq.config.ChenfengRabbitMQAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-mybatis/pom.xml b/cf-framework/cf-spring-boot-starter-mybatis/pom.xml new file mode 100644 index 000000000..8643d8f86 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/pom.xml @@ -0,0 +1,76 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-mybatis + jar + + ${project.artifactId} + 数据库连接池、多数据源、事务、MyBatis 拓展 + https://github.com/YunaiV/ruoyi-vue-pro + + + + com.cf.imes + cf-common + + + + + com.cf.imes + cf-spring-boot-starter-web + provided + + + + + com.mysql + mysql-connector-j + + + com.oracle.database.jdbc + ojdbc8 + true + + + org.postgresql + postgresql + true + + + com.microsoft.sqlserver + mssql-jdbc + true + + + com.dameng + DmJdbcDriver18 + true + + + + com.alibaba + druid-spring-boot-starter + + + com.baomidou + mybatis-plus-boot-starter + + + com.baomidou + dynamic-datasource-spring-boot-starter + + + + com.github.yulichang + mybatis-plus-join-boot-starter + + + + diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/config/ChenfengDataSourceAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/config/ChenfengDataSourceAutoConfiguration.java new file mode 100644 index 000000000..3c1eec52a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/config/ChenfengDataSourceAutoConfiguration.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.datasource.config; + +import com.cf.imes.framework.datasource.core.filter.DruidAdRemoveFilter; +import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * 数据库配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@EnableTransactionManagement(proxyTargetClass = true) // 启动事务管理 +@EnableConfigurationProperties(DruidStatProperties.class) +public class ChenfengDataSourceAutoConfiguration { + + /** + * 创建 DruidAdRemoveFilter 过滤器,过滤 common.js 的广告 + */ + @Bean + @ConditionalOnProperty(name = "spring.datasource.druid.web-stat-filter.enabled", havingValue = "true") + public FilterRegistrationBean druidAdRemoveFilterFilter(DruidStatProperties properties) { + // 获取 druid web 监控页面的参数 + DruidStatProperties.StatViewServlet config = properties.getStatViewServlet(); + // 提取 common.js 的配置路径 + String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*"; + String commonJsPattern = pattern.replaceAll("\\*", "js/common.js"); + // 创建 DruidAdRemoveFilter Bean + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new DruidAdRemoveFilter()); + registrationBean.addUrlPatterns(commonJsPattern); + return registrationBean; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/core/enums/DataSourceEnum.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/core/enums/DataSourceEnum.java new file mode 100644 index 000000000..aa45ab436 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/core/enums/DataSourceEnum.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.datasource.core.enums; + +/** + * 对应于多数据源中不同数据源配置 + * + * 通过在方法上,使用 {@link com.baomidou.dynamic.datasource.annotation.DS} 注解,设置使用的数据源。 + * 注意,默认是 {@link #MASTER} 数据源 + * + * 对应官方文档为 http://dynamic-datasource.com/guide/customize/Annotation.html + */ +public interface DataSourceEnum { + + /** + * 主库,推荐使用 {@link com.baomidou.dynamic.datasource.annotation.Master} 注解 + */ + String MASTER = "master"; + /** + * 从库,推荐使用 {@link com.baomidou.dynamic.datasource.annotation.Slave} 注解 + */ + String SLAVE = "slave"; + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/core/filter/DruidAdRemoveFilter.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/core/filter/DruidAdRemoveFilter.java new file mode 100644 index 000000000..fee3e975a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/core/filter/DruidAdRemoveFilter.java @@ -0,0 +1,38 @@ +package com.cf.imes.framework.datasource.core.filter; + +import com.alibaba.druid.util.Utils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Druid 底部广告过滤器 + * + * @author 晨丰科技 + */ +public class DruidAdRemoveFilter extends OncePerRequestFilter { + + /** + * common.js 的路径 + */ + private static final String COMMON_JS_ILE_PATH = "support/http/resources/js/common.js"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + chain.doFilter(request, response); + // 重置缓冲区,响应头不会被重置 + response.resetBuffer(); + // 获取 common.js + String text = Utils.readFromResource(COMMON_JS_ILE_PATH); + // 正则替换 banner, 除去底部的广告信息 + text = text.replaceAll("
", ""); + text = text.replaceAll("powered.*?shrek.wang", ""); + response.getWriter().write(text); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/package-info.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/package-info.java new file mode 100644 index 000000000..7d95bbc14 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/datasource/package-info.java @@ -0,0 +1,5 @@ +/** + * 数据库连接池,采用 Druid + * 多数据源,采用爆米花 + */ +package com.cf.imes.framework.datasource; diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/config/ChenfengMybatisAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/config/ChenfengMybatisAutoConfiguration.java new file mode 100644 index 000000000..35f1255c5 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/config/ChenfengMybatisAutoConfiguration.java @@ -0,0 +1,76 @@ +package com.cf.imes.framework.mybatis.config; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; +import com.cf.imes.framework.mybatis.core.generator.SnowFlakeGenerator; +import com.cf.imes.framework.mybatis.core.handler.DefaultDBFieldHandler; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator; +import com.baomidou.mybatisplus.extension.incrementer.*; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.apache.ibatis.annotations.Mapper; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * MyBaits 配置类 + * + * @author 晨丰科技 + */ +@AutoConfiguration +@MapperScan(value = "${chenfeng.info.base-package}", annotationClass = Mapper.class, + lazyInitialization = "${mybatis.lazy-initialization:false}") // Mapper 懒加载,目前仅用于单元测试 +public class ChenfengMybatisAutoConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "mybatis-plus.global-config.db-config", name = "id-type", havingValue = "ASSIGN_ID") + public IdentifierGenerator identifierGenerator( + @Value("${mybatis-plus.global-config.db-config.worker-id}") Integer workerId, + @Value("${mybatis-plus.global-config.db-config.datacenter-id}") Integer datacenterId + ) { + return new SnowFlakeGenerator(workerId, datacenterId); + } + + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); + mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 + return mybatisPlusInterceptor; + } + + @Bean + public MetaObjectHandler defaultMetaObjectHandler(){ + return new DefaultDBFieldHandler(); // 自动填充参数类 + } + + @Bean + @ConditionalOnProperty(prefix = "mybatis-plus.global-config.db-config", name = "id-type", havingValue = "INPUT") + public IKeyGenerator keyGenerator(ConfigurableEnvironment environment) { + DbType dbType = IdTypeEnvironmentPostProcessor.getDbType(environment); + if (dbType != null) { + switch (dbType) { + case POSTGRE_SQL: + return new PostgreKeyGenerator(); + case ORACLE: + case ORACLE_12C: + return new OracleKeyGenerator(); + case H2: + return new H2KeyGenerator(); + case KINGBASE_ES: + return new KingbaseKeyGenerator(); + case DM: + return new DmKeyGenerator(); + } + } + // 找不到合适的 IKeyGenerator 实现类 + throw new IllegalArgumentException(StrUtil.format("DbType{} 找不到合适的 IKeyGenerator 实现类", dbType)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java new file mode 100644 index 000000000..79aa7c061 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java @@ -0,0 +1,108 @@ +package com.cf.imes.framework.mybatis.config; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.common.util.collection.SetUtils; +import com.cf.imes.framework.mybatis.core.enums.SqlConstants; +import com.cf.imes.framework.mybatis.core.util.JdbcUtils; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.annotation.IdType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +import java.util.Set; + +/** + * 当 IdType 为 {@link IdType#NONE} 时,根据 PRIMARY 数据源所使用的数据库,自动设置 + * + * @author 晨丰科技 + */ +@Slf4j +public class IdTypeEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String ID_TYPE_KEY = "mybatis-plus.global-config.db-config.id-type"; + + private static final String DATASOURCE_DYNAMIC_KEY = "spring.datasource.dynamic"; + + private static final String QUARTZ_JOB_STORE_DRIVER_KEY = "spring.quartz.properties.org.quartz.jobStore.driverDelegateClass"; + + private static final Set INPUT_ID_TYPES = SetUtils.asSet(DbType.ORACLE, DbType.ORACLE_12C, + DbType.POSTGRE_SQL, DbType.KINGBASE_ES, DbType.DB2, DbType.H2); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 如果获取不到 DbType,则不进行处理 + DbType dbType = getDbType(environment); + if (dbType == null) { + return; + } + + // 设置 Quartz JobStore 对应的 Driver + // TODO 晨丰:暂时没有找到特别合适的地方,先放在这里 + setJobStoreDriverIfPresent(environment, dbType); + + // 初始化 SQL 静态变量 + SqlConstants.init(dbType); + + // 如果非 NONE,则不进行处理 + IdType idType = getIdType(environment); + if (idType != IdType.NONE) { + return; + } + // 情况一,用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库 + if (INPUT_ID_TYPES.contains(dbType)) { + setIdType(environment, IdType.INPUT); + return; + } + // 情况二,自增 ID,适合 MySQL 等直接自增的数据库 + setIdType(environment, IdType.AUTO); + } + + public IdType getIdType(ConfigurableEnvironment environment) { + return environment.getProperty(ID_TYPE_KEY, IdType.class); + } + + public void setIdType(ConfigurableEnvironment environment, IdType idType) { + environment.getSystemProperties().put(ID_TYPE_KEY, idType); + log.info("[setIdType][修改 MyBatis Plus 的 idType 为({})]", idType); + } + + public void setJobStoreDriverIfPresent(ConfigurableEnvironment environment, DbType dbType) { + String driverClass = environment.getProperty(QUARTZ_JOB_STORE_DRIVER_KEY); + if (StrUtil.isNotEmpty(driverClass)) { + return; + } + // 根据 dbType 类型,获取对应的 driverClass + switch (dbType) { + case POSTGRE_SQL: + driverClass = "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate"; + break; + case ORACLE: + case ORACLE_12C: + driverClass = "org.quartz.impl.jdbcjobstore.oracle.OracleDelegate"; + break; + case SQL_SERVER: + case SQL_SERVER2005: + driverClass = "org.quartz.impl.jdbcjobstore.MSSQLDelegate"; + break; + } + // 设置 driverClass 变量 + if (StrUtil.isNotEmpty(driverClass)) { + environment.getSystemProperties().put(QUARTZ_JOB_STORE_DRIVER_KEY, driverClass); + } + } + + public static DbType getDbType(ConfigurableEnvironment environment) { + String primary = environment.getProperty(DATASOURCE_DYNAMIC_KEY + "." + "primary"); + if (StrUtil.isEmpty(primary)) { + return null; + } + String url = environment.getProperty(DATASOURCE_DYNAMIC_KEY + ".datasource." + primary + ".url"); + if (StrUtil.isEmpty(url)) { + return null; + } + return JdbcUtils.getDbType(url); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/dataobject/BaseDO.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/dataobject/BaseDO.java new file mode 100644 index 000000000..2b660bdd8 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/dataobject/BaseDO.java @@ -0,0 +1,50 @@ +package com.cf.imes.framework.mybatis.core.dataobject; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import lombok.Data; +import org.apache.ibatis.type.JdbcType; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 基础实体对象 + * + * @author 晨丰科技 + */ +@Data +public abstract class BaseDO implements Serializable { + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + /** + * 最后更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + /** + * 创建者,目前使用 SysUser 的 id 编号 + * + * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 + */ + @TableField(fill = FieldFill.INSERT, jdbcType = JdbcType.VARCHAR) + private String creator; + /** + * 更新者,目前使用 SysUser 的 id 编号 + * + * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE, jdbcType = JdbcType.VARCHAR) + private String updater; + /** + * 是否删除 + */ + @TableLogic + private Boolean deleted; + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/enums/SqlConstants.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/enums/SqlConstants.java new file mode 100644 index 000000000..29c2f55a6 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/enums/SqlConstants.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.mybatis.core.enums; + +import com.baomidou.mybatisplus.annotation.DbType; + +/** + * SQL相关常量类 + * + * @author 晨丰科技 + */ +public class SqlConstants { + + /** + * 数据库的类型 + */ + public static DbType DB_TYPE; + + public static void init(DbType dbType) { + DB_TYPE = dbType; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/generator/SnowFlake.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/generator/SnowFlake.java new file mode 100644 index 000000000..1921ef09b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/generator/SnowFlake.java @@ -0,0 +1,127 @@ +package com.cf.imes.framework.mybatis.core.generator; + + +/** + * Java 雪花算法 + * @author there + */ +public class SnowFlake { + // 起始的时间戳,这个时间戳可以是你的系统初始时间,一般取当前时间戳 + private final static long START_TIMESTAMP = 1672502400000L; // 2023-01-01 00:00:00 + + // 每一部分占用的位数,可以根据自己的需求进行调整,这里是按照默认的占位数进行分配 + private final static long SEQUENCE_BIT = 12; // 序列号占用的位数 + private final static long MACHINE_BIT = 5; // 机器标识占用的位数 + private final static long DATA_CENTER_BIT = 5; // 数据中心占用的位数 + + // 每一部分的最大值,可以根据占用的位数进行计算得到 + private final static long MAX_SEQUENCE = ~(-1L << SEQUENCE_BIT); + private final static long MAX_MACHINE_NUM = ~(-1L << MACHINE_BIT); + private final static long MAX_DATA_CENTER_NUM = ~(-1L << DATA_CENTER_BIT); + + // 每一部分向左的位移,计算出来的值是为了后面生成 ID 做准备 + private final static long MACHINE_LEFT = SEQUENCE_BIT; + private final static long DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT; + private final static long TIMESTAMP_LEFT = DATA_CENTER_LEFT + DATA_CENTER_BIT; + + private long dataCenterId; // 数据中心 ID + private long machineId; // 机器 ID + private long sequence = 0L; // 序列号 + private long lastTimeStamp = -1L; // 上一次时间戳 + + /** + *

构造方法

+ * + * @param dataCenterId 数据中心 ID + * @param machineId 机器 ID + */ + public SnowFlake(long dataCenterId, long machineId) { + if (dataCenterId > MAX_DATA_CENTER_NUM || dataCenterId < 0) { + throw new IllegalArgumentException("数据中心标识不能大于等于 " + MAX_DATA_CENTER_NUM + " 或小于 0"); + } + if (machineId > MAX_MACHINE_NUM || machineId < 0) { + throw new IllegalArgumentException("机器标识不能大于等于 " + MAX_MACHINE_NUM + " 或小于 0"); + } + this.dataCenterId = dataCenterId; + this.machineId = machineId; + } + + /** + *

雪花算法核心方法

+ * 通过调用 nextId() 方法,让当前这台机器上的 snowflake 算法程序生成一个全局唯一的 id + */ + public synchronized long nextId() { + // 获取系统当前时间戳 + long currentTimeStamp = getSystemCurrentTimeMillis(); + if (currentTimeStamp < lastTimeStamp) { + throw new RuntimeException("时钟向后移动,拒绝生成雪花算法ID"); + } + + if (currentTimeStamp == lastTimeStamp) { + // 当前毫秒内,序列号自增 + sequence = (sequence + 1) & MAX_SEQUENCE; + // 序列号超出范围,需要等待下一毫秒 + if (sequence == 0L) { + // 获取下一毫秒 + currentTimeStamp = getNextMill(lastTimeStamp); + } + } else { + // 不同毫秒内,序列号置为 0 + sequence = 0L; + } + + lastTimeStamp = currentTimeStamp; + + // 使用位运算生成最终的 ID + long l = 0 | (currentTimeStamp - START_TIMESTAMP) << TIMESTAMP_LEFT + | dataCenterId << DATA_CENTER_LEFT + | machineId << MACHINE_LEFT + | sequence; + return l; + } + + /** + *

获取系统当前时间戳

+ * + * @return 当前时间(毫秒) + */ + private long getSystemCurrentTimeMillis() { + return System.currentTimeMillis(); + } + + /** + *

获取下一毫秒

+ * 当某一毫秒的时间,产生的 id 数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生 ID + * + * @param lastTimestamp 上次生成 ID 的时间截 + * @return 当前时间戳 + */ + private long getNextMill(long lastTimestamp) { + long timeMillis = getSystemCurrentTimeMillis(); + while (timeMillis <= lastTimestamp) { + timeMillis = getSystemCurrentTimeMillis(); + } + return timeMillis; + } + + /** + *

测试类

+ */ + public static void main(String[] args) { + SnowFlake worker1_1 = new SnowFlake(1, 1); + /* SnowFlakeGenerator worker2_1 = new SnowFlakeGenerator(2, 1); + SnowFlakeGenerator worker3_1 = new SnowFlakeGenerator(3, 1); + + SnowFlakeGenerator worker1_2 = new SnowFlakeGenerator(1, 2); + SnowFlakeGenerator worker1_3 = new SnowFlakeGenerator(1, 3);*/ + for (int i = 0; i < 30; i++) { + System.out.println("数据中心1,实例1,雪花算法 ID:" + worker1_1.nextId()); + /*System.out.println("数据中心2,实例1,雪花算法 ID:" + worker2_1.nextId()); + System.out.println("数据中心3,实例1,雪花算法 ID:" + worker3_1.nextId()); + + System.out.println("数据中心1,实例2,雪花算法 ID:" + worker1_2.nextId()); + System.out.println("数据中心1,实例3,雪花算法 ID:" + worker1_3.nextId());*/ + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/generator/SnowFlakeGenerator.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/generator/SnowFlakeGenerator.java new file mode 100644 index 000000000..933505f34 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/generator/SnowFlakeGenerator.java @@ -0,0 +1,23 @@ +package com.cf.imes.framework.mybatis.core.generator; + +import cn.hutool.core.lang.Snowflake; +import cn.hutool.core.util.IdUtil; +import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; + +/** + * @author there + */ +public class SnowFlakeGenerator implements IdentifierGenerator { + + private Snowflake snowflake; + + public SnowFlakeGenerator(Integer workerId,Integer datacenterId) { + this.snowflake = IdUtil.getSnowflake(workerId, datacenterId); + } + + @Override + public Number nextId(Object entity) { + return snowflake.nextId(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/handler/DefaultDBFieldHandler.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/handler/DefaultDBFieldHandler.java new file mode 100644 index 000000000..c98135cc1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/handler/DefaultDBFieldHandler.java @@ -0,0 +1,62 @@ +package com.cf.imes.framework.mybatis.core.handler; + +import com.cf.imes.framework.mybatis.core.dataobject.BaseDO; +import com.cf.imes.framework.web.core.util.WebFrameworkUtils; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 通用参数填充实现类 + * + * 如果没有显式的对通用参数进行赋值,这里会对通用参数进行填充、赋值 + * + * @author hexiaowu + */ +public class DefaultDBFieldHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + if (Objects.nonNull(metaObject) && metaObject.getOriginalObject() instanceof BaseDO) { + BaseDO baseDO = (BaseDO) metaObject.getOriginalObject(); + + LocalDateTime current = LocalDateTime.now(); + // 创建时间为空,则以当前时间为插入时间 + if (Objects.isNull(baseDO.getCreateTime())) { + baseDO.setCreateTime(current); + } + // 更新时间为空,则以当前时间为更新时间 + if (Objects.isNull(baseDO.getUpdateTime())) { + baseDO.setUpdateTime(current); + } + + Long userId = WebFrameworkUtils.getLoginUserId(); + // 当前登录用户不为空,创建人为空,则当前登录用户为创建人 + if (Objects.nonNull(userId) && Objects.isNull(baseDO.getCreator())) { + baseDO.setCreator(userId.toString()); + } + // 当前登录用户不为空,更新人为空,则当前登录用户为更新人 + if (Objects.nonNull(userId) && Objects.isNull(baseDO.getUpdater())) { + baseDO.setUpdater(userId.toString()); + } + } + } + + @Override + public void updateFill(MetaObject metaObject) { + // 更新时间为空,则以当前时间为更新时间 + Object modifyTime = getFieldValByName("updateTime", metaObject); + if (Objects.isNull(modifyTime)) { + setFieldValByName("updateTime", LocalDateTime.now(), metaObject); + } + + // 当前登录用户不为空,更新人为空,则当前登录用户为更新人 + Object modifier = getFieldValByName("updater", metaObject); + Long userId = WebFrameworkUtils.getLoginUserId(); + if (Objects.nonNull(userId) && Objects.isNull(modifier)) { + setFieldValByName("updater", userId.toString(), metaObject); + } + } +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/mapper/BaseMapperX.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/mapper/BaseMapperX.java new file mode 100644 index 000000000..8f6c93f73 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/mapper/BaseMapperX.java @@ -0,0 +1,191 @@ +package com.cf.imes.framework.mybatis.core.mapper; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.common.pojo.PageParam; +import com.cf.imes.framework.common.pojo.PageResult; +import com.cf.imes.framework.common.pojo.SortablePageParam; +import com.cf.imes.framework.common.pojo.SortingField; +import com.cf.imes.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.baomidou.mybatisplus.extension.toolkit.Db; +import com.github.yulichang.base.MPJBaseMapper; +import com.github.yulichang.interfaces.MPJBaseJoin; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; +import java.util.List; + +/** + * 在 MyBatis Plus 的 BaseMapper 的基础上拓展,提供更多的能力 + * + * 1. {@link BaseMapper} 为 MyBatis Plus 的基础接口,提供基础的 CRUD 能力 + * 2. {@link MPJBaseMapper} 为 MyBatis Plus Join 的基础接口,提供连表 Join 能力 + */ +public interface BaseMapperX extends MPJBaseMapper { + + default PageResult selectPage(SortablePageParam pageParam, @Param("ew") Wrapper queryWrapper) { + return selectPage(pageParam, pageParam.getSortingFields(), queryWrapper); + } + + default PageResult selectPage(PageParam pageParam, @Param("ew") Wrapper queryWrapper) { + return selectPage(pageParam, null, queryWrapper); + } + + default PageResult selectPage(PageParam pageParam, Collection sortingFields, @Param("ew") Wrapper queryWrapper) { + // 特殊:不分页,直接查询全部 + if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { + List list = selectList(queryWrapper); + return new PageResult<>(list, (long) list.size()); + } + + // MyBatis Plus 查询 + IPage mpPage = MyBatisUtils.buildPage(pageParam, sortingFields); + selectPage(mpPage, queryWrapper); + // 转换返回 + return new PageResult<>(mpPage.getRecords(), mpPage.getTotal()); + } + + default PageResult selectJoinPage(PageParam pageParam, Class clazz, MPJLambdaWrapper lambdaWrapper) { + // 特殊:不分页,直接查询全部 + if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageNo())) { + List list = selectJoinList(clazz, lambdaWrapper); + return new PageResult<>(list, (long) list.size()); + } + + // MyBatis Plus Join 查询 + IPage mpPage = MyBatisUtils.buildPage(pageParam); + mpPage = selectJoinPage(mpPage, clazz, lambdaWrapper); + // 转换返回 + return new PageResult<>(mpPage.getRecords(), mpPage.getTotal()); + } + + default PageResult selectJoinPage(PageParam pageParam, Class resultTypeClass, MPJBaseJoin joinQueryWrapper) { + IPage mpPage = MyBatisUtils.buildPage(pageParam); + selectJoinPage(mpPage, resultTypeClass, joinQueryWrapper); + // 转换返回 + return new PageResult<>(mpPage.getRecords(), mpPage.getTotal()); + } + + default T selectOne(String field, Object value) { + return selectOne(new QueryWrapper().eq(field, value)); + } + + default T selectOne(SFunction field, Object value) { + return selectOne(new LambdaQueryWrapper().eq(field, value)); + } + + default T selectOne(String field1, Object value1, String field2, Object value2) { + return selectOne(new QueryWrapper().eq(field1, value1).eq(field2, value2)); + } + + default T selectOne(SFunction field1, Object value1, SFunction field2, Object value2) { + return selectOne(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2)); + } + + default T selectOne(SFunction field1, Object value1, SFunction field2, Object value2, + SFunction field3, Object value3) { + return selectOne(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2) + .eq(field3, value3)); + } + + default Long selectCount() { + return selectCount(new QueryWrapper<>()); + } + + default Long selectCount(String field, Object value) { + return selectCount(new QueryWrapper().eq(field, value)); + } + + default Long selectCount(SFunction field, Object value) { + return selectCount(new LambdaQueryWrapper().eq(field, value)); + } + + default List selectList() { + return selectList(new QueryWrapper<>()); + } + + default List selectList(String field, Object value) { + return selectList(new QueryWrapper().eq(field, value)); + } + + default List selectList(SFunction field, Object value) { + return selectList(new LambdaQueryWrapper().eq(field, value)); + } + + default List selectList(String field, Collection values) { + if (CollUtil.isEmpty(values)) { + return CollUtil.newArrayList(); + } + return selectList(new QueryWrapper().in(field, values)); + } + + default List selectList(SFunction field, Collection values) { + if (CollUtil.isEmpty(values)) { + return CollUtil.newArrayList(); + } + return selectList(new LambdaQueryWrapper().in(field, values)); + } + + @Deprecated + default List selectList(SFunction leField, SFunction geField, Object value) { + return selectList(new LambdaQueryWrapper().le(leField, value).ge(geField, value)); + } + + default List selectList(SFunction field1, Object value1, SFunction field2, Object value2) { + return selectList(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2)); + } + + /** + * 批量插入,适合大量数据插入 + * + * @param entities 实体们 + */ + default Boolean insertBatch(Collection entities) { + return Db.saveBatch(entities); + } + + /** + * 批量插入,适合大量数据插入 + * + * @param entities 实体们 + * @param size 插入数量 Db.saveBatch 默认为 1000 + */ + default Boolean insertBatch(Collection entities, int size) { + return Db.saveBatch(entities, size); + } + + default int updateBatch(T update) { + return update(update, new QueryWrapper<>()); + } + + default Boolean updateBatch(Collection entities) { + return Db.updateBatchById(entities); + } + + default Boolean updateBatch(Collection entities, int size) { + return Db.updateBatchById(entities, size); + } + + default Boolean insertOrUpdate(T entity) { + return Db.saveOrUpdate(entity); + } + + default Boolean insertOrUpdateBatch(Collection collection) { + return Db.saveOrUpdateBatch(collection); + } + + default int delete(String field, String value) { + return delete(new QueryWrapper().eq(field, value)); + } + + default int delete(SFunction field, Object value) { + return delete(new LambdaQueryWrapper().eq(field, value)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/LambdaQueryWrapperX.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/LambdaQueryWrapperX.java new file mode 100644 index 000000000..1c92714f9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/LambdaQueryWrapperX.java @@ -0,0 +1,135 @@ +package com.cf.imes.framework.mybatis.core.query; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import com.cf.imes.framework.common.util.collection.ArrayUtils; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import org.springframework.util.StringUtils; + +import java.util.Collection; + +/** + * 拓展 MyBatis Plus QueryWrapper 类,主要增加如下功能: + *

+ * 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 + * + * @param 数据类型 + */ +public class LambdaQueryWrapperX extends LambdaQueryWrapper { + + public LambdaQueryWrapperX likeIfPresent(SFunction column, String val) { + if (StringUtils.hasText(val)) { + return (LambdaQueryWrapperX) super.like(column, val); + } + return this; + } + + public LambdaQueryWrapperX inIfPresent(SFunction column, Collection values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (LambdaQueryWrapperX) super.in(column, values); + } + return this; + } + + public LambdaQueryWrapperX inIfPresent(SFunction column, Object... values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (LambdaQueryWrapperX) super.in(column, values); + } + return this; + } + + public LambdaQueryWrapperX eqIfPresent(SFunction column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (LambdaQueryWrapperX) super.eq(column, val); + } + return this; + } + + public LambdaQueryWrapperX neIfPresent(SFunction column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (LambdaQueryWrapperX) super.ne(column, val); + } + return this; + } + + public LambdaQueryWrapperX gtIfPresent(SFunction column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX) super.gt(column, val); + } + return this; + } + + public LambdaQueryWrapperX geIfPresent(SFunction column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX) super.ge(column, val); + } + return this; + } + + public LambdaQueryWrapperX ltIfPresent(SFunction column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX) super.lt(column, val); + } + return this; + } + + public LambdaQueryWrapperX leIfPresent(SFunction column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX) super.le(column, val); + } + return this; + } + + public LambdaQueryWrapperX betweenIfPresent(SFunction column, Object val1, Object val2) { + if (val1 != null && val2 != null) { + return (LambdaQueryWrapperX) super.between(column, val1, val2); + } + if (val1 != null) { + return (LambdaQueryWrapperX) ge(column, val1); + } + if (val2 != null) { + return (LambdaQueryWrapperX) le(column, val2); + } + return this; + } + + public LambdaQueryWrapperX betweenIfPresent(SFunction column, Object[] values) { + Object val1 = ArrayUtils.get(values, 0); + Object val2 = ArrayUtils.get(values, 1); + return betweenIfPresent(column, val1, val2); + } + + // ========== 重写父类方法,方便链式调用 ========== + + @Override + public LambdaQueryWrapperX eq(boolean condition, SFunction column, Object val) { + super.eq(condition, column, val); + return this; + } + + @Override + public LambdaQueryWrapperX eq(SFunction column, Object val) { + super.eq(column, val); + return this; + } + + @Override + public LambdaQueryWrapperX orderByDesc(SFunction column) { + super.orderByDesc(true, column); + return this; + } + + @Override + public LambdaQueryWrapperX last(String lastSql) { + super.last(lastSql); + return this; + } + + @Override + public LambdaQueryWrapperX in(SFunction column, Collection coll) { + super.in(column, coll); + return this; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/MPJLambdaWrapperX.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/MPJLambdaWrapperX.java new file mode 100644 index 000000000..cf9f602bf --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/MPJLambdaWrapperX.java @@ -0,0 +1,313 @@ +package com.cf.imes.framework.mybatis.core.query; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import com.cf.imes.framework.common.util.collection.ArrayUtils; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.github.yulichang.toolkit.MPJWrappers; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.function.Consumer; + +/** + * 拓展 MyBatis Plus Join QueryWrapper 类,主要增加如下功能: + *

+ * 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 + * + * @param 数据类型 + */ +public class MPJLambdaWrapperX extends MPJLambdaWrapper { + + public MPJLambdaWrapperX likeIfPresent(SFunction column, String val) { + MPJWrappers.lambdaJoin().like(column, val); + if (StringUtils.hasText(val)) { + return (MPJLambdaWrapperX) super.like(column, val); + } + return this; + } + + public MPJLambdaWrapperX inIfPresent(SFunction column, Collection values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (MPJLambdaWrapperX) super.in(column, values); + } + return this; + } + + public MPJLambdaWrapperX inIfPresent(SFunction column, Object... values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (MPJLambdaWrapperX) super.in(column, values); + } + return this; + } + + public MPJLambdaWrapperX eqIfPresent(SFunction column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (MPJLambdaWrapperX) super.eq(column, val); + } + return this; + } + + public MPJLambdaWrapperX neIfPresent(SFunction column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (MPJLambdaWrapperX) super.ne(column, val); + } + return this; + } + + public MPJLambdaWrapperX gtIfPresent(SFunction column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX) super.gt(column, val); + } + return this; + } + + public MPJLambdaWrapperX geIfPresent(SFunction column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX) super.ge(column, val); + } + return this; + } + + public MPJLambdaWrapperX ltIfPresent(SFunction column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX) super.lt(column, val); + } + return this; + } + + public MPJLambdaWrapperX leIfPresent(SFunction column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX) super.le(column, val); + } + return this; + } + + public MPJLambdaWrapperX betweenIfPresent(SFunction column, Object val1, Object val2) { + if (val1 != null && val2 != null) { + return (MPJLambdaWrapperX) super.between(column, val1, val2); + } + if (val1 != null) { + return (MPJLambdaWrapperX) ge(column, val1); + } + if (val2 != null) { + return (MPJLambdaWrapperX) le(column, val2); + } + return this; + } + + public MPJLambdaWrapperX betweenIfPresent(SFunction column, Object[] values) { + Object val1 = ArrayUtils.get(values, 0); + Object val2 = ArrayUtils.get(values, 1); + return betweenIfPresent(column, val1, val2); + } + + // ========== 重写父类方法,方便链式调用 ========== + + @Override + public MPJLambdaWrapperX eq(boolean condition, SFunction column, Object val) { + super.eq(condition, column, val); + return this; + } + + @Override + public MPJLambdaWrapperX eq(SFunction column, Object val) { + super.eq(column, val); + return this; + } + + @Override + public MPJLambdaWrapperX orderByDesc(SFunction column) { + //noinspection unchecked + super.orderByDesc(true, column); + return this; + } + + @Override + public MPJLambdaWrapperX last(String lastSql) { + super.last(lastSql); + return this; + } + + @Override + public MPJLambdaWrapperX in(SFunction column, Collection coll) { + super.in(column, coll); + return this; + } + + @Override + public MPJLambdaWrapperX selectAll(Class clazz) { + super.selectAll(clazz); + return this; + } + + @Override + public MPJLambdaWrapperX selectAll(Class clazz, String prefix) { + super.selectAll(clazz, prefix); + return this; + } + + @Override + public MPJLambdaWrapperX selectAs(SFunction column, String alias) { + super.selectAs(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectAs(String column, SFunction alias) { + super.selectAs(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectAs(SFunction column, SFunction alias) { + super.selectAs(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectAs(String index, SFunction column, SFunction alias) { + super.selectAs(index, column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectAsClass(Class source, Class tag) { + super.selectAsClass(source, tag); + return this; + } + + @Override + public MPJLambdaWrapperX selectSub(Class clazz, Consumer> consumer, SFunction alias) { + super.selectSub(clazz, consumer, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectSub(Class clazz, String st, Consumer> consumer, SFunction alias) { + super.selectSub(clazz, st, consumer, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectCount(SFunction column) { + super.selectCount(column); + return this; + } + + @Override + public MPJLambdaWrapperX selectCount(Object column, String alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectCount(Object column, SFunction alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectCount(SFunction column, String alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectCount(SFunction column, SFunction alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectSum(SFunction column) { + super.selectSum(column); + return this; + } + + @Override + public MPJLambdaWrapperX selectSum(SFunction column, String alias) { + super.selectSum(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectSum(SFunction column, SFunction alias) { + super.selectSum(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectMax(SFunction column) { + super.selectMax(column); + return this; + } + + @Override + public MPJLambdaWrapperX selectMax(SFunction column, String alias) { + super.selectMax(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectMax(SFunction column, SFunction alias) { + super.selectMax(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectMin(SFunction column) { + super.selectMin(column); + return this; + } + + @Override + public MPJLambdaWrapperX selectMin(SFunction column, String alias) { + super.selectMin(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectMin(SFunction column, SFunction alias) { + super.selectMin(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectAvg(SFunction column) { + super.selectAvg(column); + return this; + } + + @Override + public MPJLambdaWrapperX selectAvg(SFunction column, String alias) { + super.selectAvg(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectAvg(SFunction column, SFunction alias) { + super.selectAvg(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectLen(SFunction column) { + super.selectLen(column); + return this; + } + + @Override + public MPJLambdaWrapperX selectLen(SFunction column, String alias) { + super.selectLen(column, alias); + return this; + } + + @Override + public MPJLambdaWrapperX selectLen(SFunction column, SFunction alias) { + super.selectLen(column, alias); + return this; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/QueryWrapperX.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/QueryWrapperX.java new file mode 100644 index 000000000..a0ad9ec23 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/query/QueryWrapperX.java @@ -0,0 +1,166 @@ +package com.cf.imes.framework.mybatis.core.query; + +import cn.hutool.core.lang.Assert; +import com.cf.imes.framework.mybatis.core.enums.SqlConstants; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.ArrayUtils; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.Collection; + +/** + * 拓展 MyBatis Plus QueryWrapper 类,主要增加如下功能: + * + * 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 + * + * @param 数据类型 + */ +public class QueryWrapperX extends QueryWrapper { + + public QueryWrapperX likeIfPresent(String column, String val) { + if (StringUtils.hasText(val)) { + return (QueryWrapperX) super.like(column, val); + } + return this; + } + + public QueryWrapperX inIfPresent(String column, Collection values) { + if (!CollectionUtils.isEmpty(values)) { + return (QueryWrapperX) super.in(column, values); + } + return this; + } + + public QueryWrapperX inIfPresent(String column, Object... values) { + if (!ArrayUtils.isEmpty(values)) { + return (QueryWrapperX) super.in(column, values); + } + return this; + } + + public QueryWrapperX eqIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX) super.eq(column, val); + } + return this; + } + + public QueryWrapperX neIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX) super.ne(column, val); + } + return this; + } + + public QueryWrapperX gtIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX) super.gt(column, val); + } + return this; + } + + public QueryWrapperX geIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX) super.ge(column, val); + } + return this; + } + + public QueryWrapperX ltIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX) super.lt(column, val); + } + return this; + } + + public QueryWrapperX leIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX) super.le(column, val); + } + return this; + } + + public QueryWrapperX betweenIfPresent(String column, Object val1, Object val2) { + if (val1 != null && val2 != null) { + return (QueryWrapperX) super.between(column, val1, val2); + } + if (val1 != null) { + return (QueryWrapperX) ge(column, val1); + } + if (val2 != null) { + return (QueryWrapperX) le(column, val2); + } + return this; + } + + public QueryWrapperX betweenIfPresent(String column, Object[] values) { + if (values!= null && values.length != 0 && values[0] != null && values[1] != null) { + return (QueryWrapperX) super.between(column, values[0], values[1]); + } + if (values!= null && values.length != 0 && values[0] != null) { + return (QueryWrapperX) ge(column, values[0]); + } + if (values!= null && values.length != 0 && values[1] != null) { + return (QueryWrapperX) le(column, values[1]); + } + return this; + } + + // ========== 重写父类方法,方便链式调用 ========== + + @Override + public QueryWrapperX eq(boolean condition, String column, Object val) { + super.eq(condition, column, val); + return this; + } + + @Override + public QueryWrapperX eq(String column, Object val) { + super.eq(column, val); + return this; + } + + @Override + public QueryWrapperX orderByDesc(String column) { + super.orderByDesc(true, column); + return this; + } + + @Override + public QueryWrapperX last(String lastSql) { + super.last(lastSql); + return this; + } + + @Override + public QueryWrapperX in(String column, Collection coll) { + super.in(column, coll); + return this; + } + + /** + * 设置只返回最后一条 + * + * TODO 晨丰:不是完美解,需要在思考下。如果使用多数据源,并且数据源是多种类型时,可能会存在问题:实现之返回一条的语法不同 + * + * @return this + */ + public QueryWrapperX limitN(int n) { + Assert.notNull(SqlConstants.DB_TYPE, "获取不到数据库的类型"); + switch (SqlConstants.DB_TYPE) { + case ORACLE: + case ORACLE_12C: + super.eq("ROWNUM", n); + break; + case SQL_SERVER: + case SQL_SERVER2005: + super.select("TOP " + n + " *"); // 由于 SQL Server 是通过 SELECT TOP 1 实现限制一条,所以只好使用 * 查询剩余字段 + break; + default: + super.last("LIMIT " + n); + } + return this; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/EncryptTypeHandler.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/EncryptTypeHandler.java new file mode 100644 index 000000000..de6106040 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/EncryptTypeHandler.java @@ -0,0 +1,75 @@ +package com.cf.imes.framework.mybatis.core.type; + +import cn.hutool.core.lang.Assert; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.symmetric.AES; +import cn.hutool.extra.spring.SpringUtil; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * 字段字段的 TypeHandler 实现类,基于 {@link cn.hutool.crypto.symmetric.AES} 实现 + * 可通过 jasypt.encryptor.password 配置项,设置密钥 + * + * @author 晨丰科技 + */ +public class EncryptTypeHandler extends BaseTypeHandler { + + private static final String ENCRYPTOR_PROPERTY_NAME = "mybatis-plus.encryptor.password"; + + private static AES aes; + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { + ps.setString(i, encrypt(parameter)); + } + + @Override + public String getNullableResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return decrypt(value); + } + + @Override + public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return decrypt(value); + } + + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return decrypt(value); + } + + private static String decrypt(String value) { + if (value == null) { + return null; + } + return getEncryptor().decryptStr(value); + } + + public static String encrypt(String rawValue) { + if (rawValue == null) { + return null; + } + return getEncryptor().encryptBase64(rawValue); + } + + private static AES getEncryptor() { + if (aes != null) { + return aes; + } + // 构建 AES + String password = SpringUtil.getProperty(ENCRYPTOR_PROPERTY_NAME); + Assert.notEmpty(password, "配置项({}) 不能为空", ENCRYPTOR_PROPERTY_NAME); + aes = SecureUtil.aes(password.getBytes()); + return aes; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/IntegerListTypeHandler.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/IntegerListTypeHandler.java new file mode 100644 index 000000000..1256a75a2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/IntegerListTypeHandler.java @@ -0,0 +1,56 @@ +package com.cf.imes.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.common.util.string.StrUtils; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +/** + * List 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author jason + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class IntegerListTypeHandler implements TypeHandler> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, List strings, JdbcType jdbcType) throws SQLException { + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public List getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public List getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public List getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private List getResult(String value) { + if (value == null) { + return null; + } + return StrUtils.splitToInteger(value, COMMA); + } +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/JsonLongSetTypeHandler.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/JsonLongSetTypeHandler.java new file mode 100644 index 000000000..20a2da952 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/JsonLongSetTypeHandler.java @@ -0,0 +1,31 @@ +package com.cf.imes.framework.mybatis.core.type; + +import com.cf.imes.framework.common.util.json.JsonUtils; +import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler; +import com.fasterxml.jackson.core.type.TypeReference; + +import java.util.Set; + +/** + * 参考 {@link com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler} 实现 + * 在我们将字符串反序列化为 Set 并且泛型为 Long 时,如果每个元素的数值太小,会被处理成 Integer 类型,导致可能存在隐性的 BUG。 + * + * 例如说哦,SysUserDO 的 postIds 属性 + * + * @author 晨丰科技 + */ +public class JsonLongSetTypeHandler extends AbstractJsonTypeHandler { + + private static final TypeReference> TYPE_REFERENCE = new TypeReference>(){}; + + @Override + protected Object parse(String json) { + return JsonUtils.parseObject(json, TYPE_REFERENCE); + } + + @Override + protected String toJson(Object obj) { + return JsonUtils.toJsonString(obj); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/LongListTypeHandler.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/LongListTypeHandler.java new file mode 100644 index 000000000..276289883 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/LongListTypeHandler.java @@ -0,0 +1,57 @@ +package com.cf.imes.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import com.cf.imes.framework.common.util.string.StrUtils; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +/** + * List 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author 晨丰科技 + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class LongListTypeHandler implements TypeHandler> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, List strings, JdbcType jdbcType) throws SQLException { + // 设置占位符 + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public List getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public List getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public List getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private List getResult(String value) { + if (value == null) { + return null; + } + return StrUtils.splitToLong(value, COMMA); + } +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/StringListTypeHandler.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/StringListTypeHandler.java new file mode 100644 index 000000000..0d4480416 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/type/StringListTypeHandler.java @@ -0,0 +1,58 @@ +package com.cf.imes.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +/** + * List 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author 永不言败 + * @since 2022 3/23 12:50:15 + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class StringListTypeHandler implements TypeHandler> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, List strings, JdbcType jdbcType) throws SQLException { + // 设置占位符 + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public List getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public List getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public List getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private List getResult(String value) { + if (value == null) { + return null; + } + return StrUtil.splitTrim(value, COMMA); + } +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/util/JdbcUtils.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/util/JdbcUtils.java new file mode 100644 index 000000000..702883b19 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/util/JdbcUtils.java @@ -0,0 +1,42 @@ +package com.cf.imes.framework.mybatis.core.util; + +import com.baomidou.mybatisplus.annotation.DbType; + +import java.sql.Connection; +import java.sql.DriverManager; + +/** + * JDBC 工具类 + * + * @author 晨丰科技 + */ +public class JdbcUtils { + + /** + * 判断连接是否正确 + * + * @param url 数据源连接 + * @param username 账号 + * @param password 密码 + * @return 是否正确 + */ + public static boolean isConnectionOK(String url, String username, String password) { + try (Connection ignored = DriverManager.getConnection(url, username, password)) { + return true; + } catch (Exception ex) { + return false; + } + } + + /** + * 获得 URL 对应的 DB 类型 + * + * @param url URL + * @return DB 类型 + */ + public static DbType getDbType(String url) { + String name = com.alibaba.druid.util.JdbcUtils.getDbType(url, null); + return DbType.getDbType(name); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/util/MyBatisUtils.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/util/MyBatisUtils.java new file mode 100644 index 000000000..01414e53d --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/core/util/MyBatisUtils.java @@ -0,0 +1,88 @@ +package com.cf.imes.framework.mybatis.core.util; + +import cn.hutool.core.collection.CollectionUtil; +import com.cf.imes.framework.common.pojo.PageParam; +import com.cf.imes.framework.common.pojo.SortingField; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.core.toolkit.StringPool; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * MyBatis 工具类 + */ +public class MyBatisUtils { + + private static final String MYSQL_ESCAPE_CHARACTER = "`"; + + public static Page buildPage(PageParam pageParam) { + return buildPage(pageParam, null); + } + + public static Page buildPage(PageParam pageParam, Collection sortingFields) { + // 页码 + 数量 + Page page = new Page<>(pageParam.getPageNo(), pageParam.getPageSize()); + // 排序字段 + if (!CollectionUtil.isEmpty(sortingFields)) { + page.addOrder(sortingFields.stream().map(sortingField -> SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? + OrderItem.asc(sortingField.getField()) : OrderItem.desc(sortingField.getField())) + .collect(Collectors.toList())); + } + return page; + } + + /** + * 将拦截器添加到链中 + * 由于 MybatisPlusInterceptor 不支持添加拦截器,所以只能全量设置 + * + * @param interceptor 链 + * @param inner 拦截器 + * @param index 位置 + */ + public static void addInterceptor(MybatisPlusInterceptor interceptor, InnerInterceptor inner, int index) { + List inners = new ArrayList<>(interceptor.getInterceptors()); + inners.add(index, inner); + interceptor.setInterceptors(inners); + } + + /** + * 获得 Table 对应的表名 + * + * 兼容 MySQL 转义表名 `t_xxx` + * + * @param table 表 + * @return 去除转移字符后的表名 + */ + public static String getTableName(Table table) { + String tableName = table.getName(); + if (tableName.startsWith(MYSQL_ESCAPE_CHARACTER) && tableName.endsWith(MYSQL_ESCAPE_CHARACTER)) { + tableName = tableName.substring(1, tableName.length() - 1); + } + return tableName; + } + + /** + * 构建 Column 对象 + * + * @param tableName 表名 + * @param tableAlias 别名 + * @param column 字段名 + * @return Column 对象 + */ + public static Column buildColumn(String tableName, Alias tableAlias, String column) { + if (tableAlias != null) { + tableName = tableAlias.getName(); + } + return new Column(tableName + StringPool.DOT + column); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/package-info.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/package-info.java new file mode 100644 index 000000000..5d4dbd24b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/mybatis/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 MyBatis Plus 提升使用 MyBatis 的开发效率 + */ +package com.cf.imes.framework.mybatis; diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/package-info.java b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/package-info.java new file mode 100644 index 000000000..81ae8b069 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/java/com/cf/imes/framework/package-info.java @@ -0,0 +1 @@ +package com.cf.imes.framework; diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/resources/META-INF/spring.factories b/cf-framework/cf-spring-boot-starter-mybatis/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..589c23779 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + com.cf.imes.framework.mybatis.config.IdTypeEnvironmentPostProcessor diff --git a/cf-framework/cf-spring-boot-starter-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..d501f7943 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.datasource.config.ChenfengDataSourceAutoConfiguration +com.cf.imes.framework.mybatis.config.ChenfengMybatisAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-protection/pom.xml b/cf-framework/cf-spring-boot-starter-protection/pom.xml new file mode 100644 index 000000000..d377dc43a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/pom.xml @@ -0,0 +1,38 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-protection + jar + + ${project.artifactId} + 服务保证,提供分布式锁、幂等、限流、熔断等等功能 + + + + + com.cf.imes + cf-spring-boot-starter-redis + + + + + com.baomidou + lock4j-redisson-spring-boot-starter + true + + + + io.github.resilience4j + resilience4j-spring-boot2 + true + + + + diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/config/ChenfengIdempotentConfiguration.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/config/ChenfengIdempotentConfiguration.java new file mode 100644 index 000000000..26d716537 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/config/ChenfengIdempotentConfiguration.java @@ -0,0 +1,40 @@ +package com.cf.imes.framework.idempotent.config; + +import com.cf.imes.framework.idempotent.core.aop.IdempotentAspect; +import com.cf.imes.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver; +import com.cf.imes.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import com.cf.imes.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import com.cf.imes.framework.idempotent.core.redis.IdempotentRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import com.cf.imes.framework.redis.config.ChenfengRedisAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +@AutoConfiguration(after = ChenfengRedisAutoConfiguration.class) +public class ChenfengIdempotentConfiguration { + + @Bean + public IdempotentAspect idempotentAspect(List keyResolvers, IdempotentRedisDAO idempotentRedisDAO) { + return new IdempotentAspect(keyResolvers, idempotentRedisDAO); + } + + @Bean + public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) { + return new IdempotentRedisDAO(stringRedisTemplate); + } + + // ========== 各种 IdempotentKeyResolver Bean ========== + + @Bean + public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() { + return new DefaultIdempotentKeyResolver(); + } + + @Bean + public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() { + return new ExpressionIdempotentKeyResolver(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/annotation/Idempotent.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/annotation/Idempotent.java new file mode 100644 index 000000000..b5f5e6f2c --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/annotation/Idempotent.java @@ -0,0 +1,46 @@ +package com.cf.imes.framework.idempotent.core.annotation; + +import com.cf.imes.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver; +import com.cf.imes.framework.idempotent.core.keyresolver.IdempotentKeyResolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 幂等注解 + * + * @author 晨丰科技 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { + + /** + * 幂等的超时时间,默认为 1 秒 + * + * 注意,如果执行时间超过它,请求还是会进来 + */ + int timeout() default 1; + /** + * 时间单位,默认为 SECONDS 秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * 提示信息,正在执行中的提示 + */ + String message() default "重复请求,请稍后重试"; + + /** + * 使用的 Key 解析器 + */ + Class keyResolver() default DefaultIdempotentKeyResolver.class; + /** + * 使用的 Key 参数 + */ + String keyArg() default ""; + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/aop/IdempotentAspect.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/aop/IdempotentAspect.java new file mode 100644 index 000000000..b3b09f10f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/aop/IdempotentAspect.java @@ -0,0 +1,56 @@ +package com.cf.imes.framework.idempotent.core.aop; + +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.cf.imes.framework.idempotent.core.annotation.Idempotent; +import com.cf.imes.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import com.cf.imes.framework.idempotent.core.redis.IdempotentRedisDAO; +import com.cf.imes.framework.common.util.collection.CollectionUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; + +/** + * 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作 + * + * @author 晨丰科技 + */ +@Aspect +@Slf4j +public class IdempotentAspect { + + /** + * IdempotentKeyResolver 集合 + */ + private final Map, IdempotentKeyResolver> keyResolvers; + + private final IdempotentRedisDAO idempotentRedisDAO; + + public IdempotentAspect(List keyResolvers, IdempotentRedisDAO idempotentRedisDAO) { + this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass); + this.idempotentRedisDAO = idempotentRedisDAO; + } + + @Before("@annotation(idempotent)") + public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) { + // 获得 IdempotentKeyResolver + IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver()); + Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver"); + // 解析 Key + String key = keyResolver.resolver(joinPoint, idempotent); + + // 锁定 Key。 + boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit()); + // 锁定失败,抛出异常 + if (!success) { + log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs()); + throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message()); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java new file mode 100644 index 000000000..253aaaeba --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java @@ -0,0 +1,22 @@ +package com.cf.imes.framework.idempotent.core.keyresolver; + +import com.cf.imes.framework.idempotent.core.annotation.Idempotent; +import org.aspectj.lang.JoinPoint; + +/** + * 幂等 Key 解析器接口 + * + * @author 晨丰科技 + */ +public interface IdempotentKeyResolver { + + /** + * 解析一个 Key + * + * @param idempotent 幂等注解 + * @param joinPoint AOP 切面 + * @return Key + */ + String resolver(JoinPoint joinPoint, Idempotent idempotent); + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java new file mode 100644 index 000000000..1320a45f1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java @@ -0,0 +1,25 @@ +package com.cf.imes.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.cf.imes.framework.idempotent.core.annotation.Idempotent; +import com.cf.imes.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * 默认幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author 晨丰科技 + */ +public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, Idempotent idempotent) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + return SecureUtil.md5(methodName + argsStr); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java new file mode 100644 index 000000000..4a3e4d62a --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java @@ -0,0 +1,63 @@ +package com.cf.imes.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.core.util.ArrayUtil; +import com.cf.imes.framework.idempotent.core.annotation.Idempotent; +import com.cf.imes.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.LocalVariableTableParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import java.lang.reflect.Method; + +/** + * 基于 Spring EL 表达式, + * + * @author 晨丰科技 + */ +public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver { + + private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + @Override + public String resolver(JoinPoint joinPoint, Idempotent idempotent) { + // 获得被拦截方法参数名列表 + Method method = getMethod(joinPoint); + Object[] args = joinPoint.getArgs(); + String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); + // 准备 Spring EL 表达式解析的上下文 + StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + if (ArrayUtil.isNotEmpty(parameterNames)) { + for (int i = 0; i < parameterNames.length; i++) { + evaluationContext.setVariable(parameterNames[i], args[i]); + } + } + + // 解析参数 + Expression expression = expressionParser.parseExpression(idempotent.keyArg()); + return expression.getValue(evaluationContext, String.class); + } + + private static Method getMethod(JoinPoint point) { + // 处理,声明在类上的情况 + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + if (!method.getDeclaringClass().isInterface()) { + return method; + } + + // 处理,声明在接口上的情况 + try { + return point.getTarget().getClass().getDeclaredMethod( + point.getSignature().getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/redis/IdempotentRedisDAO.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/redis/IdempotentRedisDAO.java new file mode 100644 index 000000000..12f6de737 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/core/redis/IdempotentRedisDAO.java @@ -0,0 +1,36 @@ +package com.cf.imes.framework.idempotent.core.redis; + +import lombok.AllArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * 幂等 Redis DAO + * + * @author 晨丰科技 + */ +@AllArgsConstructor +public class IdempotentRedisDAO { + + /** + * 幂等操作 + * + * KEY 格式:idempotent:%s // 参数为 uuid + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String IDEMPOTENT = "idempotent:%s"; + + private final StringRedisTemplate redisTemplate; + + public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) { + String redisKey = formatKey(key); + return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit); + } + + private static String formatKey(String key) { + return String.format(IDEMPOTENT, key); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/package-info.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/package-info.java new file mode 100644 index 000000000..bdf8099c9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/idempotent/package-info.java @@ -0,0 +1,12 @@ +/** + * 幂等组件,参考 https://github.com/it4alla/idempotent 项目实现 + * 实现原理是,相同参数的方法,一段时间内,有且仅能执行一次。通过这样的方式,保证幂等性。 + * + * 使用场景:例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。 + * + * 和 it4alla/idempotent 组件的差异点,主要体现在两点: + * 1. 我们去掉了 @Idempotent 注解的 delKey 属性。原因是,本质上 delKey 为 true 时,实现的是分布式锁的能力 + * 此时,我们偏向使用 Lock4j 组件。原则上,一个组件只提供一种单一的能力。 + * 2. 考虑到组件的通用性,我们并未像 it4alla/idempotent 组件一样使用 Redisson RMap 结构,而是直接使用 Redis 的 String 数据格式。 + */ +package com.cf.imes.framework.idempotent; diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/config/ChenfengLock4jConfiguration.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/config/ChenfengLock4jConfiguration.java new file mode 100644 index 000000000..2d7c478f1 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/config/ChenfengLock4jConfiguration.java @@ -0,0 +1,18 @@ +package com.cf.imes.framework.lock4j.config; + +import com.cf.imes.framework.lock4j.core.DefaultLockFailureStrategy; +import com.baomidou.lock.spring.boot.autoconfigure.LockAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration(before = LockAutoConfiguration.class) +@ConditionalOnClass(name = "com.baomidou.lock.annotation.Lock4j") +public class ChenfengLock4jConfiguration { + + @Bean + public DefaultLockFailureStrategy lockFailureStrategy() { + return new DefaultLockFailureStrategy(); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/core/DefaultLockFailureStrategy.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/core/DefaultLockFailureStrategy.java new file mode 100644 index 000000000..35dbead9f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/core/DefaultLockFailureStrategy.java @@ -0,0 +1,21 @@ +package com.cf.imes.framework.lock4j.core; + +import com.cf.imes.framework.common.exception.ServiceException; +import com.cf.imes.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.baomidou.lock.LockFailureStrategy; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Method; + +/** + * 自定义获取锁失败策略,抛出 {@link ServiceException} 异常 + */ +@Slf4j +public class DefaultLockFailureStrategy implements LockFailureStrategy { + + @Override + public void onLockFailure(String key, Method method, Object[] arguments) { + log.debug("[onLockFailure][线程:{} 获取锁失败,key:{} 获取失败:{} ]", Thread.currentThread().getName(), key, arguments); + throw new ServiceException(GlobalErrorCodeConstants.LOCKED); + } +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/core/Lock4jRedisKeyConstants.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/core/Lock4jRedisKeyConstants.java new file mode 100644 index 000000000..51e207525 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/core/Lock4jRedisKeyConstants.java @@ -0,0 +1,19 @@ +package com.cf.imes.framework.lock4j.core; + +/** + * Lock4j Redis Key 枚举类 + * + * @author 晨丰科技 + */ +public interface Lock4jRedisKeyConstants { + + /** + * 分布式锁 + * + * KEY 格式:lock4j:%s // 参数来自 DefaultLockKeyBuilder 类 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 + * 过期时间:不固定 + */ + String LOCK4J = "lock4j:%s"; + +} diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/package-info.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/package-info.java new file mode 100644 index 000000000..b392939aa --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/lock4j/package-info.java @@ -0,0 +1,4 @@ +/** + * 分布式锁组件,使用 https://gitee.com/baomidou/lock4j 开源项目 + */ +package com.cf.imes.framework.lock4j; diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/resilience4j/package-info.java b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/resilience4j/package-info.java new file mode 100644 index 000000000..526933edd --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/resilience4j/package-info.java @@ -0,0 +1,9 @@ +/** + * 使用 Resilience4j 组件,实现服务保障,包括: + * 1. 熔断器 + * 2. 限流器 + * 3. 舱壁隔离 + * 4. 重试 + * 5. 限时器 + */ +package com.cf.imes.framework.resilience4j; diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/resilience4j/《芋道 Spring Boot 服务容错 Resilience4j 入门》.md b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/resilience4j/《芋道 Spring Boot 服务容错 Resilience4j 入门》.md new file mode 100644 index 000000000..1d99255f9 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/java/com/cf/imes/framework/resilience4j/《芋道 Spring Boot 服务容错 Resilience4j 入门》.md @@ -0,0 +1 @@ + diff --git a/cf-framework/cf-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..ab70c220b --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.idempotent.config.ChenfengIdempotentConfiguration +com.cf.imes.framework.lock4j.config.ChenfengLock4jConfiguration diff --git a/cf-framework/cf-spring-boot-starter-redis/pom.xml b/cf-framework/cf-spring-boot-starter-redis/pom.xml new file mode 100644 index 000000000..a1ecea901 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/pom.xml @@ -0,0 +1,41 @@ + + + + com.cf.imes + cf-framework + ${revision} + + 4.0.0 + cf-spring-boot-starter-redis + jar + + ${project.artifactId} + Redis 封装拓展 + https://github.com/YunaiV/ruoyi-vue-pro + + + + com.cf.imes + cf-common + + + + + org.redisson + redisson-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-cache + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + diff --git a/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengCacheAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengCacheAutoConfiguration.java new file mode 100644 index 000000000..542422389 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengCacheAutoConfiguration.java @@ -0,0 +1,82 @@ +package com.cf.imes.framework.redis.config; + +import cn.hutool.core.util.StrUtil; +import com.cf.imes.framework.redis.core.TimeoutRedisCacheManager; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.cache.CacheProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.BatchStrategies; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +import static com.cf.imes.framework.redis.config.ChenfengRedisAutoConfiguration.buildRedisSerializer; + +/** + * Cache 配置类,基于 Redis 实现 + */ +@AutoConfiguration +@EnableConfigurationProperties({CacheProperties.class, ChenfengCacheProperties.class}) +@EnableCaching +public class ChenfengCacheAutoConfiguration { + + /** + * RedisCacheConfiguration Bean + *

+ * 参考 org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 的 createConfiguration 方法 + */ + @Bean + @Primary + public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig(); + // 设置使用 : 单冒号,而不是双 :: 冒号,避免 Redis Desktop Manager 多余空格 + // 详细可见 https://blog.csdn.net/chuixue24/article/details/103928965 博客 + // 再次修复单冒号,而不是双 :: 冒号问题,Issues 详情:https://gitee.com/zhijiantianya/cf-cloud/issues/I86VY2 + config = config.computePrefixWith(cacheName -> { + String keyPrefix = cacheProperties.getRedis().getKeyPrefix(); + if (StringUtils.hasText(keyPrefix)) { + keyPrefix = keyPrefix.lastIndexOf(StrUtil.COLON) == -1 ? keyPrefix + StrUtil.COLON : keyPrefix; + return keyPrefix + cacheName + StrUtil.COLON; + } + return cacheName + StrUtil.COLON; + }); + // 设置使用 JSON 序列化方式 + config = config.serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(buildRedisSerializer())); + + // 设置 CacheProperties.Redis 的属性 + CacheProperties.Redis redisProperties = cacheProperties.getRedis(); + if (redisProperties.getTimeToLive() != null) { + config = config.entryTtl(redisProperties.getTimeToLive()); + } + if (!redisProperties.isCacheNullValues()) { + config = config.disableCachingNullValues(); + } + if (!redisProperties.isUseKeyPrefix()) { + config = config.disableKeyPrefix(); + } + return config; + } + + @Bean + public RedisCacheManager redisCacheManager(RedisTemplate redisTemplate, + RedisCacheConfiguration redisCacheConfiguration, + ChenfengCacheProperties chenfengCacheProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(chenfengCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengCacheProperties.java b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengCacheProperties.java new file mode 100644 index 000000000..78906c3c3 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengCacheProperties.java @@ -0,0 +1,27 @@ +package com.cf.imes.framework.redis.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Cache 配置项 + * + * @author Wanwan + */ +@ConfigurationProperties("chenfeng.cache") +@Data +@Validated +public class ChenfengCacheProperties { + + /** + * {@link #redisScanBatchSize} 默认值 + */ + private static final Integer REDIS_SCAN_BATCH_SIZE_DEFAULT = 30; + + /** + * redis scan 一次返回数量 + */ + private Integer redisScanBatchSize = REDIS_SCAN_BATCH_SIZE_DEFAULT; + +} diff --git a/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengRedisAutoConfiguration.java b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengRedisAutoConfiguration.java new file mode 100644 index 000000000..2d7ebbcb2 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/config/ChenfengRedisAutoConfiguration.java @@ -0,0 +1,44 @@ +package com.cf.imes.framework.redis.config; + +import cn.hutool.core.util.ReflectUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * Redis 配置类 + */ +@AutoConfiguration +public class ChenfengRedisAutoConfiguration { + + /** + * 创建 RedisTemplate Bean,使用 JSON 序列化方式 + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + // 创建 RedisTemplate 对象 + RedisTemplate template = new RedisTemplate<>(); + // 设置 RedisConnection 工厂。😈 它就是实现多种 Java Redis 客户端接入的秘密工厂。感兴趣的胖友,可以自己去撸下。 + template.setConnectionFactory(factory); + // 使用 String 序列化方式,序列化 KEY 。 + template.setKeySerializer(RedisSerializer.string()); + template.setHashKeySerializer(RedisSerializer.string()); + // 使用 JSON 序列化方式(库是 Jackson ),序列化 VALUE 。 + template.setValueSerializer(buildRedisSerializer()); + template.setHashValueSerializer(buildRedisSerializer()); + return template; + } + + public static RedisSerializer buildRedisSerializer() { + RedisSerializer json = RedisSerializer.json(); + // 解决 LocalDateTime 的序列化 + ObjectMapper objectMapper = (ObjectMapper) ReflectUtil.getFieldValue(json, "mapper"); + objectMapper.registerModules(new JavaTimeModule()); + return json; + } + +} diff --git a/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/core/TimeoutRedisCacheManager.java b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/core/TimeoutRedisCacheManager.java new file mode 100644 index 000000000..220423313 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/core/TimeoutRedisCacheManager.java @@ -0,0 +1,83 @@ +package com.cf.imes.framework.redis.core; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.redis.cache.RedisCache; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; + +import java.time.Duration; + +/** + * 支持自定义过期时间的 {@link RedisCacheManager} 实现类 + * + * 在 {@link Cacheable#cacheNames()} 格式为 "key#ttl" 时,# 后面的 ttl 为过期时间。 + * 单位为最后一个字母(支持的单位有:d 天,h 小时,m 分钟,s 秒),默认单位为 s 秒 + * + * @author 晨丰科技 + */ +public class TimeoutRedisCacheManager extends RedisCacheManager { + + private static final String SPLIT = "#"; + + public TimeoutRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) { + super(cacheWriter, defaultCacheConfiguration); + } + + @Override + protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) { + if (StrUtil.isEmpty(name)) { + return super.createRedisCache(name, cacheConfig); + } + // 如果使用 # 分隔,大小不为 2,则说明不使用自定义过期时间 + String[] names = StrUtil.splitToArray(name, SPLIT); + if (names.length != 2) { + return super.createRedisCache(name, cacheConfig); + } + + // 核心:通过修改 cacheConfig 的过期时间,实现自定义过期时间 + if (cacheConfig != null) { + // 移除 # 后面的 : 以及后面的内容,避免影响解析 + names[1] = StrUtil.subBefore(names[1], StrUtil.COLON, false); + // 解析时间 + Duration duration = parseDuration(names[1]); + cacheConfig = cacheConfig.entryTtl(duration); + } + return super.createRedisCache(name, cacheConfig); + } + + /** + * 解析过期时间 Duration + * + * @param ttlStr 过期时间字符串 + * @return 过期时间 Duration + */ + private Duration parseDuration(String ttlStr) { + String timeUnit = StrUtil.subSuf(ttlStr, -1); + switch (timeUnit) { + case "d": + return Duration.ofDays(removeDurationSuffix(ttlStr)); + case "h": + return Duration.ofHours(removeDurationSuffix(ttlStr)); + case "m": + return Duration.ofMinutes(removeDurationSuffix(ttlStr)); + case "s": + return Duration.ofSeconds(removeDurationSuffix(ttlStr)); + default: + return Duration.ofSeconds(Long.parseLong(ttlStr)); + } + } + + /** + * 移除多余的后缀,返回具体的时间 + * + * @param ttlStr 过期时间字符串 + * @return 时间 + */ + private Long removeDurationSuffix(String ttlStr) { + return NumberUtil.parseLong(StrUtil.sub(ttlStr, 0, ttlStr.length() - 1)); + } + +} diff --git a/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/package-info.java b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/package-info.java new file mode 100644 index 000000000..71b9ff915 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/src/main/java/com/cf/imes/framework/redis/package-info.java @@ -0,0 +1,4 @@ +/** + * 采用 Spring Data Redis 操作 Redis,底层使用 Redisson 作为客户端 + */ +package com.cf.imes.framework.redis; diff --git a/cf-framework/cf-spring-boot-starter-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cf-framework/cf-spring-boot-starter-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..339785994 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.cf.imes.framework.redis.config.ChenfengRedisAutoConfiguration +com.cf.imes.framework.redis.config.ChenfengCacheAutoConfiguration diff --git a/cf-framework/cf-spring-boot-starter-rpc/pom.xml b/cf-framework/cf-spring-boot-starter-rpc/pom.xml new file mode 100644 index 000000000..210748b87 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-rpc/pom.xml @@ -0,0 +1,45 @@ + + + + cf-framework + com.cf.imes + ${revision} + + 4.0.0 + cf-spring-boot-starter-rpc + jar + + ${project.artifactId} + + OpenFeign:提供 RESTful API 的调用 + + + + + com.cf.imes + cf-common + + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.github.openfeign + feign-okhttp + + + + + jakarta.validation + jakarta.validation-api + + + diff --git a/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/config/package-info.java b/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/config/package-info.java new file mode 100644 index 000000000..c4c543e8f --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/config/package-info.java @@ -0,0 +1,4 @@ +/** + * 占坑 TODO + */ +package com.cf.imes.framework.rpc.config; diff --git a/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/core/package-info.java b/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/core/package-info.java new file mode 100644 index 000000000..3dca8d5dd --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占坑 TODO + */ +package com.cf.imes.framework.rpc.core; diff --git a/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/core/util/FeignUtils.java b/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/core/util/FeignUtils.java new file mode 100644 index 000000000..5035a8f46 --- /dev/null +++ b/cf-framework/cf-spring-boot-starter-rpc/src/main/java/com/cf/imes/framework/rpc/core/util/FeignUtils.java @@ -0,0 +1,48 @@ +package com.cf.imes.framework.rpc.core.util; + +import cn.hutool.core.util.ReflectUtil; +import com.cf.imes.framework.common.util.json.JsonUtils; +import feign.RequestTemplate; +import feign.template.HeaderTemplate; +import feign.template.Literal; +import feign.template.Template; +import feign.template.TemplateChunk; + +import java.util.List; +import java.util.Map; + +/** + * {@link feign.Feign} 工具类 + * + * @author 晨丰科技 + */ +public class FeignUtils { + + /** + * 添加 JSON 格式的 Header + * + * @param requestTemplate 请求 + * @param name header 名 + * @param value header 值 + */ + @SuppressWarnings("unchecked") + public static void createJsonHeader(RequestTemplate requestTemplate, String name, Object value) { + if (value == null) { + return; + } + // 添加 header + String valueStr = JsonUtils.toJsonString(value); + requestTemplate.header(name, valueStr); + // fix:由于 OpenFeign 针对 { 会进行分词,所以需要反射修改 + // 具体分析,可见 https://zhuanlan.zhihu.com/p/360501330 文档 + Map headers = (Map) + ReflectUtil.getFieldValue(requestTemplate, "headers"); + HeaderTemplate template = headers.get(name); + List