commit 80bc86d703f4af5b9f3e5aaf1bba23772525a36b Author: mapleaf Date: Sat Oct 4 18:08:05 2025 +0800 Initial commit with remote deployment configuration diff --git a/.gitee/ISSUE_TEMPLATE.zh-CN.md b/.gitee/ISSUE_TEMPLATE.zh-CN.md new file mode 100644 index 0000000..06e3de7 --- /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 0000000..6ed00a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,34 @@ +--- +name: 问题反馈 +about: 请详细描述,以便更高快的获得到解决 +title: '' +labels: '' +assignees: '' + +--- + +碰到问题,请在 搜索是否存在相似的 issue。 + +不按照模板提交的 issue,会被系统自动删除。 + +### 基本信息 + +- ruoyi-vue-pro 版本: +- 操作系统: +- 数据库: + +### 你猜测可能的原因 + +(必填)我花费了 2-4 小时自查,发现可能的原因是:xxxxxx + +### 复现步骤 + +第一步, + +第二步, + +第三步, + +### 报错信息 + +带上必要的截图 diff --git a/.github/workflows/aagro-ui-admin.yml b/.github/workflows/aagro-ui-admin.yml new file mode 100644 index 0000000..114260b --- /dev/null +++ b/.github/workflows/aagro-ui-admin.yml @@ -0,0 +1,51 @@ +name: aagro-ui-admin CI + +# 在master分支发生push事件时触发。 +on: + push: + branches: [ master ] + # pull_request: + # branches: [ master ] +env: # 设置环境变量 + TZ: Asia/Shanghai # 时区(设置时区可使页面中的`最近更新时间`使用时区时间) + WORK_DIR: aagro-ui-admin #工作目录 + +defaults: + run: + shell: bash + working-directory: aagro-ui-admin + +jobs: + build: # 自定义名称 + runs-on: ubuntu-latest # 运行在虚拟机环境ubuntu-latest + + strategy: + matrix: + node_version: [14.x, 16.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - name: Checkout # 步骤1 + uses: actions/checkout@v2 # 使用的动作。格式:userName/repoName。作用:检出仓库,获取源码。 官方actions库:https://github.com/actions + + - name: Install pnpm + uses: pnpm/action-setup@v2.0.1 + with: + version: 6.15.1 + + - name: Set node version to ${{ matrix.node_version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node_version }} + cache: "yarn" + cache-dependency-path: aagro-ui-admin/yarn.lock + + - name: Install deps + run: node --version && yarn --version && yarn install + + - name: Build + run: yarn build:prod + + # 查看 workflow 的文档来获取更多信息 + # @see https://github.com/crazy-max/ghaction-github-pages + diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..7c76592 --- /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 0000000..276895f --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +###################################################################### +# Build Tools + +.gradle +/build/ +!gradle/wrapper/gradle-wrapper.jar + +target/ +!.mvn/wrapper/maven-wrapper.jar + +.flattened-pom.xml + +###################################################################### +# IDE + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +nbproject/private/ +build/* +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +###################################################################### +# Others +*.log +*.xml.versionsBackup +*.swp + +!*/build/*.java +!*/build/*.html +!*/build/*.xml + +### JRebel ### +rebel.xml + +application-my.yaml + +/aagro-ui-app/unpackage/ +**/.DS_Store diff --git a/.image/Java监控.jpg b/.image/Java监控.jpg new file mode 100644 index 0000000..6ad522a 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 0000000..64a1940 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 0000000..787bb73 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 0000000..1a7342d 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 0000000..a83e7c1 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 0000000..9569352 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 0000000..0f65d99 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 0000000..05ec781 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 0000000..f400c68 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 0000000..d5d5ea0 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 0000000..1de6d8a 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 0000000..400ae90 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 0000000..2ed8c0f 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 0000000..090e64a 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 0000000..f2032c8 Binary files /dev/null and b/.image/admin-uniapp/09.png differ diff --git a/.image/common/aagro-cloud-architecture.png b/.image/common/aagro-cloud-architecture.png new file mode 100644 index 0000000..59416d8 Binary files /dev/null and b/.image/common/aagro-cloud-architecture.png differ diff --git a/.image/common/aagro-roadmap.png b/.image/common/aagro-roadmap.png new file mode 100644 index 0000000..f4becc9 Binary files /dev/null and b/.image/common/aagro-roadmap.png differ diff --git a/.image/common/ai-feature.png b/.image/common/ai-feature.png new file mode 100644 index 0000000..1c22dbe Binary files /dev/null and b/.image/common/ai-feature.png differ diff --git a/.image/common/ai-preview.gif b/.image/common/ai-preview.gif new file mode 100644 index 0000000..5f13ac4 Binary files /dev/null and b/.image/common/ai-preview.gif differ diff --git a/.image/common/bpm-feature.png b/.image/common/bpm-feature.png new file mode 100644 index 0000000..23787fb Binary files /dev/null and b/.image/common/bpm-feature.png differ diff --git a/.image/common/crm-feature.png b/.image/common/crm-feature.png new file mode 100644 index 0000000..e1c9670 Binary files /dev/null and b/.image/common/crm-feature.png differ diff --git a/.image/common/erp-feature.png b/.image/common/erp-feature.png new file mode 100644 index 0000000..d30b30e Binary files /dev/null and b/.image/common/erp-feature.png differ diff --git a/.image/common/infra-feature.png b/.image/common/infra-feature.png new file mode 100644 index 0000000..f5cef50 Binary files /dev/null and b/.image/common/infra-feature.png differ diff --git a/.image/common/mall-feature.png b/.image/common/mall-feature.png new file mode 100644 index 0000000..cca05c0 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 0000000..e164cd2 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 0000000..561e092 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 0000000..7bd7d59 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 0000000..24a385a Binary files /dev/null and b/.image/common/ruoyi-vue-pro-biz.png differ diff --git a/.image/common/system-feature.png b/.image/common/system-feature.png new file mode 100644 index 0000000..366087c Binary files /dev/null and b/.image/common/system-feature.png differ diff --git a/.image/个人中心.jpg b/.image/个人中心.jpg new file mode 100644 index 0000000..ce57f6e Binary files /dev/null and b/.image/个人中心.jpg differ diff --git a/.image/代码生成.jpg b/.image/代码生成.jpg new file mode 100644 index 0000000..751603e Binary files /dev/null and b/.image/代码生成.jpg differ diff --git a/.image/令牌管理.jpg b/.image/令牌管理.jpg new file mode 100644 index 0000000..04abf4d Binary files /dev/null and b/.image/令牌管理.jpg differ diff --git a/.image/任务列表-审批.jpg b/.image/任务列表-审批.jpg new file mode 100644 index 0000000..cba312a Binary files /dev/null and b/.image/任务列表-审批.jpg differ diff --git a/.image/任务列表-已办.jpg b/.image/任务列表-已办.jpg new file mode 100644 index 0000000..7a8d0fb Binary files /dev/null and b/.image/任务列表-已办.jpg differ diff --git a/.image/任务列表-待办.jpg b/.image/任务列表-待办.jpg new file mode 100644 index 0000000..a90323f Binary files /dev/null and b/.image/任务列表-待办.jpg differ diff --git a/.image/任务日志.jpg b/.image/任务日志.jpg new file mode 100644 index 0000000..599e50a Binary files /dev/null and b/.image/任务日志.jpg differ diff --git a/.image/商户信息.jpg b/.image/商户信息.jpg new file mode 100644 index 0000000..483eace Binary files /dev/null and b/.image/商户信息.jpg differ diff --git a/.image/在线用户.jpg b/.image/在线用户.jpg new file mode 100644 index 0000000..b183009 Binary files /dev/null and b/.image/在线用户.jpg differ diff --git a/.image/大屏设计器-列表.jpg b/.image/大屏设计器-列表.jpg new file mode 100644 index 0000000..9a45c3b Binary files /dev/null and b/.image/大屏设计器-列表.jpg differ diff --git a/.image/大屏设计器-编辑.jpg b/.image/大屏设计器-编辑.jpg new file mode 100644 index 0000000..63298a0 Binary files /dev/null and b/.image/大屏设计器-编辑.jpg differ diff --git a/.image/大屏设计器-预览.jpg b/.image/大屏设计器-预览.jpg new file mode 100644 index 0000000..501d9ea Binary files /dev/null and b/.image/大屏设计器-预览.jpg differ diff --git a/.image/字典数据.jpg b/.image/字典数据.jpg new file mode 100644 index 0000000..8298c89 Binary files /dev/null and b/.image/字典数据.jpg differ diff --git a/.image/字典类型.jpg b/.image/字典类型.jpg new file mode 100644 index 0000000..6613392 Binary files /dev/null and b/.image/字典类型.jpg differ diff --git a/.image/定时任务.jpg b/.image/定时任务.jpg new file mode 100644 index 0000000..d5bbd85 Binary files /dev/null and b/.image/定时任务.jpg differ diff --git a/.image/岗位管理.jpg b/.image/岗位管理.jpg new file mode 100644 index 0000000..42b64d2 Binary files /dev/null and b/.image/岗位管理.jpg differ diff --git a/.image/工作流设计器-bpmn.jpg b/.image/工作流设计器-bpmn.jpg new file mode 100644 index 0000000..2a61f60 Binary files /dev/null and b/.image/工作流设计器-bpmn.jpg differ diff --git a/.image/工作流设计器-simple.jpg b/.image/工作流设计器-simple.jpg new file mode 100644 index 0000000..9ef2c9e Binary files /dev/null and b/.image/工作流设计器-simple.jpg differ diff --git a/.image/应用信息-列表.jpg b/.image/应用信息-列表.jpg new file mode 100644 index 0000000..da419a2 Binary files /dev/null and b/.image/应用信息-列表.jpg differ diff --git a/.image/应用信息-编辑.jpg b/.image/应用信息-编辑.jpg new file mode 100644 index 0000000..913cfbc Binary files /dev/null and b/.image/应用信息-编辑.jpg differ diff --git a/.image/应用管理.jpg b/.image/应用管理.jpg new file mode 100644 index 0000000..6e7789f Binary files /dev/null and b/.image/应用管理.jpg differ diff --git a/.image/我的流程-列表.jpg b/.image/我的流程-列表.jpg new file mode 100644 index 0000000..223d17a Binary files /dev/null and b/.image/我的流程-列表.jpg differ diff --git a/.image/我的流程-发起.jpg b/.image/我的流程-发起.jpg new file mode 100644 index 0000000..7a83306 Binary files /dev/null and b/.image/我的流程-发起.jpg differ diff --git a/.image/我的流程-详情.jpg b/.image/我的流程-详情.jpg new file mode 100644 index 0000000..6a01541 Binary files /dev/null and b/.image/我的流程-详情.jpg differ diff --git a/.image/报表设计器-图形报表.jpg b/.image/报表设计器-图形报表.jpg new file mode 100644 index 0000000..681b318 Binary files /dev/null and b/.image/报表设计器-图形报表.jpg differ diff --git a/.image/报表设计器-打印设计.jpg b/.image/报表设计器-打印设计.jpg new file mode 100644 index 0000000..bb86da6 Binary files /dev/null and b/.image/报表设计器-打印设计.jpg differ diff --git a/.image/报表设计器-数据报表.jpg b/.image/报表设计器-数据报表.jpg new file mode 100644 index 0000000..9ca5b9b Binary files /dev/null and b/.image/报表设计器-数据报表.jpg differ diff --git a/.image/操作日志.jpg b/.image/操作日志.jpg new file mode 100644 index 0000000..4a0611a Binary files /dev/null and b/.image/操作日志.jpg differ diff --git a/.image/支付订单.jpg b/.image/支付订单.jpg new file mode 100644 index 0000000..0a56dd7 Binary files /dev/null and b/.image/支付订单.jpg differ diff --git a/.image/敏感词.jpg b/.image/敏感词.jpg new file mode 100644 index 0000000..92a5397 Binary files /dev/null and b/.image/敏感词.jpg differ diff --git a/.image/数据库文档.jpg b/.image/数据库文档.jpg new file mode 100644 index 0000000..a4339d9 Binary files /dev/null and b/.image/数据库文档.jpg differ diff --git a/.image/文件管理.jpg b/.image/文件管理.jpg new file mode 100644 index 0000000..054b19f Binary files /dev/null and b/.image/文件管理.jpg differ diff --git a/.image/文件管理2.jpg b/.image/文件管理2.jpg new file mode 100644 index 0000000..b12e5c3 Binary files /dev/null and b/.image/文件管理2.jpg differ diff --git a/.image/文件配置.jpg b/.image/文件配置.jpg new file mode 100644 index 0000000..e618049 Binary files /dev/null and b/.image/文件配置.jpg differ diff --git a/.image/日志中心.jpg b/.image/日志中心.jpg new file mode 100644 index 0000000..27c1c6c Binary files /dev/null and b/.image/日志中心.jpg differ diff --git a/.image/流程模型-列表.jpg b/.image/流程模型-列表.jpg new file mode 100644 index 0000000..ffdc584 Binary files /dev/null and b/.image/流程模型-列表.jpg differ diff --git a/.image/流程模型-定义.jpg b/.image/流程模型-定义.jpg new file mode 100644 index 0000000..18b316c Binary files /dev/null and b/.image/流程模型-定义.jpg differ diff --git a/.image/流程模型-设计.jpg b/.image/流程模型-设计.jpg new file mode 100644 index 0000000..9614969 Binary files /dev/null and b/.image/流程模型-设计.jpg differ diff --git a/.image/流程表单.jpg b/.image/流程表单.jpg new file mode 100644 index 0000000..60669c1 Binary files /dev/null and b/.image/流程表单.jpg differ diff --git a/.image/生成效果.jpg b/.image/生成效果.jpg new file mode 100644 index 0000000..98ff2cc Binary files /dev/null and b/.image/生成效果.jpg differ diff --git a/.image/用户分组.jpg b/.image/用户分组.jpg new file mode 100644 index 0000000..39af1cd Binary files /dev/null and b/.image/用户分组.jpg differ diff --git a/.image/用户管理.jpg b/.image/用户管理.jpg new file mode 100644 index 0000000..844604a Binary files /dev/null and b/.image/用户管理.jpg differ diff --git a/.image/登录.jpg b/.image/登录.jpg new file mode 100644 index 0000000..b782b98 Binary files /dev/null and b/.image/登录.jpg differ diff --git a/.image/登录日志.jpg b/.image/登录日志.jpg new file mode 100644 index 0000000..25662d9 Binary files /dev/null and b/.image/登录日志.jpg differ diff --git a/.image/短信日志.jpg b/.image/短信日志.jpg new file mode 100644 index 0000000..ada8e56 Binary files /dev/null and b/.image/短信日志.jpg differ diff --git a/.image/短信模板.jpg b/.image/短信模板.jpg new file mode 100644 index 0000000..09381cc Binary files /dev/null and b/.image/短信模板.jpg differ diff --git a/.image/短信渠道.jpg b/.image/短信渠道.jpg new file mode 100644 index 0000000..df3a5c3 Binary files /dev/null and b/.image/短信渠道.jpg differ diff --git a/.image/租户套餐.png b/.image/租户套餐.png new file mode 100644 index 0000000..9663167 Binary files /dev/null and b/.image/租户套餐.png differ diff --git a/.image/租户管理.jpg b/.image/租户管理.jpg new file mode 100644 index 0000000..647416a Binary files /dev/null and b/.image/租户管理.jpg differ diff --git a/.image/系统接口.jpg b/.image/系统接口.jpg new file mode 100644 index 0000000..6d39d42 Binary files /dev/null and b/.image/系统接口.jpg differ diff --git a/.image/菜单管理.jpg b/.image/菜单管理.jpg new file mode 100644 index 0000000..ad3b797 Binary files /dev/null and b/.image/菜单管理.jpg differ diff --git a/.image/表单构建.jpg b/.image/表单构建.jpg new file mode 100644 index 0000000..81f0374 Binary files /dev/null and b/.image/表单构建.jpg differ diff --git a/.image/角色管理.jpg b/.image/角色管理.jpg new file mode 100644 index 0000000..eed776e Binary files /dev/null and b/.image/角色管理.jpg differ diff --git a/.image/访问日志.jpg b/.image/访问日志.jpg new file mode 100644 index 0000000..ef301aa Binary files /dev/null and b/.image/访问日志.jpg differ diff --git a/.image/退款订单.jpg b/.image/退款订单.jpg new file mode 100644 index 0000000..2c6c6c9 Binary files /dev/null and b/.image/退款订单.jpg differ diff --git a/.image/通知公告.jpg b/.image/通知公告.jpg new file mode 100644 index 0000000..97bb42f Binary files /dev/null and b/.image/通知公告.jpg differ diff --git a/.image/部门管理.jpg b/.image/部门管理.jpg new file mode 100644 index 0000000..6eab233 Binary files /dev/null and b/.image/部门管理.jpg differ diff --git a/.image/配置管理.jpg b/.image/配置管理.jpg new file mode 100644 index 0000000..0abaec9 Binary files /dev/null and b/.image/配置管理.jpg differ diff --git a/.image/链路追踪.jpg b/.image/链路追踪.jpg new file mode 100644 index 0000000..12f7aa8 Binary files /dev/null and b/.image/链路追踪.jpg differ diff --git a/.image/错误日志.jpg b/.image/错误日志.jpg new file mode 100644 index 0000000..eb615ea Binary files /dev/null and b/.image/错误日志.jpg differ diff --git a/.image/错误码管理.jpg b/.image/错误码管理.jpg new file mode 100644 index 0000000..ea91dde Binary files /dev/null and b/.image/错误码管理.jpg differ diff --git a/.image/首页.jpg b/.image/首页.jpg new file mode 100644 index 0000000..10a7fde Binary files /dev/null and b/.image/首页.jpg differ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..fb2e43e --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,67 @@ +# 项目部署指南 + +## 数据库和Redis配置 + +项目已经配置好连接到指定的数据库和Redis服务器: + +### 数据库配置 +- 地址:111.3.47.177:13306 +- 用户名:root +- 密码:aiotagro +- 数据库名:aiotmini + +### Redis配置 +- 地址:111.3.47.177:16379 +- 密码:aiotagro + +这些配置已经在 [aagro-server/src/main/resources/application-custom.yaml](file:///E:/vue/aiotagro-new/aagro-server/src/main/resources/application-custom.yaml) 文件中完成配置,并通过 [aagro-server/src/main/resources/application.yaml](file:///E:/vue/aiotagro-new/aagro-server/src/main/resources/application.yaml) 激活。 + +## 部署到远程服务器 + +### 部署配置说明 + +- 远程服务器地址: 192.168.0.95 +- SSH 用户名: root +- SSH 密码: aiotagro +- 远程目录: /data/java/aiotmini +- 应用端口: 48080 + +### Linux/Mac 环境部署 + +使用脚本 [script/deploy-to-remote.sh](file:///E:/vue/aiotagro-new/script/deploy-to-remote.sh) 进行部署: + +```bash +# 给脚本添加执行权限 +chmod +x script/deploy-to-remote.sh + +# 运行部署脚本 +./script/deploy-to-remote.sh +``` + +### Windows 环境部署 + +使用脚本 [script/deploy-to-remote.bat](file:///E:/vue/aiotagro-new/script/deploy-to-remote.bat) 进行部署: + +注意: Windows 环境下需要安装以下工具: +1. Maven: 用于构建项目 +2. PuTTY: 用于SSH连接和文件传输 (需要包含 plink 和 pscp 工具) + +运行部署脚本: +```cmd +script\deploy-to-remote.bat +``` + +### 部署步骤详解 + +1. 构建项目: + - 使用 Maven 清理并打包项目: `mvn clean package -Dmaven.test.skip=true` + +2. 传输文件到远程服务器: + - 将构建好的 jar 文件传输到远程服务器 + - 将 Dockerfile 传输到远程服务器 + +3. 在远程服务器上运行: + - 构建 Docker 镜像 + - 运行 Docker 容器 + +部署完成后,可以通过 http://192.168.0.95:48080 访问应用。 \ No newline at end of file diff --git a/DEPLOYMENT_REMOTE.md b/DEPLOYMENT_REMOTE.md new file mode 100644 index 0000000..2f84b9f --- /dev/null +++ b/DEPLOYMENT_REMOTE.md @@ -0,0 +1,100 @@ +# 远程部署指南 + +本文档说明如何将项目部署到远程服务器 192.168.0.95。 + +## 配置信息 + +### 数据库配置 +- 地址:111.3.47.177:13306 +- 用户名:root +- 密码:aiotagro +- 数据库名:aiotmini + +### Redis配置 +- 地址:111.3.47.177:16379 +- 密码:aiotagro + +### 远程服务器配置 +- 地址:192.168.0.95 +- 用户名:root +- 密码:aiotagro +- 部署目录:/data/java/aiotagro-mini + +## 部署步骤 + +### 1. 准备工作 + +确保本地环境已安装以下工具: +- Maven +- SSH 客户端 (Linux/Mac 自带,Windows 需要安装 PuTTY 或使用 WSL) +- scp 命令 (用于文件传输) + +### 2. 构建项目 + +在项目根目录下执行: +```bash +mvn clean package -Dmaven.test.skip=true +``` + +### 3. 部署方式 + +#### 方式一:使用脚本部署(推荐) + +##### Linux/Mac 环境: +```bash +# 给脚本添加执行权限 +chmod +x script/deploy-remote.sh + +# 运行部署脚本 +./script/deploy-remote.sh +``` + +##### Windows 环境: +```cmd +script\deploy-remote.bat +``` + +#### 方式二:手动部署 + +1. 构建项目: + ```bash + mvn clean package -Dmaven.test.skip=true + ``` + +2. 在远程服务器上创建目录: + ```bash + ssh root@192.168.0.95 "mkdir -p /data/java/aiotagro-mini" + ``` + +3. 传输 jar 文件到远程服务器: + ```bash + scp aagro-server/target/aagro-server.jar root@192.168.0.95:/data/java/aiotagro-mini/ + ``` + +4. 传输 Dockerfile 到远程服务器: + ```bash + scp aagro-server/Dockerfile root@192.168.0.95:/data/java/aiotagro-mini/ + ``` + +5. 在远程服务器上构建并运行 Docker 容器: + ```bash + ssh root@192.168.0.95 + cd /data/java/aiotagro-mini + docker build -t aagro-server . + docker run -d \ + --name aagro-server-container \ + --restart always \ + -p 48080:48080 \ + aagro-server + ``` + +### 4. 验证部署 + +部署完成后,可以通过以下地址访问应用: +- http://192.168.0.95:48080 + +## 故障排除 + +1. 如果部署过程中遇到权限问题,请确保远程服务器上的目录权限正确。 +2. 如果 Docker 构建失败,请检查远程服务器上的 Docker 是否正常运行。 +3. 如果应用无法连接数据库或 Redis,请检查防火墙设置和网络连接。 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd9da62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2021 ruoyi-vue-pro + +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 new file mode 100644 index 0000000..d073bae --- /dev/null +++ b/README.md @@ -0,0 +1,391 @@ +

+ Downloads + Downloads + +

+ +**严肃声明:现在、未来都不会有商业版本,所有代码全部开源!!** + +**「我喜欢写代码,乐此不疲」** +**「我喜欢做开源,以此为乐」** + +我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。 + +如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。 + +## 🐶 新手必读 + +* 演示地址【Vue3 + element-plus】: +* 演示地址【Vue3 + vben(ant-design-vue)】: +* 演示地址【Vue2 + element-ui】: +* 启动文档: +* 视频教程: + +## 🐰 版本说明 + +| 版本 | JDK 8 + Spring Boot 2.7 | JDK 17/21 + Spring Boot 3.2 | +|---------------------------------------------------------------------|---------------------------------------------------------------------------|---------------------------------------------------------------------------------------| +| 【完整版】[ruoyi-vue-pro](https://gitee.com/zhijiantianya/ruoyi-vue-pro) | [`master`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master-jdk17/) 分支 | +| 【精简版】[aagro-boot-mini](https://gitee.com/aagrocode/aagro-boot-mini) | [`master`](https://gitee.com/aagrocode/aagro-boot-mini/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/aagrocode/aagro-boot-mini/tree/master-jdk17/) 分支 | + +* 【完整版】:包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP 等功能 +* 【精简版】:只包括系统功能、基础设施功能,不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP 等功能 + +可参考 [《迁移文档》](https://doc.iocoder.cn/migrate-module/) ,只需要 5-10 分钟,即可将【完整版】按需迁移到【精简版】 + +## 🐯 平台简介 + +**芋道**,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。 + +> 有任何问题,或者想要的功能,可以在 _Issues_ 中提给艿艿。 +> +> 😜 给项目点点 Star 吧,这对我们真的很重要! + +![架构图](/.image/common/ruoyi-vue-pro-architecture.png) + +* Java 后端:`master` 分支为 JDK 8 + Spring Boot 2.7,`master-jdk17` 分支为 JDK 17/21 + Spring Boot 3.2 +* 管理后台的电脑端:Vue3 提供 `element-plus`、`vben(ant-design-vue)` 两个版本,Vue2 提供 `element-ui` 版本 +* 管理后台的移动端:采用 `uni-app` 方案,一份代码多终端适配,同时支持 APP、小程序、H5! +* 后端采用 Spring Boot 多模块架构、MySQL + MyBatis Plus、Redis + Redisson +* 数据库可使用 MySQL、Oracle、PostgreSQL、SQL Server、MariaDB、国产达梦 DM、TiDB 等 +* 消息队列可使用 Event、Redis、RabbitMQ、Kafka、RocketMQ 等 +* 权限认证使用 Spring Security & Token & Redis,支持多终端、多种用户的认证系统,支持 SSO 单点登录 +* 支持加载动态权限菜单,按钮级别权限控制,Redis 缓存提升性能 +* 支持 SaaS 多租户,可自定义每个租户的权限,提供透明化的多租户底层封装 +* 工作流使用 Flowable,支持动态表单、在线设计流程、会签 / 或签、多种任务分配方式 +* 高效率开发,使用代码生成器可以一键生成 Java、Vue 前后端代码、SQL 脚本、接口文档,支持单表、树表、主子表 +* 实时通信,采用 Spring WebSocket 实现,内置 Token 身份校验,支持 WebSocket 集群 +* 集成微信小程序、微信公众号、企业微信、钉钉等三方登陆,集成支付宝、微信等支付与退款 +* 集成阿里云、腾讯云等短信渠道,集成 MinIO、阿里云、腾讯云、七牛云等云存储服务 +* 集成报表设计器、大屏设计器,通过拖拽即可生成酷炫的报表与大屏 + +## 🐳 项目关系 + +![架构演进](/.image/common/aagro-roadmap.png) + +三个项目的功能对比,可见社区共同整理的 [国产开源项目对比](https://www.yuque.com/xiatian-bsgny/lm0ec1/wqf8mn) 表格。 + +### 后端项目 + +| 项目 | Star | 简介 | +|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------| +| [ruoyi-vue-pro](https://gitee.com/zhijiantianya/ruoyi-vue-pro) | [![Gitee star](https://gitee.com/zhijiantianya/ruoyi-vue-pro/badge/star.svg?theme=white)](https://gitee.com/zhijiantianya/ruoyi-vue-pro) [![GitHub stars](https://img.shields.io/github/stars/YunaiV/ruoyi-vue-pro.svg?style=social&label=Stars)](https://github.com/YunaiV/ruoyi-vue-pro) | 基于 Spring Boot 多模块架构 | +| [aagro-cloud](https://gitee.com/zhijiantianya/aagro-cloud) | [![Gitee star](https://gitee.com/zhijiantianya/aagro-cloud/badge/star.svg?theme=white)](https://gitee.com/zhijiantianya/aagro-cloud) [![GitHub stars](https://img.shields.io/github/stars/YunaiV/aagro-cloud.svg?style=social&label=Stars)](https://github.com/YunaiV/aagro-cloud) | 基于 Spring Cloud 微服务架构 | +| [Spring-Boot-Labs](https://gitee.com/aagrocode/SpringBoot-Labs) | [![Gitee star](https://gitee.com/aagrocode/SpringBoot-Labs/badge/star.svg?theme=white)](https://gitee.com/zhijiantianya/aagro-cloud) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/SpringBoot-Labs.svg?style=social&label=Stars)](https://github.com/aagrocode/SpringBoot-Labs) | 系统学习 Spring Boot & Cloud 专栏 | + +### 前端项目 + +| 项目 | Star | 简介 | +|----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------| +| [aagro-ui-admin-vue3](https://gitee.com/aagrocode/aagro-ui-admin-vue3) | [![Gitee star](https://gitee.com/aagrocode/aagro-ui-admin-vue3/badge/star.svg?theme=white)](https://gitee.com/aagrocode/aagro-ui-admin-vue3) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/aagro-ui-admin-vue3.svg?style=social&label=Stars)](https://github.com/aagrocode/aagro-ui-admin-vue3) | 基于 Vue3 + element-plus 实现的管理后台 | +| [aagro-ui-admin-vben](https://gitee.com/aagrocode/aagro-ui-admin-vben) | [![Gitee star](https://gitee.com/aagrocode/aagro-ui-admin-vben/badge/star.svg?theme=white)](https://gitee.com/aagrocode/aagro-ui-admin-vben) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/aagro-ui-admin-vben.svg?style=social&label=Stars)](https://github.com/aagrocode/aagro-ui-admin-vben) | 基于 Vue3 + vben(ant-design-vue) 实现的管理后台 | +| [aagro-mall-uniapp](https://gitee.com/aagrocode/aagro-mall-uniapp) | [![Gitee star](https://gitee.com/aagrocode/aagro-mall-uniapp/badge/star.svg?theme=white)](https://gitee.com/aagrocode/aagro-mall-uniapp) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/aagro-mall-uniapp.svg?style=social&label=Stars)](https://github.com/aagrocode/aagro-mall-uniapp) | 基于 uni-app 实现的商城小程序 | +| [aagro-ui-admin-vue2](https://gitee.com/aagrocode/aagro-ui-admin-vue2) | [![Gitee star](https://gitee.com/aagrocode/aagro-ui-admin-vue2/badge/star.svg?theme=white)](https://gitee.com/aagrocode/aagro-ui-admin-vue2) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/aagro-ui-admin-vue2.svg?style=social&label=Stars)](https://github.com/aagrocode/aagro-ui-admin-vue2) | 基于 Vue2 + element-ui 实现的管理后台 | +| [aagro-ui-admin-uniapp](https://gitee.com/aagrocode/aagro-ui-admin-uniapp) | [![Gitee star](https://gitee.com/aagrocode/aagro-ui-admin-uniapp/badge/star.svg?theme=white)](https://gitee.com/aagrocode/aagro-ui-admin-uniapp) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/aagro-ui-admin-uniapp.svg?style=social&label=Stars)](https://github.com/aagrocode/aagro-ui-admin-uniapp) | 基于 Vue2 + element-ui 实现的管理后台 | +| [aagro-ui-go-view](https://gitee.com/aagrocode/aagro-ui-go-view) | [![Gitee star](https://gitee.com/aagrocode/aagro-ui-go-view/badge/star.svg?theme=white)](https://gitee.com/aagrocode/aagro-ui-go-view) [![GitHub stars](https://img.shields.io/github/stars/aagrocode/aagro-ui-go-view.svg?style=social&label=Stars)](https://github.com/aagrocode/aagro-ui-go-view) | 基于 Vue3 + naive-ui 实现的大屏报表 | + +## 😎 开源协议 + +**为什么推荐使用本项目?** + +① 本项目采用比 Apache 2.0 更宽松的 [MIT License](https://gitee.com/zhijiantianya/ruoyi-vue-pro/blob/master/LICENSE) 开源协议,个人与企业可 100% 免费使用,不用保留类作者、Copyright 信息。 + +② 代码全部开源,不会像其他项目一样,只开源部分代码,让你无法了解整个项目的架构设计。[国产开源项目对比](https://www.yuque.com/xiatian-bsgny/lm0ec1/wqf8mn) + +![开源项目对比](/.image/common/project-vs.png) + +③ 代码整洁、架构整洁,遵循《阿里巴巴 Java 开发手册》规范,代码注释详细,113770 行 Java 代码,42462 行代码注释。 + +## 🤝 项目外包 + +我们也是接外包滴,如果你有项目想要外包,可以微信联系【**Aix9975**】。 + +团队包含专业的项目经理、架构师、前端工程师、后端工程师、测试工程师、运维工程师,可以提供全流程的外包服务。 + +项目可以是商城、SCRM 系统、OA 系统、物流系统、ERP 系统、CMS 系统、HIS 系统、支付系统、IM 聊天、微信公众号、微信小程序等等。 + +## 🐼 内置功能 + +系统内置多种多种业务功能,可以用于快速你的业务系统: + +![功能分层](/.image/common/ruoyi-vue-pro-biz.png) + +* 通用模块(必选):系统功能、基础设施 +* 通用模块(可选):工作流程、支付系统、数据报表、会员中心 +* 业务系统(按需):ERP 系统、CRM 系统、商城系统、微信公众号、AI 大模型 + +> 友情提示:本项目基于 RuoYi-Vue 修改,**重构优化**后端的代码,**美化**前端的界面。 +> +> * 额外新增的功能,我们使用 🚀 标记。 +> * 重新实现的功能,我们使用 ⭐️ 标记。 + +🙂 所有功能,都通过 **单元测试** 保证高质量。 + +### 系统功能 + +| | 功能 | 描述 | +|-----|-------|---------------------------------| +| | 用户管理 | 用户是系统操作者,该功能主要完成系统用户配置 | +| ⭐️ | 在线用户 | 当前系统中活跃用户状态监控,支持手动踢下线 | +| | 角色管理 | 角色菜单权限分配、设置角色按机构进行数据范围权限划分 | +| | 菜单管理 | 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 | +| | 部门管理 | 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 | +| | 岗位管理 | 配置系统用户所属担任职务 | +| 🚀 | 租户管理 | 配置系统租户,支持 SaaS 场景下的多租户功能 | +| 🚀 | 租户套餐 | 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 | +| | 字典管理 | 对系统中经常使用的一些较为固定的数据进行维护 | +| 🚀 | 短信管理 | 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台 | +| 🚀 | 邮件管理 | 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 | +| 🚀 | 站内信 | 系统内的消息通知,提供站内信模版、站内信消息 | +| 🚀 | 操作日志 | 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 | +| ⭐️ | 登录日志 | 系统登录日志记录查询,包含登录异常 | +| 🚀 | 错误码管理 | 系统所有错误码的管理,可在线修改错误提示,无需重启服务 | +| | 通知公告 | 系统通知公告信息发布维护 | +| 🚀 | 敏感词 | 配置系统敏感词,支持标签分组 | +| 🚀 | 应用管理 | 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 | +| 🚀 | 地区管理 | 展示省份、城市、区镇等城市信息,支持 IP 对应城市 | + +![功能图](/.image/common/system-feature.png) + +### 工作流程 + +![功能图](/.image/common/bpm-feature.png) + +基于 Flowable 构建,可支持信创(国产)数据库,满足中国特色流程操作: + +| BPMN 设计器 | 钉钉/飞书设计器 | +|------------------------------|--------------------------------| +| ![](/.image/工作流设计器-bpmn.jpg) | ![](/.image/工作流设计器-simple.jpg) | + +> 历经头部企业生产验证,工作流引擎须标配仿钉钉/飞书 + BPMN 双设计器!!! +> +> 前者支持轻量配置简单流程,后者实现复杂场景深度编排 + +| 功能列表 | 功能描述 | 是否完成 | +|------------|-------------------------------------------------------------------------------------|------| +| SIMPLE 设计器 | 仿钉钉/飞书设计器,支持拖拽搭建表单流程,10 分钟快速完成审批流程配置 | ✅ | +| BPMN 设计器 | 基于 BPMN 标准开发,适配复杂业务场景,满足多层级审批及流程自动化需求 | ✅ | +| 会签 | 同一个审批节点设置多个人(如 A、B、C 三人,三人会同时收到待办任务),需全部同意之后,审批才可到下一审批节点 | ✅ | +| 或签 | 同一个审批节点设置多个人,任意一个人处理后,就能进入下一个节点 | ✅ | +| 依次审批 | (顺序会签)同一个审批节点设置多个人(如 A、B、C 三人),三人按顺序依次收到待办,即 A 先审批,A 提交后 B 才能审批,需全部同意之后,审批才可到下一审批节点 | ✅ | +| 抄送 | 将审批结果通知给抄送人,同一个审批默认排重,不重复抄送给同一人 | ✅ | +| 驳回 | (退回)将审批重置发送给某节点,重新审批。可驳回至发起人、上一节点、任意节点 | ✅ | +| 转办 | A 转给其 B 审批,B 审批后,进入下一节点 | ✅ | +| 委派 | A 转给其 B 审批,B 审批后,转给 A,A 继续审批后进入下一节点 | ✅ | +| 加签 | 允许当前审批人根据需要,自行增加当前节点的审批人,支持向前、向后加签 | ✅ | +| 减签 | (取消加签)在当前审批人操作之前,减少审批人 | ✅ | +| 撤销 | (取消流程)流程发起人,可以对流程进行撤销处理 | ✅ | +| 终止 | 系统管理员,在任意节点终止流程实例 | ✅ | +| 表单权限 | 支持拖拉拽配置表单,每个审批节点可配置只读、编辑、隐藏权限 | ✅ | +| 超时审批 | 配置超时审批时间,超时后自动触发审批通过、不通过、驳回等操作 | ✅ | +| 自动提醒 | 配置提醒时间,到达时间后自动触发短信、邮箱、站内信等通知提醒,支持自定义重复提醒频次 | ✅ | +| 父子流程 | 主流程设置子流程节点,子流程节点会自动触发子流程。子流程结束后,主流程才会执行(继续往下下执行),支持同步子流程、异步子流程 | ✅ | +| 条件分支 | (排它分支)用于在流程中实现决策,即根据条件选择一个分支执行 | ✅ | +| 并行分支 | 允许将流程分成多条分支,不进行条件判断,所有分支都会执行 | ✅ | +| 包容分支 | (条件分支 + 并行分支的结合体)允许基于条件选择多条分支执行,但如果没有任何一个分支满足条件,则可以选择默认分支 | ✅ | +| 路由分支 | 根据条件选择一个分支执行(重定向到指定配置节点),也可以选择默认分支执行(继续往下执行) | ✅ | +| 触发节点 | 执行到该节点,触发 HTTP 请求、HTTP 回调、更新数据、删除数据等 | ✅ | +| 延迟节点 | 执行到该节点,审批等待一段时间再执行,支持固定时长、固定日期等 | ✅ | +| 拓展设置 | 流程前置/后置通知,节点(任务)前置、后置通知,流程报表,自动审批去重,自定流程编号、标题、摘要,流程报表等 | ✅ | + +### 支付系统 + +| | 功能 | 描述 | +|-----|------|---------------------------| +| 🚀 | 应用信息 | 配置商户的应用信息,对接支付宝、微信等多个支付渠道 | +| 🚀 | 支付订单 | 查看用户发起的支付宝、微信等的【支付】订单 | +| 🚀 | 退款订单 | 查看用户发起的支付宝、微信等的【退款】订单 | +| 🚀 | 回调通知 | 查看支付回调业务的【支付】【退款】的通知结果 | +| 🚀 | 接入示例 | 提供接入支付系统的【支付】【退款】的功能实战 | + +### 基础设施 + +| | 功能 | 描述 | +|-----|-----------|----------------------------------------------| +| 🚀 | 代码生成 | 前后端代码的生成(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 实现分布式锁、幂等、限流功能,满足高并发场景 | +| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 | +| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 | + +![功能图](/.image/common/infra-feature.png) + +### 数据报表 + +| | 功能 | 描述 | +|-----|-------|--------------------| +| 🚀 | 报表设计器 | 支持数据报表、图形报表、打印设计等 | +| 🚀 | 大屏设计器 | 拖拽生成数据大屏,内置几十种图表组件 | + +### 微信公众号 + +| | 功能 | 描述 | +|-----|--------|-------------------------------| +| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 | +| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 | +| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 | +| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 | +| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 | +| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 | +| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 | +| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 | +| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 | +| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 | + +### 商城系统 + +演示地址: + +![功能图](/.image/common/mall-feature.png) + +![功能图](/.image/common/mall-preview.png) + +### 会员中心 + +| | 功能 | 描述 | +|-----|------|----------------------------------| +| 🚀 | 会员管理 | 会员是 C 端的消费者,该功能用于会员的搜索与管理 | +| 🚀 | 会员标签 | 对会员的标签进行创建、查询、修改、删除等操作 | +| 🚀 | 会员等级 | 对会员的等级、成长值进行管理,可用于订单折扣等会员权益 | +| 🚀 | 会员分组 | 对会员进行分组,用于用户画像、内容推送等运营手段 | +| 🚀 | 积分签到 | 回馈给签到、消费等行为的积分,会员可订单抵现、积分兑换等途径消耗 | + +### ERP 系统 + +演示地址: + +![功能图](/.image/common/erp-feature.png) + +### CRM 系统 + +演示地址: + +![功能图](/.image/common/crm-feature.png) + +### AI 大模型 + +演示地址: + +![功能图](/.image/common/ai-feature.png) + +![功能图](/.image/common/ai-preview.gif) + +## 🐨 技术栈 + +### 模块 + +| 项目 | 说明 | +|-----------------------|--------------------| +| `aagro-dependencies` | Maven 依赖版本管理 | +| `aagro-framework` | Java 框架拓展 | +| `aagro-server` | 管理后台 + 用户 APP 的服务端 | +| `aagro-module-system` | 系统功能的 Module 模块 | +| `aagro-module-member` | 会员中心的 Module 模块 | +| `aagro-module-infra` | 基础设施的 Module 模块 | +| `aagro-module-bpm` | 工作流程的 Module 模块 | +| `aagro-module-pay` | 支付系统的 Module 模块 | +| `aagro-module-mall` | 商城系统的 Module 模块 | +| `aagro-module-erp` | ERP 系统的 Module 模块 | +| `aagro-module-crm` | CRM 系统的 Module 模块 | +| `aagro-module-ai` | AI 大模型的 Module 模块 | +| `aagro-module-mp` | 微信公众号的 Module 模块 | +| `aagro-module-report` | 大屏报表 Module 模块 | + +### 框架 + +| 框架 | 说明 | 版本 | 学习指南 | +|---------------------------------------------------------------------------------------------|------------------|----------------|----------------------------------------------------------------| +| [Spring Boot](https://spring.io/projects/spring-boot) | 应用开发框架 | 2.7.18 | [文档](https://github.com/YunaiV/SpringBoot-Labs) | +| [MySQL](https://www.mysql.com/cn/) | 数据库服务器 | 5.7 / 8.0+ | | +| [Druid](https://github.com/alibaba/druid) | JDBC 连接池、监控组件 | 1.2.23 | [文档](http://www.iocoder.cn/Spring-Boot/datasource-pool/?aagro) | +| [MyBatis Plus](https://mp.baomidou.com/) | MyBatis 增强工具包 | 3.5.7 | [文档](http://www.iocoder.cn/Spring-Boot/MyBatis/?aagro) | +| [Dynamic Datasource](https://dynamic-datasource.com/) | 动态数据源 | 3.6.1 | [文档](http://www.iocoder.cn/Spring-Boot/datasource-pool/?aagro) | +| [Redis](https://redis.io/) | key-value 数据库 | 5.0 / 6.0 /7.0 | | +| [Redisson](https://github.com/redisson/redisson) | Redis 客户端 | 3.32.0 | [文档](http://www.iocoder.cn/Spring-Boot/Redis/?aagro) | +| [Spring MVC](https://github.com/spring-projects/spring-framework/tree/master/spring-webmvc) | MVC 框架 | 5.3.24 | [文档](http://www.iocoder.cn/SpringMVC/MVC/?aagro) | +| [Spring Security](https://github.com/spring-projects/spring-security) | Spring 安全框架 | 5.7.11 | [文档](http://www.iocoder.cn/Spring-Boot/Spring-Security/?aagro) | +| [Hibernate Validator](https://github.com/hibernate/hibernate-validator) | 参数校验组件 | 6.2.5 | [文档](http://www.iocoder.cn/Spring-Boot/Validation/?aagro) | +| [Flowable](https://github.com/flowable/flowable-engine) | 工作流引擎 | 6.8.0 | [文档](https://doc.iocoder.cn/bpm/) | +| [Quartz](https://github.com/quartz-scheduler) | 任务调度组件 | 2.3.2 | [文档](http://www.iocoder.cn/Spring-Boot/Job/?aagro) | +| [Springdoc](https://springdoc.org/) | Swagger 文档 | 1.7.0 | [文档](http://www.iocoder.cn/Spring-Boot/Swagger/?aagro) | +| [SkyWalking](https://skywalking.apache.org/) | 分布式应用追踪系统 | 8.12.0 | [文档](http://www.iocoder.cn/Spring-Boot/SkyWalking/?aagro) | +| [Spring Boot Admin](https://github.com/codecentric/spring-boot-admin) | Spring Boot 监控平台 | 2.7.10 | [文档](http://www.iocoder.cn/Spring-Boot/Admin/?aagro) | +| [Jackson](https://github.com/FasterXML/jackson) | JSON 工具库 | 2.13.5 | | +| [MapStruct](https://mapstruct.org/) | Java Bean 转换 | 1.6.3 | [文档](http://www.iocoder.cn/Spring-Boot/MapStruct/?aagro) | +| [Lombok](https://projectlombok.org/) | 消除冗长的 Java 代码 | 1.18.34 | [文档](http://www.iocoder.cn/Spring-Boot/Lombok/?aagro) | +| [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/我的流程-列表.jpg) | ![我的流程-发起](/.image/我的流程-发起.jpg) | ![我的流程-详情](/.image/我的流程-详情.jpg) | +| 待办 & 已办 | ![任务列表-审批](/.image/任务列表-审批.jpg) | ![任务列表-待办](/.image/任务列表-待办.jpg) | ![任务列表-已办](/.image/任务列表-已办.jpg) | +| OA 请假 | ![OA请假-列表](/.image/OA请假-列表.jpg) | ![OA请假-发起](/.image/OA请假-发起.jpg) | ![OA请假-详情](/.image/OA请假-详情.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) | + +### 支付系统 + +| 模块 | biu | biu | biu | +|---------|---------------------------|---------------------------------|---------------------------------| +| 商家 & 应用 | ![商户信息](/.image/商户信息.jpg) | ![应用信息-列表](/.image/应用信息-列表.jpg) | ![应用信息-编辑](/.image/应用信息-编辑.jpg) | +| 支付 & 退款 | ![支付订单](/.image/支付订单.jpg) | ![退款订单](/.image/退款订单.jpg) | --- | +### 数据报表 + +| 模块 | biu | biu | biu | +|-------|---------------------------------|---------------------------------|---------------------------------------| +| 报表设计器 | ![数据报表](/.image/报表设计器-数据报表.jpg) | ![图形报表](/.image/报表设计器-图形报表.jpg) | ![报表设计器-打印设计](/.image/报表设计器-打印设计.jpg) | +| 大屏设计器 | ![大屏列表](/.image/大屏设计器-列表.jpg) | ![大屏预览](/.image/大屏设计器-预览.jpg) | ![大屏编辑](/.image/大屏设计器-编辑.jpg) | + +### 移动端(管理后台) + +| biu | biu | biu | +|----------------------------------|----------------------------------|----------------------------------| +| ![](/.image/admin-uniapp/01.png) | ![](/.image/admin-uniapp/02.png) | ![](/.image/admin-uniapp/03.png) | +| ![](/.image/admin-uniapp/04.png) | ![](/.image/admin-uniapp/05.png) | ![](/.image/admin-uniapp/06.png) | +| ![](/.image/admin-uniapp/07.png) | ![](/.image/admin-uniapp/08.png) | ![](/.image/admin-uniapp/09.png) | + +目前已经实现登录、我的、工作台、编辑资料、头像修改、密码修改、常见问题、关于我们等基础功能。 diff --git a/aagro-dependencies/pom.xml b/aagro-dependencies/pom.xml new file mode 100644 index 0000000..3b246a2 --- /dev/null +++ b/aagro-dependencies/pom.xml @@ -0,0 +1,720 @@ + + + 4.0.0 + + cn.aagro.gg + aagro-dependencies + ${revision} + pom + + ${project.artifactId} + 基础 bom 文件,管理整个项目的依赖版本 + https://github.com/YunaiV/ruoyi-vue-pro + + + 2025.09-jdk8-SNAPSHOT + 1.6.0 + + 5.3.39 + 5.8.16 + 2.7.18 + + 1.8.0 + 4.5.0 + 2.5 + + 1.2.27 + 3.5.19 + 3.5.14 + 1.5.4 + 4.3.1 + 3.0.6 + 3.51.0 + 8.1.3.140 + 8.6.0 + 5.1.0 + 3.7.3 + + 2.3.4 + + 2.2.7 + + 8.12.0 + 2.7.15 + 0.33.0 + + 7.2.11.RELEASE + 1.1.11 + 4.11.0 + + 6.8.0 + + 1.4.0 + 1.21.2 + 1.18.38 + 1.6.3 + 5.8.40 + 1.3.0 + 2.4 + 1.2.83 + 33.4.8-jre + 2.14.5 + 3.11.1 + 3.18.0 + 2.27.3 + 2.9.3 + 2.7.0 + 3.0.6 + 4.2.4.Final + 1.2.5 + 0.9.0 + 4.5.13 + + 2.30.14 + 1.16.7 + 1.4.0 + 2.1.1 + 2.1.0 + 4.7.7-20250808.182223 + + 1.2.13 + + + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + org.springframework + spring-framework-bom + ${spring.framework.version} + pom + import + + + org.springframework.security + spring-security-bom + ${spring.security.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + io.github.mouzt + bizlog-sdk + ${bizlog-sdk.version} + + + org.springframework.boot + spring-boot-starter + + + + + cn.aagro.gg + aagro-spring-boot-starter-biz-tenant + ${revision} + + + cn.aagro.gg + aagro-spring-boot-starter-biz-data-permission + ${revision} + + + cn.aagro.gg + aagro-spring-boot-starter-biz-ip + ${revision} + + + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring.boot.version} + + + + + cn.aagro.gg + aagro-spring-boot-starter-web + ${revision} + + + + cn.aagro.gg + aagro-spring-boot-starter-security + ${revision} + + + + cn.aagro.gg + aagro-spring-boot-starter-websocket + ${revision} + + + + com.github.xiaoymin + knife4j-openapi3-spring-boot-starter + ${knife4j.version} + + + org.springdoc + springdoc-openapi-ui + ${springdoc.version} + + + + + cn.aagro.gg + aagro-spring-boot-starter-mybatis + ${revision} + + + + com.alibaba + druid-spring-boot-starter + ${druid.version} + + + + org.mybatis + mybatis + ${mybatis.version} + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + com.baomidou + mybatis-plus-jsqlparser-4.9 + ${mybatis-plus.version} + + + com.baomidou + mybatis-plus-generator + ${mybatis-plus.version} + + + com.baomidou + dynamic-datasource-spring-boot-starter + ${dynamic-datasource.version} + + + com.github.yulichang + mybatis-plus-join-boot-starter + ${mybatis-plus-join.version} + + + + com.fhs-opensource + easy-trans-spring-boot-starter + ${easy-trans.version} + + + org.springframework + spring-context + + + org.springframework.cloud + spring-cloud-commons + + + + + com.fhs-opensource + easy-trans-mybatis-plus-extend + ${easy-trans.version} + + + com.fhs-opensource + easy-trans-anno + ${easy-trans.version} + + + + cn.aagro.gg + aagro-spring-boot-starter-redis + ${revision} + + + + org.redisson + redisson-spring-boot-starter + ${redisson.version} + + + org.springframework.boot + spring-boot-starter-actuator + + + org.redisson + + redisson-spring-data-35 + + + + + org.redisson + redisson-spring-data-27 + ${redisson.version} + + + + com.dameng + DmJdbcDriver18 + ${dm8.jdbc.version} + + + + org.opengauss + opengauss-jdbc + ${opengauss.jdbc.version} + + + + cn.com.kingbase + kingbase8 + ${kingbase.jdbc.version} + + + + com.taosdata.jdbc + taos-jdbcdriver + ${taos.version} + + + + + cn.aagro.gg + aagro-spring-boot-starter-job + ${revision} + + + + + cn.aagro.gg + aagro-spring-boot-starter-mq + ${revision} + + + org.apache.rocketmq + rocketmq-spring-boot-starter + ${rocketmq-spring.version} + + + + + cn.aagro.gg + aagro-spring-boot-starter-protection + ${revision} + + + + com.baomidou + lock4j-redisson-spring-boot-starter + ${lock4j.version} + + + redisson-spring-boot-starter + org.redisson + + + + + + + cn.aagro.gg + aagro-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-server-cloud + + + + + de.codecentric + spring-boot-admin-starter-client + ${spring-boot-admin.version} + + + + + cn.aagro.gg + aagro-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} + + + + + org.flowable + flowable-spring-boot-starter-process + ${flowable.version} + + + org.flowable + flowable-spring-boot-starter-actuator + ${flowable.version} + + + + + + cn.aagro.gg + aagro-common + ${revision} + + + + cn.aagro.gg + aagro-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-5.version} + + + + cn.idev.excel + fastexcel + ${fastexcel.version} + + + + org.apache.tika + tika-core + ${tika-core.version} + + + + org.apache.velocity + velocity-engine-core + ${velocity.version} + + + + com.alibaba + fastjson + ${fastjson.version} + + + + com.google.guava + guava + ${guava.version} + + + + com.alibaba + transmittable-thread-local + ${transmittable-thread-local.version} + + + + commons-net + commons-net + ${commons-net.version} + + + com.github.mwiede + jsch + ${jsch.version} + + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + + com.anji-plus + captcha-spring-boot-starter + ${anji-plus-captcha.version} + + + + org.lionsoul + ip2region + ${ip2region.version} + + + + org.jsoup + jsoup + ${jsoup.version} + + + + + software.amazon.awssdk + s3 + ${awssdk.version} + + + + me.zhyd.oauth + JustAuth + ${justauth.version} + + + com.xkcoding.justauth + justauth-spring-boot-starter + ${justauth-starter.version} + + + + cn.hutool + hutool-core + + + + + + 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} + + + + + org.jeecgframework.jimureport + jimureport-spring-boot-starter + ${jimureport.version} + + + org.jeecgframework.jimureport + jimubi-spring-boot-starter + ${jimubi.version} + + + com.github.jsqlparser + jsqlparser + + + cn.hutool + hutool-core + + + + + + + org.pf4j + pf4j-spring + ${pf4j-spring.version} + + + org.slf4j + slf4j-log4j12 + + + + + + + io.vertx + vertx-core + ${vertx.version} + + + io.vertx + vertx-web + ${vertx.version} + + + io.vertx + vertx-mqtt + ${vertx.version} + + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + ${mqtt.version} + + + + + ch.qos.logback + logback-core + ${logback.version} + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + bom + true + + + + + flatten + + flatten + process-resources + + + + clean + + flatten.clean + clean + + + + + + + diff --git a/aagro-framework/aagro-common/pom.xml b/aagro-framework/aagro-common/pom.xml new file mode 100644 index 0000000..13f6c6d --- /dev/null +++ b/aagro-framework/aagro-common/pom.xml @@ -0,0 +1,149 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-common + jar + + ${project.artifactId} + 定义基础 pojo 类、枚举、工具类等等 + https://github.com/YunaiV/ruoyi-vue-pro + + + + + 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 + + + + com.fhs-opensource + easy-trans-anno + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/ApiAccessLogCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/ApiAccessLogCommonApi.java new file mode 100644 index 0000000..9b10a23 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/ApiAccessLogCommonApi.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.common.biz.infra.logger; + +import cn.aagro.pp.framework.common.biz.infra.logger.dto.ApiAccessLogCreateReqDTO; +import org.springframework.scheduling.annotation.Async; + +import javax.validation.Valid; + +/** + * API 访问日志的 API 接口 + * + * @author 芋道源码 + */ +public interface ApiAccessLogCommonApi { + + /** + * 创建 API 访问日志 + * + * @param createDTO 创建信息 + */ + void createApiAccessLog(@Valid ApiAccessLogCreateReqDTO createDTO); + + /** + * 【异步】创建 API 访问日志 + * + * @param createDTO 访问日志 DTO + */ + @Async + default void createApiAccessLogAsync(ApiAccessLogCreateReqDTO createDTO) { + createApiAccessLog(createDTO); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/ApiErrorLogCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/ApiErrorLogCommonApi.java new file mode 100644 index 0000000..2c2260e --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/ApiErrorLogCommonApi.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.common.biz.infra.logger; + +import cn.aagro.pp.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO; +import org.springframework.scheduling.annotation.Async; + +import javax.validation.Valid; + +/** + * API 错误日志的 API 接口 + * + * @author 芋道源码 + */ +public interface ApiErrorLogCommonApi { + + /** + * 创建 API 错误日志 + * + * @param createDTO 创建信息 + */ + void createApiErrorLog(@Valid ApiErrorLogCreateReqDTO createDTO); + + /** + * 【异步】创建 API 异常日志 + * + * @param createDTO 异常日志 DTO + */ + @Async + default void createApiErrorLogAsync(ApiErrorLogCreateReqDTO createDTO) { + createApiErrorLog(createDTO); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/dto/ApiAccessLogCreateReqDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/dto/ApiAccessLogCreateReqDTO.java new file mode 100644 index 0000000..2e168ad --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/dto/ApiAccessLogCreateReqDTO.java @@ -0,0 +1,103 @@ +package cn.aagro.pp.framework.common.biz.infra.logger.dto; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * API 访问日志 + * + * @author 芋道源码 + */ +@Data +public class ApiAccessLogCreateReqDTO { + + /** + * 链路追踪编号 + */ + private String traceId; + /** + * 用户编号 + */ + private Long userId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 应用名 + */ + @NotNull(message = "应用名不能为空") + private String applicationName; + + /** + * 请求方法名 + */ + @NotNull(message = "http 请求方法不能为空") + private String requestMethod; + /** + * 访问地址 + */ + @NotNull(message = "访问地址不能为空") + private String requestUrl; + /** + * 请求参数 + */ + private String requestParams; + /** + * 响应结果 + */ + private String responseBody; + /** + * 用户 IP + */ + @NotNull(message = "ip 不能为空") + private String userIp; + /** + * 浏览器 UA + */ + @NotNull(message = "User-Agent 不能为空") + private String userAgent; + + /** + * 操作模块 + */ + private String operateModule; + /** + * 操作名 + */ + private String operateName; + /** + * 操作分类 + * + * 枚举,参见 OperateTypeEnum 类 + */ + private Integer operateType; + + /** + * 开始请求时间 + */ + @NotNull(message = "开始请求时间不能为空") + private LocalDateTime beginTime; + /** + * 结束请求时间 + */ + @NotNull(message = "结束请求时间不能为空") + private LocalDateTime endTime; + /** + * 执行时长,单位:毫秒 + */ + @NotNull(message = "执行时长不能为空") + private Integer duration; + /** + * 结果码 + */ + @NotNull(message = "错误码不能为空") + private Integer resultCode; + /** + * 结果提示 + */ + private String resultMsg; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/dto/ApiErrorLogCreateReqDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/dto/ApiErrorLogCreateReqDTO.java new file mode 100644 index 0000000..6a990c1 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/logger/dto/ApiErrorLogCreateReqDTO.java @@ -0,0 +1,107 @@ +package cn.aagro.pp.framework.common.biz.infra.logger.dto; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * API 错误日志 + * + * @author 芋道源码 + */ +@Data +public class ApiErrorLogCreateReqDTO { + + /** + * 链路编号 + */ + private String traceId; + /** + * 账号编号 + */ + private Long userId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 应用名 + */ + @NotNull(message = "应用名不能为空") + private String applicationName; + + /** + * 请求方法名 + */ + @NotNull(message = "http 请求方法不能为空") + private String requestMethod; + /** + * 访问地址 + */ + @NotNull(message = "访问地址不能为空") + private String requestUrl; + /** + * 请求参数 + */ + @NotNull(message = "请求参数不能为空") + private String requestParams; + /** + * 用户 IP + */ + @NotNull(message = "ip 不能为空") + private String userIp; + /** + * 浏览器 UA + */ + @NotNull(message = "User-Agent 不能为空") + private String userAgent; + + /** + * 异常时间 + */ + @NotNull(message = "异常时间不能为空") + private LocalDateTime exceptionTime; + /** + * 异常名 + */ + @NotNull(message = "异常名不能为空") + private String exceptionName; + /** + * 异常发生的类全名 + */ + @NotNull(message = "异常发生的类全名不能为空") + private String exceptionClassName; + /** + * 异常发生的类文件 + */ + @NotNull(message = "异常发生的类文件不能为空") + private String exceptionFileName; + /** + * 异常发生的方法名 + */ + @NotNull(message = "异常发生的方法名不能为空") + private String exceptionMethodName; + /** + * 异常发生的方法所在行 + */ + @NotNull(message = "异常发生的方法所在行不能为空") + private Integer exceptionLineNumber; + /** + * 异常的栈轨迹异常的栈轨迹 + */ + @NotNull(message = "异常的栈轨迹不能为空") + private String exceptionStackTrace; + /** + * 异常导致的根消息 + */ + @NotNull(message = "异常导致的根消息不能为空") + private String exceptionRootCauseMessage; + /** + * 异常导致的消息 + */ + @NotNull(message = "异常导致的消息不能为空") + private String exceptionMessage; + + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/package-info.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/package-info.java new file mode 100644 index 0000000..b0accb9 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/infra/package-info.java @@ -0,0 +1,4 @@ +/** + * 针对 infra 模块的 api 包 + */ +package cn.aagro.pp.framework.common.biz.infra; \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/package-info.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/package-info.java new file mode 100644 index 0000000..476d12a --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/package-info.java @@ -0,0 +1,4 @@ +/** + * 特殊:用于 framework 下,starter 需要调用 biz 业务模块的接口定义! + */ +package cn.aagro.pp.framework.common.biz; \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/dict/DictDataCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/dict/DictDataCommonApi.java new file mode 100644 index 0000000..32c4ccf --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/dict/DictDataCommonApi.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.framework.common.biz.system.dict; + +import cn.aagro.pp.framework.common.biz.system.dict.dto.DictDataRespDTO; + +import java.util.List; + +/** + * 字典数据 API 接口 + * + * @author 芋道源码 + */ +public interface DictDataCommonApi { + + /** + * 获得指定字典类型的字典数据列表 + * + * @param dictType 字典类型 + * @return 字典数据列表 + */ + List getDictDataList(String dictType); + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/dict/dto/DictDataRespDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/dict/dto/DictDataRespDTO.java new file mode 100644 index 0000000..198ddd4 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/dict/dto/DictDataRespDTO.java @@ -0,0 +1,33 @@ +package cn.aagro.pp.framework.common.biz.system.dict.dto; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import lombok.Data; + +/** + * 字典数据 Response DTO + * + * @author 芋道源码 + */ +@Data +public class DictDataRespDTO { + + /** + * 字典标签 + */ + private String label; + /** + * 字典值 + */ + private String value; + /** + * 字典类型 + */ + private String dictType; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/logger/OperateLogCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/logger/OperateLogCommonApi.java new file mode 100644 index 0000000..62d6d70 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/logger/OperateLogCommonApi.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.common.biz.system.logger; + +import cn.aagro.pp.framework.common.biz.system.logger.dto.OperateLogCreateReqDTO; +import org.springframework.scheduling.annotation.Async; + +import javax.validation.Valid; + +/** + * 操作日志 API 接口 + * + * @author 芋道源码 + */ +public interface OperateLogCommonApi { + + /** + * 创建操作日志 + * + * @param createReqDTO 请求 + */ + void createOperateLog(@Valid OperateLogCreateReqDTO createReqDTO); + + /** + * 【异步】创建操作日志 + * + * @param createReqDTO 请求 + */ + @Async + default void createOperateLogAsync(OperateLogCreateReqDTO createReqDTO) { + createOperateLog(createReqDTO); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/logger/dto/OperateLogCreateReqDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/logger/dto/OperateLogCreateReqDTO.java new file mode 100644 index 0000000..52b542b --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/logger/dto/OperateLogCreateReqDTO.java @@ -0,0 +1,85 @@ +package cn.aagro.pp.framework.common.biz.system.logger.dto; + +import cn.aagro.pp.framework.common.enums.UserTypeEnum; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * 系统操作日志 Create Request DTO + * + * @author HUIHUI + */ +@Data +public class OperateLogCreateReqDTO { + + /** + * 链路追踪编号 + * + * 一般来说,通过链路追踪编号,可以将访问日志,错误日志,链路追踪日志,logger 打印日志等,结合在一起,从而进行排错。 + */ + private String traceId; + /** + * 用户编号 + * + * 关联 MemberUserDO 的 id 属性,或者 AdminUserDO 的 id 属性 + */ + @NotNull(message = "用户编号不能为空") + private Long userId; + /** + * 用户类型 + * + * 关联 {@link UserTypeEnum} + */ + @NotNull(message = "用户类型不能为空") + private Integer userType; + /** + * 操作模块类型 + */ + @NotEmpty(message = "操作模块类型不能为空") + private String type; + /** + * 操作名 + */ + @NotEmpty(message = "操作名不能为空") + private String subType; + /** + * 操作模块业务编号 + */ + @NotNull(message = "操作模块业务编号不能为空") + private Long bizId; + /** + * 操作内容,记录整个操作的明细 + * 例如说,修改编号为 1 的用户信息,将性别从男改成女,将姓名从芋道改成源码。 + */ + @NotEmpty(message = "操作内容不能为空") + private String action; + /** + * 拓展字段,有些复杂的业务,需要记录一些字段 ( JSON 格式 ) + * 例如说,记录订单编号,{ orderId: "1"} + */ + private String extra; + + /** + * 请求方法名 + */ + @NotEmpty(message = "请求方法名不能为空") + private String requestMethod; + /** + * 请求地址 + */ + @NotEmpty(message = "请求地址不能为空") + private String requestUrl; + /** + * 用户 IP + */ + @NotEmpty(message = "用户 IP 不能为空") + private String userIp; + /** + * 浏览器 UA + */ + @NotEmpty(message = "浏览器 UA 不能为空") + private String userAgent; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/OAuth2TokenCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/OAuth2TokenCommonApi.java new file mode 100644 index 0000000..f7c2598 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/OAuth2TokenCommonApi.java @@ -0,0 +1,49 @@ +package cn.aagro.pp.framework.common.biz.system.oauth2; + +import cn.aagro.pp.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO; +import cn.aagro.pp.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCreateReqDTO; +import cn.aagro.pp.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenRespDTO; + +import javax.validation.Valid; + +/** + * OAuth2.0 Token API 接口 + * + * @author 芋道源码 + */ +public interface OAuth2TokenCommonApi { + + /** + * 创建访问令牌 + * + * @param reqDTO 访问令牌的创建信息 + * @return 访问令牌的信息 + */ + OAuth2AccessTokenRespDTO createAccessToken(@Valid OAuth2AccessTokenCreateReqDTO reqDTO); + + /** + * 校验访问令牌 + * + * @param accessToken 访问令牌 + * @return 访问令牌的信息 + */ + OAuth2AccessTokenCheckRespDTO checkAccessToken(String accessToken); + + /** + * 移除访问令牌 + * + * @param accessToken 访问令牌 + * @return 访问令牌的信息 + */ + OAuth2AccessTokenRespDTO removeAccessToken(String accessToken); + + /** + * 刷新访问令牌 + * + * @param refreshToken 刷新令牌 + * @param clientId 客户端编号 + * @return 访问令牌的信息 + */ + OAuth2AccessTokenRespDTO refreshAccessToken(String refreshToken, String clientId); + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java new file mode 100644 index 0000000..50e722a --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCheckRespDTO.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.framework.common.biz.system.oauth2.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * OAuth2.0 访问令牌的校验 Response DTO + * + * @author 芋道源码 + */ +@Data +public class OAuth2AccessTokenCheckRespDTO implements Serializable { + + /** + * 用户编号 + */ + private Long userId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户信息 + */ + private Map userInfo; + /** + * 租户编号 + */ + private Long tenantId; + /** + * 授权范围的数组 + */ + private List scopes; + /** + * 过期时间 + */ + private LocalDateTime expiresTime; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCreateReqDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCreateReqDTO.java new file mode 100644 index 0000000..9b511e4 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenCreateReqDTO.java @@ -0,0 +1,40 @@ +package cn.aagro.pp.framework.common.biz.system.oauth2.dto; + +import cn.aagro.pp.framework.common.enums.UserTypeEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.List; + +/** + * OAuth2.0 访问令牌创建 Request DTO + * + * @author 芋道源码 + */ +@Data +public class OAuth2AccessTokenCreateReqDTO implements Serializable { + + /** + * 用户编号 + */ + @NotNull(message = "用户编号不能为空") + private Long userId; + /** + * 用户类型 + */ + @NotNull(message = "用户类型不能为空") + @InEnum(value = UserTypeEnum.class, message = "用户类型必须是 {value}") + private Integer userType; + /** + * 客户端编号 + */ + @NotNull(message = "客户端编号不能为空") + private String clientId; + /** + * 授权范围 + */ + private List scopes; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java new file mode 100644 index 0000000..e005ed8 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.framework.common.biz.system.oauth2.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * OAuth2.0 访问令牌的信息 Response DTO + * + * @author 芋道源码 + */ +@Data +public class OAuth2AccessTokenRespDTO implements Serializable { + + /** + * 访问令牌 + */ + private String accessToken; + /** + * 刷新令牌 + */ + private String refreshToken; + /** + * 用户编号 + */ + private Long userId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 过期时间 + */ + private LocalDateTime expiresTime; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/package-info.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/package-info.java new file mode 100644 index 0000000..23eb467 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/package-info.java @@ -0,0 +1,4 @@ +/** + * 针对 system 模块的 api 包 + */ +package cn.aagro.pp.framework.common.biz.system; \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/permission/PermissionCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/permission/PermissionCommonApi.java new file mode 100644 index 0000000..a4adf53 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/permission/PermissionCommonApi.java @@ -0,0 +1,38 @@ +package cn.aagro.pp.framework.common.biz.system.permission; + +import cn.aagro.pp.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; + +/** + * 权限 API 接口 + * + * @author 芋道源码 + */ +public interface PermissionCommonApi { + + /** + * 判断是否有权限,任一一个即可 + * + * @param userId 用户编号 + * @param permissions 权限 + * @return 是否 + */ + boolean hasAnyPermissions(Long userId, String... permissions); + + /** + * 判断是否有角色,任一一个即可 + * + * @param userId 用户编号 + * @param roles 角色数组 + * @return 是否 + */ + boolean hasAnyRoles(Long userId, String... roles); + + /** + * 获得登陆用户的部门数据权限 + * + * @param userId 用户编号 + * @return 部门数据权限 + */ + DeptDataPermissionRespDTO getDeptDataPermission(Long userId); + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/permission/dto/DeptDataPermissionRespDTO.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/permission/dto/DeptDataPermissionRespDTO.java new file mode 100644 index 0000000..3cdcea8 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/permission/dto/DeptDataPermissionRespDTO.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.common.biz.system.permission.dto; + +import lombok.Data; + +import java.util.HashSet; +import java.util.Set; + +/** + * 部门的数据权限 Response DTO + * + * @author 芋道源码 + */ +@Data +public class DeptDataPermissionRespDTO { + + /** + * 是否可查看全部数据 + */ + private Boolean all; + /** + * 是否可查看自己的数据 + */ + private Boolean self; + /** + * 可查看的部门编号数组 + */ + private Set deptIds; + + public DeptDataPermissionRespDTO() { + this.all = false; + this.self = false; + this.deptIds = new HashSet<>(); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/tenant/TenantCommonApi.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/tenant/TenantCommonApi.java new file mode 100644 index 0000000..f36c3df --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/biz/system/tenant/TenantCommonApi.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.framework.common.biz.system.tenant; + +import java.util.List; + +/** + * 多租户的 API 接口 + * + * @author 芋道源码 + */ +public interface TenantCommonApi { + + /** + * 获得所有租户 + * + * @return 租户编号数组 + */ + List getTenantIdList(); + + /** + * 校验租户是否合法 + * + * @param id 租户编号 + */ + void validateTenant(Long id); + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/core/ArrayValuable.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/core/ArrayValuable.java new file mode 100644 index 0000000..7b2537b --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/core/ArrayValuable.java @@ -0,0 +1,15 @@ +package cn.aagro.pp.framework.common.core; + +/** + * 可生成 T 数组的接口 + * + * @author HUIHUI + */ +public interface ArrayValuable { + + /** + * @return 数组 + */ + T[] array(); + +} \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/core/KeyValue.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/core/KeyValue.java new file mode 100644 index 0000000..dc5a561 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/core/KeyValue.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/CommonStatusEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/CommonStatusEnum.java new file mode 100644 index 0000000..4f6da71 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/CommonStatusEnum.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.common.enums; + +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 通用状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum CommonStatusEnum implements ArrayValuable { + + ENABLE(0, "开启"), + DISABLE(1, "关闭"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(CommonStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态值 + */ + private final Integer status; + /** + * 状态名 + */ + private final String name; + + @Override + public Integer[] 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/DateIntervalEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/DateIntervalEnum.java new file mode 100644 index 0000000..2581ce5 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/DateIntervalEnum.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.common.enums; + +import cn.hutool.core.util.ArrayUtil; +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 时间间隔的枚举 + * + * @author dhb52 + */ +@Getter +@AllArgsConstructor +public enum DateIntervalEnum implements ArrayValuable { + + HOUR(0, "小时"), // 特殊:字典里,暂时不会有这个枚举!!!因为大多数情况下,用不到这个间隔 + DAY(1, "天"), + WEEK(2, "周"), + MONTH(3, "月"), + QUARTER(4, "季度"), + YEAR(5, "年") + ; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(DateIntervalEnum::getInterval).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer interval; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static DateIntervalEnum valueOf(Integer interval) { + return ArrayUtil.firstMatch(item -> item.getInterval().equals(interval), DateIntervalEnum.values()); + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/DocumentEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/DocumentEnum.java new file mode 100644 index 0000000..a737cec --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/DocumentEnum.java @@ -0,0 +1,21 @@ +package cn.aagro.pp.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 安装文档"), + TENANT("https://doc.iocoder.cn", "SaaS 多租户文档"); + + private final String url; + private final String memo; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/RpcConstants.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/RpcConstants.java new file mode 100644 index 0000000..c3de564 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/RpcConstants.java @@ -0,0 +1,17 @@ +package cn.aagro.pp.framework.common.enums; + +/** + * RPC 相关的枚举 + * + * 虽然放在 aagro-spring-boot-starter-rpc 会相对合适,但是每个 API 模块需要使用到,所以暂时只好放在此处 + * + * @author 芋道源码 + */ +public class RpcConstants { + + /** + * RPC API 的前缀 + */ + public static final String RPC_API_PREFIX = "/rpc-api"; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/TerminalEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/TerminalEnum.java new file mode 100644 index 0000000..9fadd34 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/TerminalEnum.java @@ -0,0 +1,40 @@ +package cn.aagro.pp.framework.common.enums; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * 终端的枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum TerminalEnum implements ArrayValuable { + + UNKNOWN(0, "未知"), // 目的:在无法解析到 terminal 时,使用它 + WECHAT_MINI_PROGRAM(10, "微信小程序"), + WECHAT_WAP(11, "微信公众号"), + H5(20, "H5 网页"), + APP(31, "手机 App"), + ; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(TerminalEnum::getTerminal).toArray(Integer[]::new); + + /** + * 终端 + */ + private final Integer terminal; + /** + * 终端名 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/UserTypeEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/UserTypeEnum.java new file mode 100644 index 0000000..6c7ba0a --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/UserTypeEnum.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.framework.common.enums; + +import cn.hutool.core.util.ArrayUtil; +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 全局用户类型枚举 + */ +@AllArgsConstructor +@Getter +public enum UserTypeEnum implements ArrayValuable { + + MEMBER(1, "会员"), // 面向 c 端,普通用户 + ADMIN(2, "管理员"); // 面向 b 端,管理后台 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(UserTypeEnum::getValue).toArray(Integer[]::new); + + /** + * 类型 + */ + 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 Integer[] array() { + return ARRAYS; + } +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/WebFilterOrderEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/WebFilterOrderEnum.java new file mode 100644 index 0000000..e4d3652 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/enums/WebFilterOrderEnum.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.framework.common.enums; + +/** + * Web 过滤器顺序的枚举类,保证过滤器按照符合我们的预期 + * + * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 enums 包下 + * + * @author 芋道源码 + */ +public interface WebFilterOrderEnum { + + int CORS_FILTER = Integer.MIN_VALUE; + + int TRACE_FILTER = CORS_FILTER + 1; + + int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500; + + int API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1; + + // 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ErrorCode.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ErrorCode.java new file mode 100644 index 0000000..b95cee2 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ErrorCode.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.common.exception; + +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ServerException.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ServerException.java new file mode 100644 index 0000000..0df6545 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ServerException.java @@ -0,0 +1,60 @@ +package cn.aagro.pp.framework.common.exception; + +import cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ServiceException.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ServiceException.java new file mode 100644 index 0000000..32f251f --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/ServiceException.java @@ -0,0 +1,60 @@ +package cn.aagro.pp.framework.common.exception; + +import cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/enums/GlobalErrorCodeConstants.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/enums/GlobalErrorCodeConstants.java new file mode 100644 index 0000000..9602280 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/enums/GlobalErrorCodeConstants.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.framework.common.exception.enums; + +import cn.aagro.pp.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 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/enums/ServiceErrorCodeRange.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/enums/ServiceErrorCodeRange.java new file mode 100644 index 0000000..bcabc7e --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/enums/ServiceErrorCodeRange.java @@ -0,0 +1,48 @@ +package cn.aagro.pp.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-002-000-000) + // 模块 system 错误码区间 [1-002-000-000 ~ 1-003-000-000) + // 模块 report 错误码区间 [1-003-000-000 ~ 1-004-000-000) + // 模块 member 错误码区间 [1-004-000-000 ~ 1-005-000-000) + // 模块 mp 错误码区间 [1-006-000-000 ~ 1-007-000-000) + // 模块 pay 错误码区间 [1-007-000-000 ~ 1-008-000-000) + // 模块 bpm 错误码区间 [1-009-000-000 ~ 1-010-000-000) + + // 模块 product 错误码区间 [1-008-000-000 ~ 1-009-000-000) + // 模块 trade 错误码区间 [1-011-000-000 ~ 1-012-000-000) + // 模块 promotion 错误码区间 [1-013-000-000 ~ 1-014-000-000) + + // 模块 crm 错误码区间 [1-020-000-000 ~ 1-021-000-000) + + // 模块 ai 错误码区间 [1-022-000-000 ~ 1-023-000-000) + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/util/ServiceExceptionUtil.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/util/ServiceExceptionUtil.java new file mode 100644 index 0000000..81028e8 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/exception/util/ServiceExceptionUtil.java @@ -0,0 +1,77 @@ +package cn.aagro.pp.framework.common.exception.util; + +import cn.aagro.pp.framework.common.exception.ErrorCode; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +/** + * {@link ServiceException} 工具类 + * + * 目的在于,格式化异常信息提示。 + * 考虑到 String.format 在参数不正确时会报错,因此使用 {} 作为占位符,并使用 {@link #doFormat(int, String, Object...)} 方法来格式化 + * + */ +@Slf4j +public class ServiceExceptionUtil { + + // ========== 和 ServiceException 的集成 ========== + + public static ServiceException exception(ErrorCode errorCode) { + return exception0(errorCode.getCode(), errorCode.getMsg()); + } + + public static ServiceException exception(ErrorCode errorCode, Object... params) { + return exception0(errorCode.getCode(), errorCode.getMsg(), 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/package-info.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/package-info.java new file mode 100644 index 0000000..0f3bc03 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/package-info.java @@ -0,0 +1,6 @@ +/** + * 基础的通用类,和框架无关 + * + * 例如说,CommonResult 为通用返回 + */ +package cn.aagro.pp.framework.common; diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/CommonResult.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/CommonResult.java new file mode 100644 index 0000000..d332288 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/CommonResult.java @@ -0,0 +1,121 @@ +package cn.aagro.pp.framework.common.pojo; + +import cn.hutool.core.lang.Assert; +import cn.aagro.pp.framework.common.exception.ErrorCode; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.exception.util.ServiceExceptionUtil; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.io.Serializable; +import java.util.Objects; + +/** + * 通用返回 + * + * @param 数据泛型 + */ +@Data +public class CommonResult implements Serializable { + + /** + * 错误码 + * + * @see ErrorCode#getCode() + */ + private Integer code; + /** + * 错误提示,用户可阅读 + * + * @see ErrorCode#getMsg() () + */ + private String msg; + /** + * 返回数据 + */ + private T data; + + /** + * 将传入的 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.notEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), code, "code 必须是错误的!"); + CommonResult result = new CommonResult<>(); + result.code = code; + result.msg = message; + return result; + } + + public static CommonResult error(ErrorCode errorCode, Object... params) { + Assert.notEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), errorCode.getCode(), "code 必须是错误的!"); + CommonResult result = new CommonResult<>(); + result.code = errorCode.getCode(); + result.msg = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMsg(), params); + 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/PageParam.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/PageParam.java new file mode 100644 index 0000000..9b564a0 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/PageParam.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/PageResult.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/PageResult.java new file mode 100644 index 0000000..3851a52 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/PageResult.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.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 Long total; + + @Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED) + private List list; + + public PageResult() { + } + + 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/SortablePageParam.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/SortablePageParam.java new file mode 100644 index 0000000..c7828f7 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/SortablePageParam.java @@ -0,0 +1,19 @@ +package cn.aagro.pp.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; + +} \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/SortingField.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/SortingField.java new file mode 100644 index 0000000..3548720 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/pojo/SortingField.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/cache/CacheUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/cache/CacheUtils.java new file mode 100644 index 0000000..b159ce4 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/cache/CacheUtils.java @@ -0,0 +1,61 @@ +package cn.aagro.pp.framework.common.util.cache; + +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.Executors; + +/** + * Cache 工具类 + * + * @author 芋道源码 + */ +public class CacheUtils { + + /** + * 异步刷新的 LoadingCache 最大缓存数量 + * + * @see 本地缓存 CacheUtils 工具类建议 + */ + private static final Integer CACHE_MAX_SIZE = 10000; + + /** + * 构建异步刷新的 LoadingCache 对象 + * + * 注意:如果你的缓存和 ThreadLocal 有关系,要么自己处理 ThreadLocal 的传递,要么使用 {@link #buildCache(Duration, CacheLoader)} 方法 + * + * 或者简单理解: + * 1、和“人”相关的,使用 {@link #buildCache(Duration, CacheLoader)} 方法 + * 2、和“全局”、“系统”相关的,使用当前缓存方法 + * + * @param duration 过期时间 + * @param loader CacheLoader 对象 + * @return LoadingCache 对象 + */ + public static LoadingCache buildAsyncReloadingCache(Duration duration, CacheLoader loader) { + return CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX_SIZE) + // 只阻塞当前数据加载线程,其他线程返回旧值 + .refreshAfterWrite(duration) + // 通过 asyncReloading 实现全异步加载,包括 refreshAfterWrite 被阻塞的加载线程 + .build(CacheLoader.asyncReloading(loader, Executors.newCachedThreadPool())); // TODO 芋艿:可能要思考下,未来要不要做成可配置 + } + + /** + * 构建同步刷新的 LoadingCache 对象 + * + * @param duration 过期时间 + * @param loader CacheLoader 对象 + * @return LoadingCache 对象 + */ + public static LoadingCache buildCache(Duration duration, CacheLoader loader) { + return CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX_SIZE) + // 只阻塞当前数据加载线程,其他线程返回旧值 + .refreshAfterWrite(duration) + .build(loader); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/ArrayUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/ArrayUtils.java new file mode 100644 index 0000000..5be370b --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/ArrayUtils.java @@ -0,0 +1,58 @@ +package cn.aagro.pp.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 cn.aagro.pp.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(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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/CollectionUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/CollectionUtils.java new file mode 100644 index 0000000..d1a180c --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/CollectionUtils.java @@ -0,0 +1,352 @@ +package cn.aagro.pp.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.aagro.pp.framework.common.pojo.PageResult; +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 cn.hutool.core.convert.Convert.toCollection; +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 PageResult convertPage(PageResult from, Function func) { + if (ArrayUtil.isEmpty(from)) { + return new PageResult<>(from.getTotal()); + } + return new PageResult<>(convertList(from.getList(), func), from.getTotal()); + } + + public static List convertListByFlatMap(Collection from, + Function> func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().filter(Objects::nonNull).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).filter(Objects::nonNull).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) { + return convertSet(from, v -> v); + } + + 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().filter(Objects::nonNull).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).filter(Objects::nonNull).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(Collection from, Predicate predicate) { + return findFirst(from, predicate, Function.identity()); + } + + public static U findFirst(Collection 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 > T getMinObject(List from, Function valueFunc) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert from.size() > 0; // 断言,避免告警 + return from.stream().min(Comparator.comparing(valueFunc)).get(); + } + + public static > V getSumValue(Collection from, Function valueFunc, + BinaryOperator accumulator) { + return getSumValue(from, valueFunc, accumulator, null); + } + + public static > V getSumValue(Collection from, Function valueFunc, + BinaryOperator accumulator, V defaultValue) { + if (CollUtil.isEmpty(from)) { + return defaultValue; + } + assert !from.isEmpty(); // 断言,避免告警 + return from.stream().map(valueFunc).filter(Objects::nonNull).reduce(accumulator).orElse(defaultValue); + } + + public static void addIfNotNull(Collection coll, T item) { + if (item == null) { + return; + } + coll.add(item); + } + + public static Collection singleton(T obj) { + return obj == null ? Collections.emptyList() : Collections.singleton(obj); + } + + public static List newArrayList(List> list) { + return list.stream().filter(Objects::nonNull).flatMap(Collection::stream).collect(Collectors.toList()); + } + + /** + * 转换为 LinkedHashSet + * + * @param 元素类型 + * @param elementType 集合中元素类型 + * @param value 被转换的值 + * @return {@link LinkedHashSet} + */ + @SuppressWarnings("unchecked") + public static LinkedHashSet toLinkedHashSet(Class elementType, Object value) { + return (LinkedHashSet) toCollection(LinkedHashSet.class, elementType, value); + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/MapUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/MapUtils.java new file mode 100644 index 0000000..6ada583 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/MapUtils.java @@ -0,0 +1,68 @@ +package cn.aagro.pp.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.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,然后进一步处理 + * key 为 null 时, 不处理 + * 注意,如果查找到的 value 为 null 时,不进行处理 + * + * @param map 哈希表 + * @param key key + * @param consumer 进一步处理的逻辑 + */ + public static void findAndThen(Map map, K key, Consumer consumer) { + if (ObjUtil.isNull(key) || 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/SetUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/SetUtils.java new file mode 100644 index 0000000..00f0129 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/collection/SetUtils.java @@ -0,0 +1,19 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/date/DateUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/date/DateUtils.java new file mode 100644 index 0000000..cbeb9e4 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/date/DateUtils.java @@ -0,0 +1,149 @@ +package cn.aagro.pp.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"; + + /** + * 将 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(LocalDateTime time) { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(time); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param month 月 + * @param day 日 + * @return 指定时间 + */ + public static Date buildTime(int year, int month, int day) { + return buildTime(year, month, day, 0, 0, 0); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param month 月 + * @param day 日 + * @param hour 小时 + * @param minute 分钟 + * @param second 秒 + * @return 指定时间 + */ + public static Date buildTime(int year, int month, int day, + int hour, int minute, int second) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 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 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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/date/LocalDateTimeUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/date/LocalDateTimeUtils.java new file mode 100644 index 0000000..4869241 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/date/LocalDateTimeUtils.java @@ -0,0 +1,350 @@ +package cn.aagro.pp.framework.common.util.date; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.date.TemporalAccessorUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.enums.DateIntervalEnum; + +import java.sql.Timestamp; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; + +import static cn.hutool.core.date.DatePattern.*; + +/** + * 时间工具类,用于 {@link LocalDateTime} + * + * @author 芋道源码 + */ +public class LocalDateTimeUtils { + + /** + * 空的 LocalDateTime 对象,主要用于 DB 唯一索引的默认值 + */ + public static LocalDateTime EMPTY = buildTime(1970, 1, 1); + + public static DateTimeFormatter UTC_MS_WITH_XXX_OFFSET_FORMATTER = createFormatter(UTC_MS_WITH_XXX_OFFSET_PATTERN); + + /** + * 解析时间 + * + * 相比 {@link LocalDateTimeUtil#parse(CharSequence)} 方法来说,会尽量去解析,直到成功 + * + * @param time 时间 + * @return 时间字符串 + */ + public static LocalDateTime parse(String time) { + try { + return LocalDateTimeUtil.parse(time, DatePattern.NORM_DATE_PATTERN); + } catch (DateTimeParseException e) { + return LocalDateTimeUtil.parse(time); + } + } + + 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 month 月 + * @param day 日 + * @return 指定时间 + */ + public static LocalDateTime buildTime(int year, int month, int day) { + return LocalDateTime.of(year, month, day, 0, 0, 0); + } + + public static LocalDateTime[] buildBetweenTime(int year1, int month1, int day1, + int year2, int month2, int day2) { + return new LocalDateTime[]{buildTime(year1, month1, day1), buildTime(year2, month2, day2)}; + } + + /** + * 判指定断时间,是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param time 指定时间 + * @return 是否 + */ + public static boolean isBetween(LocalDateTime startTime, LocalDateTime endTime, Timestamp time) { + if (startTime == null || endTime == null || time == null) { + return false; + } + return LocalDateTimeUtil.isIn(LocalDateTimeUtil.of(time), startTime, endTime); + } + + /** + * 判指定断时间,是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param time 指定时间 + * @return 是否 + */ + public static boolean isBetween(LocalDateTime startTime, LocalDateTime endTime, String time) { + if (startTime == null || endTime == null || time == null) { + return false; + } + return LocalDateTimeUtil.isIn(parse(time), startTime, endTime); + } + + /** + * 判断当前时间是否在该时间范围内 + * + * @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 date 日期 + * @return 所在季度 + */ + public static int getQuarterOfYear(LocalDateTime date) { + return (date.getMonthValue() - 1) / 3 + 1; + } + + /** + * 获取指定日期到现在过了几天,如果指定日期在当前日期之后,获取结果为负 + * + * @param dateTime 日期 + * @return 相差天数 + */ + public static Long between(LocalDateTime dateTime) { + return LocalDateTimeUtil.between(dateTime, LocalDateTime.now(), ChronoUnit.DAYS); + } + + /** + * 获取今天的开始时间 + * + * @return 今天 + */ + public static LocalDateTime getToday() { + return LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + } + + /** + * 获取昨天的开始时间 + * + * @return 昨天 + */ + public static LocalDateTime getYesterday() { + return LocalDateTimeUtil.beginOfDay(LocalDateTime.now().minusDays(1)); + } + + /** + * 获取本月的开始时间 + * + * @return 本月 + */ + public static LocalDateTime getMonth() { + return beginOfMonth(LocalDateTime.now()); + } + + /** + * 获取本年的开始时间 + * + * @return 本年 + */ + public static LocalDateTime getYear() { + return LocalDateTime.now().with(TemporalAdjusters.firstDayOfYear()).with(LocalTime.MIN); + } + + public static List getDateRangeList(LocalDateTime startTime, + LocalDateTime endTime, + Integer interval) { + // 1.1 找到枚举 + DateIntervalEnum intervalEnum = DateIntervalEnum.valueOf(interval); + Assert.notNull(intervalEnum, "interval({}} 找不到对应的枚举", interval); + // 1.2 将时间对齐 + startTime = LocalDateTimeUtil.beginOfDay(startTime); + endTime = LocalDateTimeUtil.endOfDay(endTime); + + // 2. 循环,生成时间范围 + List timeRanges = new ArrayList<>(); + switch (intervalEnum) { + case HOUR: + while (startTime.isBefore(endTime)) { + timeRanges.add(new LocalDateTime[]{startTime, startTime.plusHours(1).minusNanos(1)}); + startTime = startTime.plusHours(1); + } + case DAY: + while (startTime.isBefore(endTime)) { + timeRanges.add(new LocalDateTime[]{startTime, startTime.plusDays(1).minusNanos(1)}); + startTime = startTime.plusDays(1); + } + break; + case WEEK: + while (startTime.isBefore(endTime)) { + LocalDateTime endOfWeek = startTime.with(DayOfWeek.SUNDAY).plusDays(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, endOfWeek}); + startTime = endOfWeek.plusNanos(1); + } + break; + case MONTH: + while (startTime.isBefore(endTime)) { + LocalDateTime endOfMonth = startTime.with(TemporalAdjusters.lastDayOfMonth()).plusDays(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, endOfMonth}); + startTime = endOfMonth.plusNanos(1); + } + break; + case QUARTER: + while (startTime.isBefore(endTime)) { + int quarterOfYear = getQuarterOfYear(startTime); + LocalDateTime quarterEnd = quarterOfYear == 4 + ? startTime.with(TemporalAdjusters.lastDayOfYear()).plusDays(1).minusNanos(1) + : startTime.withMonth(quarterOfYear * 3 + 1).withDayOfMonth(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, quarterEnd}); + startTime = quarterEnd.plusNanos(1); + } + break; + case YEAR: + while (startTime.isBefore(endTime)) { + LocalDateTime endOfYear = startTime.with(TemporalAdjusters.lastDayOfYear()).plusDays(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, endOfYear}); + startTime = endOfYear.plusNanos(1); + } + break; + default: + throw new IllegalArgumentException("Invalid interval: " + interval); + } + // 3. 兜底,最后一个时间,需要保持在 endTime 之前 + LocalDateTime[] lastTimeRange = CollUtil.getLast(timeRanges); + if (lastTimeRange != null) { + lastTimeRange[1] = endTime; + } + return timeRanges; + } + + /** + * 格式化时间范围 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param interval 时间间隔 + * @return 时间范围 + */ + public static String formatDateRange(LocalDateTime startTime, LocalDateTime endTime, Integer interval) { + // 1. 找到枚举 + DateIntervalEnum intervalEnum = DateIntervalEnum.valueOf(interval); + Assert.notNull(intervalEnum, "interval({}} 找不到对应的枚举", interval); + + // 2. 循环,生成时间范围 + switch (intervalEnum) { + case HOUR: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATETIME_MINUTE_PATTERN); + case DAY: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATE_PATTERN); + case WEEK: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATE_PATTERN) + + StrUtil.format("(第 {} 周)", LocalDateTimeUtil.weekOfYear(startTime)); + case MONTH: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_MONTH_PATTERN); + case QUARTER: + return StrUtil.format("{}-Q{}", startTime.getYear(), getQuarterOfYear(startTime)); + case YEAR: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_YEAR_PATTERN); + default: + throw new IllegalArgumentException("Invalid interval: " + interval); + } + } + + /** + * 将给定的 {@link LocalDateTime} 转换为自 Unix 纪元时间(1970-01-01T00:00:00Z)以来的秒数。 + * + * @param sourceDateTime 需要转换的本地日期时间,不能为空 + * @return 自 1970-01-01T00:00:00Z 起的秒数(epoch second) + * @throws NullPointerException 如果 {@code sourceDateTime} 为 {@code null} + * @throws DateTimeException 如果转换过程中发生时间超出范围或其他时间处理异常 + */ + public static Long toEpochSecond(LocalDateTime sourceDateTime) { + return TemporalAccessorUtil.toInstant(sourceDateTime).getEpochSecond(); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/http/HttpUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/http/HttpUtils.java new file mode 100644 index 0000000..9ad2def --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/http/HttpUtils.java @@ -0,0 +1,184 @@ +package cn.aagro.pp.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 cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import lombok.SneakyThrows; +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.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * HTTP 工具类 + * + * @author 芋道源码 + */ +public class HttpUtils { + + /** + * 编码 URL 参数 + * + * @param value 参数 + * @return 编码后的参数 + */ + @SneakyThrows + public static String encodeUtf8(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } + + @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(); + } + + public static String removeUrlQuery(String url) { + if (!StrUtil.contains(url, '?')) { + return url; + } + UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset()); + // 移除 query、fragment + builder.setQuery(null); + builder.setFragment(null); + return builder.build(); + } + + /** + * 拼接 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; + } + + /** + * HTTP post 请求,基于 {@link cn.hutool.http.HttpUtil} 实现 + * + * 为什么要封装该方法,因为 HttpUtil 默认封装的方法,没有允许传递 headers 参数 + * + * @param url URL + * @param headers 请求头 + * @param requestBody 请求体 + * @return 请求结果 + */ + public static String post(String url, Map headers, String requestBody) { + try (HttpResponse response = HttpRequest.post(url) + .addHeaders(headers) + .body(requestBody) + .execute()) { + return response.body(); + } + } + + /** + * HTTP get 请求,基于 {@link cn.hutool.http.HttpUtil} 实现 + * + * 为什么要封装该方法,因为 HttpUtil 默认封装的方法,没有允许传递 headers 参数 + * + * @param url URL + * @param headers 请求头 + * @return 请求结果 + */ + public static String get(String url, Map headers) { + try (HttpResponse response = HttpRequest.get(url) + .addHeaders(headers) + .execute()) { + return response.body(); + } + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/io/FileUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/io/FileUtils.java new file mode 100644 index 0000000..1dd1a3b --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/io/FileUtils.java @@ -0,0 +1,61 @@ +package cn.aagro.pp.framework.common.util.io; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import lombok.SneakyThrows; + +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; + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/io/IoUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/io/IoUtils.java new file mode 100644 index 0000000..f401a79 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/io/IoUtils.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/JsonUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/JsonUtils.java new file mode 100644 index 0000000..cc99328 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/JsonUtils.java @@ -0,0 +1,232 @@ +package cn.aagro.pp.framework.common.util.json; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import cn.aagro.pp.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer; +import cn.aagro.pp.framework.common.util.json.databind.TimestampLocalDateTimeSerializer; +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.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * JSON 工具类 + * + * @author 芋道源码 + */ +@Slf4j +public class JsonUtils { + + @Getter + 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 值 + // 解决 LocalDateTime 的序列化 + SimpleModule simpleModule = new JavaTimeModule() + .addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE) + .addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + objectMapper.registerModules(simpleModule); + } + + /** + * 初始化 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); + } + } + + public static T parseObject(byte[] text, Type type) { + if (ArrayUtil.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); + } + } + + /** + * 解析 JSON 字符串成指定类型的对象,如果解析失败,则返回 null + * + * @param text 字符串 + * @param typeReference 类型引用 + * @return 指定类型的对象 + */ + public static T parseObjectQuietly(String text, TypeReference typeReference) { + try { + return objectMapper.readValue(text, typeReference); + } catch (IOException e) { + return null; + } + } + + 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); + } + + /** + * 判断字符串是否为 JSON 类型的字符串 + * @param str 字符串 + */ + public static boolean isJsonObject(String str) { + return JSONUtil.isTypeJSONObject(str); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/NumberSerializer.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/NumberSerializer.java new file mode 100644 index 0000000..4e3e48f --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/NumberSerializer.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.framework.common.util.json.databind; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JacksonStdImpl; + +import java.io.IOException; + +/** + * Long 序列化规则 + * + * 会将超长 long 值转换为 string,解决前端 JavaScript 最大安全整数是 2^53-1 的问题 + * + * @author 星语 + */ +@JacksonStdImpl +public class NumberSerializer extends com.fasterxml.jackson.databind.ser.std.NumberSerializer { + + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final long MIN_SAFE_INTEGER = -9007199254740991L; + + public static final NumberSerializer INSTANCE = new NumberSerializer(Number.class); + + public NumberSerializer(Class rawType) { + super(rawType); + } + + @Override + public void serialize(Number value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 超出范围 序列化位字符串 + if (value.longValue() > MIN_SAFE_INTEGER && value.longValue() < MAX_SAFE_INTEGER) { + super.serialize(value, gen, serializers); + } else { + gen.writeString(value.toString()); + } + } +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java new file mode 100644 index 0000000..3fd8e57 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.common.util.json.databind; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 基于时间戳的 LocalDateTime 反序列化器 + * + * @author 老五 + */ +public class TimestampLocalDateTimeDeserializer extends JsonDeserializer { + + public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // 将 Long 时间戳,转换为 LocalDateTime 对象 + return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault()); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java new file mode 100644 index 0000000..7497f3c --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.framework.common.util.json.databind; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 基于时间戳的 LocalDateTime 序列化器 + * + * @author 老五 + */ +public class TimestampLocalDateTimeSerializer extends JsonSerializer { + + public static final TimestampLocalDateTimeSerializer INSTANCE = new TimestampLocalDateTimeSerializer(); + + @Override + public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 将 LocalDateTime 对象,转换为 Long 时间戳 + gen.writeNumber(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/monitor/TracerUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/monitor/TracerUtils.java new file mode 100644 index 0000000..7ef0b19 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/monitor/TracerUtils.java @@ -0,0 +1,30 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/number/MoneyUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/number/MoneyUtils.java new file mode 100644 index 0000000..f48845d --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/number/MoneyUtils.java @@ -0,0 +1,131 @@ +package cn.aagro.pp.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 { + + /** + * 金额的小数位数 + */ + private static final int PRICE_SCALE = 2; + + /** + * 百分比对应的 BigDecimal 对象 + */ + public static final BigDecimal PERCENT_100 = BigDecimal.valueOf(100); + + /** + * 计算百分比金额,四舍五入 + * + * @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 count 数量 + * @param percent 折扣(单位分),列如 60.2%,则传入 6020 + * @return 商品总价 + */ + public static Integer calculator(Integer price, Integer count, Integer percent) { + price = price * count; + if (percent == null) { + return price; + } + return MoneyUtils.calculateRatePriceFloor(price, (double) (percent / 100)); + } + + /** + * 计算百分比金额 + * + * @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(); + } + + /** + * 金额相乘,默认进行四舍五入 + * + * 位数:{@link #PRICE_SCALE} + * + * @param price 金额 + * @param count 数量 + * @return 金额相乘结果 + */ + public static BigDecimal priceMultiply(BigDecimal price, BigDecimal count) { + if (price == null || count == null) { + return null; + } + return price.multiply(count).setScale(PRICE_SCALE, RoundingMode.HALF_UP); + } + + /** + * 金额相乘(百分比),默认进行四舍五入 + * + * 位数:{@link #PRICE_SCALE} + * + * @param price 金额 + * @param percent 百分比 + * @return 金额相乘结果 + */ + public static BigDecimal priceMultiplyPercent(BigDecimal price, BigDecimal percent) { + if (price == null || percent == null) { + return null; + } + return price.multiply(percent).divide(PERCENT_100, PRICE_SCALE, RoundingMode.HALF_UP); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/number/NumberUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/number/NumberUtils.java new file mode 100644 index 0000000..7d37739 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/number/NumberUtils.java @@ -0,0 +1,78 @@ +package cn.aagro.pp.framework.common.util.number; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 数字的工具类,补全 {@link cn.hutool.core.util.NumberUtil} 的功能 + * + * @author 芋道源码 + */ +public class NumberUtils { + + public static Long parseLong(String str) { + return StrUtil.isNotEmpty(str) ? Long.valueOf(str) : null; + } + + public static Integer parseInt(String str) { + return StrUtil.isNotEmpty(str) ? Integer.valueOf(str) : null; + } + + public static boolean isAllNumber(List values) { + if (CollUtil.isEmpty(values)) { + return false; + } + for (String value : values) { + if (!NumberUtil.isNumber(value)) { + return false; + } + } + return true; + } + + /** + * 通过经纬度获取地球上两点之间的距离 + * + * 参考 <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; + } + + /** + * 提供精确的乘法运算 + * + * 和 hutool {@link NumberUtil#mul(BigDecimal...)} 的差别是,如果存在 null,则返回 null + * + * @param values 多个被乘值 + * @return 积 + */ + public static BigDecimal mul(BigDecimal... values) { + for (BigDecimal value : values) { + if (value == null) { + return null; + } + } + return NumberUtil.mul(values); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/BeanUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/BeanUtils.java new file mode 100644 index 0000000..959f9da --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/BeanUtils.java @@ -0,0 +1,69 @@ +package cn.aagro.pp.framework.common.util.object; + +import cn.hutool.core.bean.BeanUtil; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.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()); + } + + public static void copyProperties(Object source, Object target) { + if (source == null || target == null) { + return; + } + BeanUtil.copyProperties(source, target, false); + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/ObjectUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/ObjectUtils.java new file mode 100644 index 0000000..0a0c24b --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/ObjectUtils.java @@ -0,0 +1,67 @@ +package cn.aagro.pp.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); + } + + public static boolean isNotAllEmpty(Object... objs) { + return !ObjectUtil.isAllEmpty(objs); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/PageUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/PageUtils.java new file mode 100644 index 0000000..df58537 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/object/PageUtils.java @@ -0,0 +1,67 @@ +package cn.aagro.pp.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 cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.pojo.SortablePageParam; +import cn.aagro.pp.framework.common.pojo.SortingField; +import org.springframework.util.Assert; + +import static java.util.Collections.singletonList; + +/** + * {@link cn.aagro.pp.framework.common.pojo.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/package-info.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/package-info.java new file mode 100644 index 0000000..c756a95 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/package-info.java @@ -0,0 +1,7 @@ +/** + * 对于工具类的选择,优先查找 Hutool 中有没对应的方法 + * 如果没有,则自己封装对应的工具类,以 Utils 结尾,用于区分 + * + * ps:如果担心 Hutool 存在坑的问题,可以阅读 Hutool 的实现源码,以确保可靠性。并且,可以补充相关的单元测试。 + */ +package cn.aagro.pp.framework.common.util; diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/servlet/ServletUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/servlet/ServletUtils.java new file mode 100644 index 0000000..5575ee5 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/servlet/ServletUtils.java @@ -0,0 +1,123 @@ +package cn.aagro.pp.framework.common.util.servlet; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.ServletUtil; +import cn.aagro.pp.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) { + // 只有在 json 请求在读取,因为只有 CacheRequestBodyFilter 才会进行缓存,支持重复读取 + if (isJsonRequest(request)) { + return ServletUtil.getBody(request); + } + return null; + } + + public static byte[] getBodyBytes(HttpServletRequest request) { + // 只有在 json 请求在读取,因为只有 CacheRequestBodyFilter 才会进行缓存,支持重复读取 + if (isJsonRequest(request)) { + return ServletUtil.getBodyBytes(request); + } + return null; + } + + public static String getClientIP(HttpServletRequest request) { + return ServletUtil.getClientIP(request); + } + + public static Map getParamMap(HttpServletRequest request) { + return ServletUtil.getParamMap(request); + } + + public static Map getHeaderMap(HttpServletRequest request) { + return ServletUtil.getHeaderMap(request); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/spring/SpringExpressionUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/spring/SpringExpressionUtils.java new file mode 100644 index 0000000..3f0ff80 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/spring/SpringExpressionUtils.java @@ -0,0 +1,123 @@ +package cn.aagro.pp.framework.common.util.spring; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.EvaluationContext; +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; +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; + } + + /** + * 从 Bean 工厂,解析 EL 表达式的结果 + * + * @param expressionString EL 表达式 + * @return 执行界面 + */ + public static Object parseExpression(String expressionString) { + return parseExpression(expressionString, null); + } + + /** + * 从 Bean 工厂,解析 EL 表达式的结果 + * + * @param expressionString EL 表达式 + * @param variables 变量 + * @return 执行界面 + */ + public static Object parseExpression(String expressionString, Map variables) { + if (StrUtil.isBlank(expressionString)) { + return null; + } + Expression expression = EXPRESSION_PARSER.parseExpression(expressionString); + StandardEvaluationContext context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(SpringUtil.getApplicationContext())); + if (MapUtil.isNotEmpty(variables)) { + context.setVariables(variables); + } + return expression.getValue(context); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/spring/SpringUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/spring/SpringUtils.java new file mode 100644 index 0000000..9e9ee17 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/spring/SpringUtils.java @@ -0,0 +1,24 @@ +package cn.aagro.pp.framework.common.util.spring; + +import cn.hutool.extra.spring.SpringUtil; + +import java.util.Objects; + +/** + * Spring 工具类 + * + * @author 芋道源码 + */ +public class SpringUtils extends SpringUtil { + + /** + * 是否为生产环境 + * + * @return 是否生产环境 + */ + public static boolean isProd() { + String activeProfile = getActiveProfile(); + return Objects.equals("prod", activeProfile); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/string/StrUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/string/StrUtils.java new file mode 100644 index 0000000..8a5422f --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/string/StrUtils.java @@ -0,0 +1,107 @@ +package cn.aagro.pp.framework.common.util.string; + +import cn.hutool.core.text.StrPool; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import org.aspectj.lang.JoinPoint; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +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 Set splitToLongSet(String value) { + return splitToLongSet(value, StrPool.COMMA); + } + + public static Set splitToLongSet(String value, CharSequence separator) { + long[] longs = StrUtil.splitToLong(value, separator); + return Arrays.stream(longs).boxed().collect(Collectors.toSet()); + } + + 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")); + } + + /** + * 拼接方法的参数 + * + * 特殊:排除一些无法序列化的参数,如 ServletRequest、ServletResponse、MultipartFile + * + * @param joinPoint 连接点 + * @return 拼接后的参数 + */ + public static String joinMethodArgs(JoinPoint joinPoint) { + Object[] args = joinPoint.getArgs(); + if (ArrayUtil.isEmpty(args)) { + return ""; + } + return ArrayUtil.join(args, ",", item -> { + if (item == null) { + return ""; + } + // 讨论可见:https://t.zsxq.com/XUJVk、https://t.zsxq.com/MnKcL + String clazzName = item.getClass().getName(); + if (StrUtil.startWithAny(clazzName, "javax.servlet", "jakarta.servlet", "org.springframework.web")) { + return ""; + } + return item; + }); + } + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/validation/ValidationUtils.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/validation/ValidationUtils.java new file mode 100644 index 0000000..a3432fa --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/util/validation/ValidationUtils.java @@ -0,0 +1,55 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnum.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnum.java new file mode 100644 index 0000000..08405eb --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnum.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.common.validation; + +import cn.aagro.pp.framework.common.core.ArrayValuable; + +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 实现 ArrayValuable 接口的类 + */ + Class> value(); + + String message() default "必须在指定范围 {value}"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnumCollectionValidator.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnumCollectionValidator.java new file mode 100644 index 0000000..d567dac --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnumCollectionValidator.java @@ -0,0 +1,44 @@ +package cn.aagro.pp.framework.common.validation; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.common.core.ArrayValuable; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class InEnumCollectionValidator implements ConstraintValidator> { + + private List values; + + @Override + public void initialize(InEnum annotation) { + ArrayValuable[] values = annotation.value().getEnumConstants(); + if (values.length == 0) { + this.values = Collections.emptyList(); + } else { + this.values = Arrays.asList(values[0].array()); + } + } + + @Override + public boolean isValid(Collection list, ConstraintValidatorContext context) { + if (list == null) { + return true; + } + // 校验通过 + if (CollUtil.containsAll(values, list)) { + return true; + } + // 校验不通过,自定义提示语句 + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate() + .replaceAll("\\{value}", CollUtil.join(list, ","))).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnumValidator.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnumValidator.java new file mode 100644 index 0000000..ee84cbc --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/InEnumValidator.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.framework.common.validation; + +import cn.aagro.pp.framework.common.core.ArrayValuable; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class InEnumValidator implements ConstraintValidator { + + private List values; + + @Override + public void initialize(InEnum annotation) { + ArrayValuable[] values = annotation.value().getEnumConstants(); + if (values.length == 0) { + this.values = Collections.emptyList(); + } else { + this.values = Arrays.asList(values[0].array()); + } + } + + @Override + public boolean isValid(Object value, ConstraintValidatorContext context) { + // 为空时,默认不校验,即认为通过 + if (value == null) { + return true; + } + // 校验通过 + if (values.contains(value)) { + return true; + } + // 校验不通过,自定义提示语句 + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate() + .replaceAll("\\{value}", values.toString())).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/Mobile.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/Mobile.java new file mode 100644 index 0000000..7cd96eb --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/Mobile.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/MobileValidator.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/MobileValidator.java new file mode 100644 index 0000000..3e3de59 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/MobileValidator.java @@ -0,0 +1,25 @@ +package cn.aagro.pp.framework.common.validation; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/Telephone.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/Telephone.java new file mode 100644 index 0000000..9e7438d --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/Telephone.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/TelephoneValidator.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/TelephoneValidator.java new file mode 100644 index 0000000..11260b8 --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/TelephoneValidator.java @@ -0,0 +1,25 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/package-info.java b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/package-info.java new file mode 100644 index 0000000..f99b47f --- /dev/null +++ b/aagro-framework/aagro-common/src/main/java/cn/aagro/pp/framework/common/validation/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 Hibernate Validator 实现参数校验 + */ +package cn.aagro.pp.framework.common.validation; diff --git a/aagro-framework/aagro-common/src/test/java/cn/aagro/pp/framework/common/util/collection/CollectionUtilsTest.java b/aagro-framework/aagro-common/src/test/java/cn/aagro/pp/framework/common/util/collection/CollectionUtilsTest.java new file mode 100644 index 0000000..f959a29 --- /dev/null +++ b/aagro-framework/aagro-common/src/test/java/cn/aagro/pp/framework/common/util/collection/CollectionUtilsTest.java @@ -0,0 +1,64 @@ +package cn.aagro.pp.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/aagro-framework/aagro-common/《芋道 Spring Boot 参数校验 Validation 入门》.md b/aagro-framework/aagro-common/《芋道 Spring Boot 参数校验 Validation 入门》.md new file mode 100644 index 0000000..071e4d8 --- /dev/null +++ b/aagro-framework/aagro-common/《芋道 Spring Boot 参数校验 Validation 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/pom.xml b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/pom.xml new file mode 100644 index 0000000..f156b60 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/pom.xml @@ -0,0 +1,45 @@ + + + + aagro-framework + cn.aagro.gg + ${revision} + + 4.0.0 + aagro-spring-boot-starter-biz-data-permission + jar + + ${project.artifactId} + 数据权限 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + cn.aagro.gg + aagro-spring-boot-starter-security + true + + + + + cn.aagro.gg + aagro-spring-boot-starter-mybatis + + + + + cn.aagro.gg + aagro-spring-boot-starter-test + test + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/config/AagroDataPermissionAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/config/AagroDataPermissionAutoConfiguration.java new file mode 100644 index 0000000..8a2ab52 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/config/AagroDataPermissionAutoConfiguration.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.datapermission.config; + +import cn.aagro.pp.framework.datapermission.core.aop.DataPermissionAnnotationAdvisor; +import cn.aagro.pp.framework.datapermission.core.db.DataPermissionRuleHandler; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRule; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRuleFactory; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRuleFactoryImpl; +import cn.aagro.pp.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * 数据权限的自动配置类 + * + * @author 芋道源码 + */ +@AutoConfiguration +public class AagroDataPermissionAutoConfiguration { + + @Bean + public DataPermissionRuleFactory dataPermissionRuleFactory(List rules) { + return new DataPermissionRuleFactoryImpl(rules); + } + + @Bean + public DataPermissionRuleHandler dataPermissionRuleHandler(MybatisPlusInterceptor interceptor, + DataPermissionRuleFactory ruleFactory) { + // 创建 DataPermissionInterceptor 拦截器 + DataPermissionRuleHandler handler = new DataPermissionRuleHandler(ruleFactory); + DataPermissionInterceptor inner = new DataPermissionInterceptor(handler); + // 添加到 interceptor 中 + // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定 + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return handler; + } + + @Bean + public DataPermissionAnnotationAdvisor dataPermissionAnnotationAdvisor() { + return new DataPermissionAnnotationAdvisor(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/config/AagroDeptDataPermissionAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/config/AagroDeptDataPermissionAutoConfiguration.java new file mode 100644 index 0000000..4f18e41 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/config/AagroDeptDataPermissionAutoConfiguration.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.framework.datapermission.config; + +import cn.aagro.pp.framework.common.biz.system.permission.PermissionCommonApi; +import cn.aagro.pp.framework.datapermission.core.rule.dept.DeptDataPermissionRule; +import cn.aagro.pp.framework.datapermission.core.rule.dept.DeptDataPermissionRuleCustomizer; +import cn.aagro.pp.framework.security.core.LoginUser; +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 AagroDeptDataPermissionAutoConfiguration { + + @Bean + public DeptDataPermissionRule deptDataPermissionRule(PermissionCommonApi permissionApi, + List customizers) { + // 创建 DeptDataPermissionRule 对象 + DeptDataPermissionRule rule = new DeptDataPermissionRule(permissionApi); + // 补全表配置 + customizers.forEach(customizer -> customizer.customize(rule)); + return rule; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/annotation/DataPermission.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/annotation/DataPermission.java new file mode 100644 index 0000000..d96f669 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/annotation/DataPermission.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.datapermission.core.annotation; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationAdvisor.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationAdvisor.java new file mode 100644 index 0000000..4f94f87 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationAdvisor.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.framework.datapermission.core.aop; + +import cn.aagro.pp.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 cn.aagro.pp.framework.datapermission.core.annotation.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationInterceptor.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationInterceptor.java new file mode 100644 index 0000000..fcabba1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationInterceptor.java @@ -0,0 +1,72 @@ +package cn.aagro.pp.framework.datapermission.core.aop; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionContextHolder.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionContextHolder.java new file mode 100644 index 0000000..149a47c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionContextHolder.java @@ -0,0 +1,72 @@ +package cn.aagro.pp.framework.datapermission.core.aop; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/db/DataPermissionRuleHandler.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/db/DataPermissionRuleHandler.java new file mode 100644 index 0000000..0873e87 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/db/DataPermissionRuleHandler.java @@ -0,0 +1,64 @@ +package cn.aagro.pp.framework.datapermission.core.db; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRule; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRuleFactory; +import cn.aagro.pp.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler; +import lombok.RequiredArgsConstructor; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.schema.Table; + +import java.util.List; + +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck; + +/** + * 基于 {@link DataPermissionRule} 的数据权限处理器 + * + * 它的底层,是基于 MyBatis Plus 的 数据权限插件 + * 核心原理:它会在 SQL 执行前拦截 SQL 语句,并根据用户权限动态添加权限相关的 SQL 片段。这样,只有用户有权限访问的数据才会被查询出来 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class DataPermissionRuleHandler implements MultiDataPermissionHandler { + + private final DataPermissionRuleFactory ruleFactory; + + @Override + public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) { + // 特殊:跨租户访问 + if (skipPermissionCheck()) { + return null; + } + + // 获得 Mapper 对应的数据权限的规则 + List rules = ruleFactory.getDataPermissionRule(mappedStatementId); + if (CollUtil.isEmpty(rules)) { + return null; + } + + // 生成条件 + Expression allExpression = null; + for (DataPermissionRule rule : rules) { + // 判断表名是否匹配 + String tableName = MyBatisUtils.getTableName(table); + if (!rule.getTableNames().contains(tableName)) { + continue; + } + + // 单条规则的条件 + Expression oneExpress = rule.getExpression(tableName, table.getAlias()); + if (oneExpress == null) { + continue; + } + // 拼接到 allExpression 中 + allExpression = allExpression == null ? oneExpress + : new AndExpression(allExpression, oneExpress); + } + return allExpression; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRule.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRule.java new file mode 100644 index 0000000..6d6265f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRule.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactory.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactory.java new file mode 100644 index 0000000..7be818d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactory.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java new file mode 100644 index 0000000..64278e4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactoryImpl.java @@ -0,0 +1,62 @@ +package cn.aagro.pp.framework.datapermission.core.rule; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.aagro.pp.framework.datapermission.core.annotation.DataPermission; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java new file mode 100644 index 0000000..b8905ce --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java @@ -0,0 +1,210 @@ +package cn.aagro.pp.framework.datapermission.core.rule.dept; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.biz.system.permission.PermissionCommonApi; +import cn.aagro.pp.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; +import cn.aagro.pp.framework.common.enums.UserTypeEnum; +import cn.aagro.pp.framework.common.util.collection.CollectionUtils; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRule; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.framework.mybatis.core.util.MyBatisUtils; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.NullValue; +import net.sf.jsqlparser.expression.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 net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; + +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 不进行修改,则会导致用户看不到之前的数据。【aagro-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 PermissionCommonApi 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()); + 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 ParenthesedExpressionList(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), + // Parenthesis 的目的,是提供 (1,2,3) 的 () 左右括号 + new ParenthesedExpressionList(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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRuleCustomizer.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRuleCustomizer.java new file mode 100644 index 0000000..1299c2e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRuleCustomizer.java @@ -0,0 +1,20 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/package-info.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/package-info.java new file mode 100644 index 0000000..faa09f7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/rule/dept/package-info.java @@ -0,0 +1,6 @@ +/** + * 基于部门的数据权限规则 + * + * @author 芋道源码 + */ +package cn.aagro.pp.framework.datapermission.core.rule.dept; diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/util/DataPermissionUtils.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/util/DataPermissionUtils.java new file mode 100644 index 0000000..90d0956 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/core/util/DataPermissionUtils.java @@ -0,0 +1,73 @@ +package cn.aagro.pp.framework.datapermission.core.util; + +import cn.aagro.pp.framework.datapermission.core.annotation.DataPermission; +import cn.aagro.pp.framework.datapermission.core.aop.DataPermissionContextHolder; +import lombok.SneakyThrows; + +import java.util.concurrent.Callable; + +/** + * 数据权限 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) { + addDisableDataPermission(); + try { + // 执行 runnable + runnable.run(); + } finally { + removeDataPermission(); + } + } + + /** + * 忽略数据权限,执行对应的逻辑 + * + * @param callable 逻辑 + * @return 执行结果 + */ + @SneakyThrows + public static T executeIgnore(Callable callable) { + addDisableDataPermission(); + try { + // 执行 callable + return callable.call(); + } finally { + removeDataPermission(); + } + } + + /** + * 添加忽略数据权限 + */ + public static void addDisableDataPermission(){ + DataPermission dataPermission = getDisableDataPermissionDisable(); + DataPermissionContextHolder.add(dataPermission); + } + + public static void removeDataPermission(){ + DataPermissionContextHolder.remove(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/package-info.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/package-info.java new file mode 100644 index 0000000..f0391ae --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/java/cn/aagro/pp/framework/datapermission/package-info.java @@ -0,0 +1,4 @@ +/** + * 基于 JSqlParser 解析 SQL,增加数据权限的 WHERE 条件 + */ +package cn.aagro.pp.framework.datapermission; diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..c1d3def --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.aagro.pp.framework.datapermission.config.AagroDataPermissionAutoConfiguration +cn.aagro.pp.framework.datapermission.config.AagroDeptDataPermissionAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java new file mode 100644 index 0000000..62c83b9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionAnnotationInterceptorTest.java @@ -0,0 +1,108 @@ +package cn.aagro.pp.framework.datapermission.core.aop; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.datapermission.core.annotation.DataPermission; +import cn.aagro.pp.framework.test.core.ut.BaseMockitoUnitTest; +import org.aopalliance.intercept.MethodInvocation; +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()); + 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()); + 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()); + 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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionContextHolderTest.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionContextHolderTest.java new file mode 100644 index 0000000..bf50038 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/aop/DataPermissionContextHolderTest.java @@ -0,0 +1,66 @@ +package cn.aagro.pp.framework.datapermission.core.aop; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/db/DataPermissionRuleHandlerTest.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/db/DataPermissionRuleHandlerTest.java new file mode 100644 index 0000000..1b2c5fd --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/db/DataPermissionRuleHandlerTest.java @@ -0,0 +1,540 @@ +package cn.aagro.pp.framework.datapermission.core.db; + +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRule; +import cn.aagro.pp.framework.datapermission.core.rule.DataPermissionRuleFactory; +import cn.aagro.pp.framework.mybatis.core.util.MyBatisUtils; +import cn.aagro.pp.framework.test.core.ut.BaseMockitoUnitTest; +import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor; +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.expression.operators.relational.ParenthesedExpressionList; +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 cn.aagro.pp.framework.common.util.collection.SetUtils.asSet; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * {@link DataPermissionRuleHandler} 的单元测试 + * 主要复用了 MyBatis Plus 的 TenantLineInnerInterceptorTest 的单元测试 + * 不过它的单元测试不是很规范,考虑到是复用的,所以暂时不进行修改~ + * + * @author 芋道源码 + */ +public class DataPermissionRuleHandlerTest extends BaseMockitoUnitTest { + + @InjectMocks + private DataPermissionRuleHandler handler; + + @Mock + private DataPermissionRuleFactory ruleFactory; + + private DataPermissionInterceptor interceptor; + + @BeforeEach + public void setUp() { + interceptor = new DataPermissionInterceptor(handler); + + // 租户的数据权限规则 + DataPermissionRule tenantRule = new DataPermissionRule() { + + private static final String COLUMN = "tenant_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, new ParenthesedExpressionList((values))); + } + + }; + // 设置到上下文 + when(ruleFactory.getDataPermissionRule(any())).thenReturn(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 e.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java new file mode 100644 index 0000000..0674f94 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/rule/DataPermissionRuleFactoryImplTest.java @@ -0,0 +1,145 @@ +package cn.aagro.pp.framework.datapermission.core.rule; + +import cn.aagro.pp.framework.datapermission.core.annotation.DataPermission; +import cn.aagro.pp.framework.datapermission.core.aop.DataPermissionContextHolder; +import cn.aagro.pp.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 cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java new file mode 100644 index 0000000..85fc90c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java @@ -0,0 +1,238 @@ +package cn.aagro.pp.framework.datapermission.core.rule.dept; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.aagro.pp.framework.common.biz.system.permission.PermissionCommonApi; +import cn.aagro.pp.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; +import cn.aagro.pp.framework.common.enums.UserTypeEnum; +import cn.aagro.pp.framework.common.util.collection.SetUtils; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import cn.aagro.pp.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.Mock; +import org.mockito.MockedStatic; + +import java.util.Map; + +import static cn.aagro.pp.framework.datapermission.core.rule.dept.DeptDataPermissionRule.EXPRESSION_NULL; +import static cn.aagro.pp.framework.test.core.util.RandomUtils.randomPojo; +import static cn.aagro.pp.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 PermissionCommonApi 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(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(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(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(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(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(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(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/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/util/DataPermissionUtilsTest.java b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/util/DataPermissionUtilsTest.java new file mode 100644 index 0000000..414d6a9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-data-permission/src/test/java/cn/aagro/pp/framework/datapermission/core/util/DataPermissionUtilsTest.java @@ -0,0 +1,15 @@ +package cn.aagro.pp.framework.datapermission.core.util; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-ip/pom.xml b/aagro-framework/aagro-spring-boot-starter-biz-ip/pom.xml new file mode 100644 index 0000000..f8939ff --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/pom.xml @@ -0,0 +1,54 @@ + + + + aagro-framework + cn.aagro.gg + ${revision} + + 4.0.0 + aagro-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 实现 + + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.lionsoul + ip2region + + + + org.projectlombok + lombok + + + + org.slf4j + slf4j-api + provided + + + + + cn.aagro.gg + aagro-spring-boot-starter-test + test + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/Area.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/Area.java new file mode 100644 index 0000000..1c4b79a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/Area.java @@ -0,0 +1,61 @@ +package cn.aagro.pp.framework.ip.core; + +import cn.aagro.pp.framework.ip.core.enums.AreaTypeEnum; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.List; + +/** + * 区域节点,包括国家、省份、城市、地区等信息 + * + * 数据可见 resources/area.csv 文件 + * + * @author 芋道源码 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@ToString(exclude = {"parent"}) // 参见 https://gitee.com/aagrocode/aagro-cloud-mini/pulls/2 原因 +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; + + /** + * 父节点 + */ + @JsonManagedReference + private Area parent; + /** + * 子节点 + */ + @JsonBackReference + private List children; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/enums/AreaTypeEnum.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/enums/AreaTypeEnum.java new file mode 100644 index 0000000..66eb261 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/enums/AreaTypeEnum.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.framework.ip.core.enums; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 区域类型枚举 + * + * @author 芋道源码 + */ +@AllArgsConstructor +@Getter +public enum AreaTypeEnum implements ArrayValuable { + + COUNTRY(1, "国家"), + PROVINCE(2, "省份"), + CITY(3, "城市"), + DISTRICT(4, "地区"), // 县、镇、区等 + ; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(AreaTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/utils/AreaUtils.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/utils/AreaUtils.java new file mode 100644 index 0000000..3e8c9f0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/utils/AreaUtils.java @@ -0,0 +1,214 @@ +package cn.aagro.pp.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 cn.aagro.pp.framework.common.util.object.ObjectUtils; +import cn.aagro.pp.framework.ip.core.Area; +import cn.aagro.pp.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 cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.findFirst; + +/** + * 区域工具类 + * + * @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 pathStr 区域路径,例如说:河南省/石家庄市/新华区 + * @return 区域 + */ + public static Area parseArea(String pathStr) { + String[] paths = pathStr.split("/"); + Area area = null; + for (String path : paths) { + if (area == null) { + area = findFirst(areas.values(), item -> item.getName().equals(path)); + } else { + area = findFirst(area.getChildren(), item -> item.getName().equals(path)); + } + } + return area; + } + + /** + * 获取所有节点的全路径名称如:河南省/石家庄市/新华区 + * + * @param areas 地区树 + * @return 所有节点的全路径名称 + */ + public static List getAreaNodePathList(List areas) { + List paths = new ArrayList<>(); + areas.forEach(area -> getAreaNodePathList(area, "", paths)); + return paths; + } + + /** + * 构建一棵树的所有节点的全路径名称,并将其存储为 "祖先/父级/子级" 的形式 + * + * @param node 父节点 + * @param path 全路径名称 + * @param paths 全路径名称列表,省份/城市/地区 + */ + private static void getAreaNodePathList(Area node, String path, List paths) { + if (node == null) { + return; + } + // 构建当前节点的路径 + String currentPath = path.isEmpty() ? node.getName() : path + "/" + node.getName(); + paths.add(currentPath); + // 递归遍历子节点 + for (Area child : node.getChildren()) { + getAreaNodePathList(child, currentPath, paths); + } + } + + /** + * 格式化区域 + * + * @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/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/utils/IPUtils.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/utils/IPUtils.java new file mode 100644 index 0000000..caf724a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/core/utils/IPUtils.java @@ -0,0 +1,87 @@ +package cn.aagro.pp.framework.ip.core.utils; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/package-info.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/framework/ip/package-info.java new file mode 100644 index 0000000..7d40de6 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/java/cn/aagro/pp/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 cn.aagro.pp.framework.ip; diff --git a/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/resources/area.csv b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/resources/area.csv new file mode 100644 index 0000000..0dd830e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/resources/area.csv @@ -0,0 +1,3662 @@ +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 +442001,石岐街道,4,442000 +442002,东区街道,4,442000 +442003,中山港街道,4,442000 +442004,西区街道,4,442000 +442005,南区街道,4,442000 +442006,五桂山街道,4,442000 +442007,民众街道,4,442000 +442008,南朗街道,4,442000 +442009,黄圃镇,4,442000 +442010,东凤镇,4,442000 +442011,古镇镇,4,442000 +442012,沙溪镇,4,442000 +442013,坦洲镇,4,442000 +442014,港口镇,4,442000 +442015,三角镇,4,442000 +442016,横栏镇,4,442000 +442017,南头镇,4,442000 +442018,阜沙镇,4,442000 +442019,三乡镇,4,442000 +442020,板芙镇,4,442000 +442021,大涌镇,4,442000 +442022,神湾镇,4,442000 +442023,小榄镇,4,442000 +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/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/resources/ip2region.xdb b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/resources/ip2region.xdb new file mode 100644 index 0000000..58596a5 Binary files /dev/null and b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/main/resources/ip2region.xdb differ diff --git a/aagro-framework/aagro-spring-boot-starter-biz-ip/src/test/java/cn/aagro/pp/framework/ip/core/utils/AreaUtilsTest.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/test/java/cn/aagro/pp/framework/ip/core/utils/AreaUtilsTest.java new file mode 100644 index 0000000..7743f01 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/test/java/cn/aagro/pp/framework/ip/core/utils/AreaUtilsTest.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.framework.ip.core.utils; + + +import cn.aagro.pp.framework.ip.core.Area; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-ip/src/test/java/cn/aagro/pp/framework/ip/core/utils/IPUtilsTest.java b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/test/java/cn/aagro/pp/framework/ip/core/utils/IPUtilsTest.java new file mode 100644 index 0000000..e746ca4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-ip/src/test/java/cn/aagro/pp/framework/ip/core/utils/IPUtilsTest.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.ip.core.utils; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-biz-tenant/pom.xml b/aagro-framework/aagro-spring-boot-starter-biz-tenant/pom.xml new file mode 100644 index 0000000..7cbcf39 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/pom.xml @@ -0,0 +1,83 @@ + + + + aagro-framework + cn.aagro.gg + ${revision} + + 4.0.0 + aagro-spring-boot-starter-biz-tenant + jar + + ${project.artifactId} + 多租户 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + cn.aagro.gg + aagro-spring-boot-starter-security + + + + + cn.aagro.gg + aagro-spring-boot-starter-mybatis + + + + cn.aagro.gg + aagro-spring-boot-starter-redis + + + + + cn.aagro.gg + aagro-spring-boot-starter-job + + + + + cn.aagro.gg + aagro-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 + + + + + cn.aagro.gg + aagro-spring-boot-starter-test + test + + + + + com.google.guava + guava + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/config/AagroTenantAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/config/AagroTenantAutoConfiguration.java new file mode 100644 index 0000000..8757f43 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/config/AagroTenantAutoConfiguration.java @@ -0,0 +1,201 @@ +package cn.aagro.pp.framework.tenant.config; + +import cn.aagro.pp.framework.common.biz.system.tenant.TenantCommonApi; +import cn.aagro.pp.framework.common.enums.WebFilterOrderEnum; +import cn.aagro.pp.framework.mybatis.core.util.MyBatisUtils; +import cn.aagro.pp.framework.redis.config.AagroCacheProperties; +import cn.aagro.pp.framework.security.core.service.SecurityFrameworkService; +import cn.aagro.pp.framework.tenant.core.aop.TenantIgnore; +import cn.aagro.pp.framework.tenant.core.aop.TenantIgnoreAspect; +import cn.aagro.pp.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.aagro.pp.framework.tenant.core.job.TenantJobAspect; +import cn.aagro.pp.framework.tenant.core.mq.rabbitmq.TenantRabbitMQInitializer; +import cn.aagro.pp.framework.tenant.core.mq.redis.TenantRedisMessageInterceptor; +import cn.aagro.pp.framework.tenant.core.mq.rocketmq.TenantRocketMQInitializer; +import cn.aagro.pp.framework.tenant.core.redis.TenantRedisCacheManager; +import cn.aagro.pp.framework.tenant.core.security.TenantSecurityWebFilter; +import cn.aagro.pp.framework.tenant.core.service.TenantFrameworkService; +import cn.aagro.pp.framework.tenant.core.service.TenantFrameworkServiceImpl; +import cn.aagro.pp.framework.tenant.core.web.TenantContextWebFilter; +import cn.aagro.pp.framework.tenant.core.web.TenantVisitContextInterceptor; +import cn.aagro.pp.framework.web.config.WebProperties; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +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.ApplicationContext; +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.web.method.HandlerMethod; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPattern; + +import javax.annotation.Resource; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "aagro.tenant", value = "enable", matchIfMissing = true) // 允许使用 aagro.tenant.enable=false 禁用多租户 +@EnableConfigurationProperties(TenantProperties.class) +public class AagroTenantAutoConfiguration { + + @Resource + private ApplicationContext applicationContext; + + @Bean + public TenantFrameworkService tenantFrameworkService(TenantCommonApi tenantApi) { + return new TenantFrameworkServiceImpl(tenantApi); + } + + // ========== AOP ========== + + @Bean + public TenantIgnoreAspect tenantIgnoreAspect() { + return new TenantIgnoreAspect(); + } + + // ========== DB ========== + + @Bean + public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties, + MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties)); + // 添加到 interceptor 中 + // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定 + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + // ========== WEB ========== + + @Bean + public FilterRegistrationBean tenantContextWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantContextWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER); + return registrationBean; + } + + @Bean + public TenantVisitContextInterceptor tenantVisitContextInterceptor(TenantProperties tenantProperties, + SecurityFrameworkService securityFrameworkService) { + return new TenantVisitContextInterceptor(tenantProperties, securityFrameworkService); + } + + @Bean + public WebMvcConfigurer tenantWebMvcConfigurer(TenantProperties tenantProperties, + TenantVisitContextInterceptor tenantVisitContextInterceptor) { + return new WebMvcConfigurer() { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantVisitContextInterceptor) + .excludePathPatterns(tenantProperties.getIgnoreVisitUrls().toArray(new String[0])); + } + }; + } + + // ========== Security ========== + + @Bean + public FilterRegistrationBean tenantSecurityWebFilter(TenantProperties tenantProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + TenantFrameworkService tenantFrameworkService) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(), + globalExceptionHandler, tenantFrameworkService)); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER); + return registrationBean; + } + + /** + * 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中 + * + * @return 忽略租户的 URL 集合 + */ + private Set getTenantIgnoreUrls() { + Set ignoreUrls = new HashSet<>(); + // 获得接口对应的 HandlerMethod 集合 + RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) + applicationContext.getBean("requestMappingHandlerMapping"); + Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); + // 获得有 @TenantIgnore 注解的接口 + for (Map.Entry entry : handlerMethodMap.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!handlerMethod.hasMethodAnnotation(TenantIgnore.class) // 方法级 + && !handlerMethod.getBeanType().isAnnotationPresent(TenantIgnore.class)) { // 接口级 + continue; + } + // 添加到忽略的 URL 中 + if (entry.getKey().getPatternsCondition() != null) { + ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns()); + } + if (entry.getKey().getPathPatternsCondition() != null) { + ignoreUrls.addAll( + convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); + } + } + return ignoreUrls; + } + + // ========== MQ ========== + + @Bean + public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() { + return new TenantRedisMessageInterceptor(); + } + + @Bean + @ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") + public TenantRabbitMQInitializer tenantRabbitMQInitializer() { + return new TenantRabbitMQInitializer(); + } + + @Bean + @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate") + public TenantRocketMQInitializer tenantRocketMQInitializer() { + return new TenantRocketMQInitializer(); + } + + // ========== Job ========== + + @Bean + public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) { + return new TenantJobAspect(tenantFrameworkService); + } + + // ========== Redis ========== + + @Bean + @Primary // 引入租户时,tenantRedisCacheManager 为主 Bean + public RedisCacheManager tenantRedisCacheManager(RedisTemplate redisTemplate, + RedisCacheConfiguration redisCacheConfiguration, + AagroCacheProperties aagroCacheProperties, + TenantProperties tenantProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(aagroCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/config/TenantProperties.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/config/TenantProperties.java new file mode 100644 index 0000000..c871c30 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/config/TenantProperties.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.framework.tenant.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * 多租户配置 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "aagro.tenant") +@Data +public class TenantProperties { + + /** + * 租户是否开启 + */ + private static final Boolean ENABLE_DEFAULT = true; + + /** + * 是否开启 + */ + private Boolean enable = ENABLE_DEFAULT; + + /** + * 需要忽略多租户的请求 + * + * 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API! + */ + private Set ignoreUrls = new HashSet<>(); + + /** + * 需要忽略跨(切换)租户访问的请求 + * + * 原因是:某些接口,访问的是个人信息,在跨租户是获取不到的! + */ + private Set ignoreVisitUrls = Collections.emptySet(); + + /** + * 需要忽略多租户的表 + * + * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 + */ + private Set ignoreTables = Collections.emptySet(); + + /** + * 需要忽略多租户的 Spring Cache 缓存 + * + * 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 + */ + private Set ignoreCaches = Collections.emptySet(); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/aop/TenantIgnore.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/aop/TenantIgnore.java new file mode 100644 index 0000000..d77b60d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/aop/TenantIgnore.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.tenant.core.aop; + +import cn.aagro.pp.framework.tenant.config.TenantProperties; + +import java.lang.annotation.*; + +/** + * 忽略租户,标记指定方法不进行租户的自动过滤 + * + * 注意,只有 DB 的场景会过滤,其它场景暂时不过滤: + * 1、Redis 场景:因为是基于 Key 实现多租户的能力,所以忽略没有意义,不像 DB 是一个 column 实现的 + * 2、MQ 场景:有点难以抉择,目前可以通过 Consumer 手动在消费的方法上,添加 @TenantIgnore 进行忽略 + * + * 特殊: + * 1、如果添加到 Controller 类上,则该 URL 自动添加到 {@link TenantProperties#getIgnoreUrls()} 中 + * 2、如果添加到 DO 实体类上,则它对应的表名“相当于”自动添加到 {@link TenantProperties#getIgnoreTables()} 中 + * + * @author 芋道源码 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface TenantIgnore { + + /** + * 是否开启忽略租户,默认为 true 开启 + * + * 支持 Spring EL 表达式,如果返回 true 则满足条件,进行租户的忽略 + */ + String enable() default "true"; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/aop/TenantIgnoreAspect.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/aop/TenantIgnoreAspect.java new file mode 100644 index 0000000..00fae6f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/aop/TenantIgnoreAspect.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.framework.tenant.core.aop; + +import cn.aagro.pp.framework.common.util.spring.SpringExpressionUtils; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import cn.aagro.pp.framework.tenant.core.util.TenantUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +/** + * 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。 + * 例如说,一个定时任务,读取所有数据,进行处理。 + * 又例如说,读取所有数据,进行缓存。 + * + * 整体逻辑的实现,和 {@link TenantUtils#executeIgnore(Runnable)} 需要保持一致 + * + * @author 芋道源码 + */ +@Aspect +@Slf4j +public class TenantIgnoreAspect { + + @Around("@annotation(tenantIgnore)") + public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable { + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + // 计算条件,满足的情况下,才进行忽略 + Object enable = SpringExpressionUtils.parseExpression(tenantIgnore.enable()); + if (Boolean.TRUE.equals(enable)) { + TenantContextHolder.setIgnore(true); + } + + // 执行逻辑 + return joinPoint.proceed(); + } finally { + TenantContextHolder.setIgnore(oldIgnore); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/context/TenantContextHolder.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/context/TenantContextHolder.java new file mode 100644 index 0000000..2579f84 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/context/TenantContextHolder.java @@ -0,0 +1,68 @@ +package cn.aagro.pp.framework.tenant.core.context; + +import cn.aagro.pp.framework.common.enums.DocumentEnum; +import com.alibaba.ttl.TransmittableThreadLocal; + +/** + * 多租户上下文 Holder + * + * @author 芋道源码 + */ +public class TenantContextHolder { + + /** + * 当前租户编号 + */ + private static final ThreadLocal TENANT_ID = new TransmittableThreadLocal<>(); + + /** + * 是否忽略租户 + */ + private static final ThreadLocal IGNORE = new TransmittableThreadLocal<>(); + + /** + * 获得租户编号 + * + * @return 租户编号 + */ + public static Long getTenantId() { + return TENANT_ID.get(); + } + + /** + * 获得租户编号。如果不存在,则抛出 NullPointerException 异常 + * + * @return 租户编号 + */ + public static Long getRequiredTenantId() { + Long tenantId = getTenantId(); + if (tenantId == null) { + throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:" + + DocumentEnum.TENANT.getUrl()); + } + return tenantId; + } + + public static void setTenantId(Long tenantId) { + TENANT_ID.set(tenantId); + } + + public static void setIgnore(Boolean ignore) { + IGNORE.set(ignore); + } + + /** + * 当前是否忽略租户 + * + * @return 是否忽略 + */ + public static boolean isIgnore() { + return Boolean.TRUE.equals(IGNORE.get()); + } + + public static void clear() { + TENANT_ID.remove(); + IGNORE.remove(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/db/TenantBaseDO.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/db/TenantBaseDO.java new file mode 100644 index 0000000..b6f27e7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/db/TenantBaseDO.java @@ -0,0 +1,21 @@ +package cn.aagro.pp.framework.tenant.core.db; + +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 拓展多租户的 BaseDO 基类 + * + * @author 芋道源码 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public abstract class TenantBaseDO extends BaseDO { + + /** + * 多租户编号 + */ + private Long tenantId; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/db/TenantDatabaseInterceptor.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/db/TenantDatabaseInterceptor.java new file mode 100644 index 0000000..596efcd --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/db/TenantDatabaseInterceptor.java @@ -0,0 +1,83 @@ +package cn.aagro.pp.framework.tenant.core.db; + +import cn.aagro.pp.framework.tenant.config.TenantProperties; +import cn.aagro.pp.framework.tenant.core.aop.TenantIgnore; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.toolkit.SqlParserUtils; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; + +import java.util.HashMap; +import java.util.Map; + +/** + * 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能 + * + * @author 芋道源码 + */ +public class TenantDatabaseInterceptor implements TenantLineHandler { + + /** + * 忽略的表 + * + * KEY:表名 + * VALUE:是否忽略 + */ + private final Map ignoreTables = new HashMap<>(); + + public TenantDatabaseInterceptor(TenantProperties properties) { + // 不同 DB 下,大小写的习惯不同,所以需要都添加进去 + properties.getIgnoreTables().forEach(table -> { + addIgnoreTable(table, true); + }); + // 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错 + addIgnoreTable("DUAL", true); + } + + @Override + public Expression getTenantId() { + return new LongValue(TenantContextHolder.getRequiredTenantId()); + } + + @Override + public boolean ignoreTable(String tableName) { + // 情况一,全局忽略多租户 + if (TenantContextHolder.isIgnore()) { + return true; + } + // 情况二,忽略多租户的表 + tableName = SqlParserUtils.removeWrapperSymbol(tableName); + Boolean ignore = ignoreTables.get(tableName.toLowerCase()); + if (ignore == null) { + ignore = computeIgnoreTable(tableName); + synchronized (ignoreTables) { + addIgnoreTable(tableName, ignore); + } + } + return ignore; + } + + private void addIgnoreTable(String tableName, boolean ignore) { + ignoreTables.put(tableName.toLowerCase(), ignore); + ignoreTables.put(tableName.toUpperCase(), ignore); + } + + private boolean computeIgnoreTable(String tableName) { + // 找不到的表,说明不是 aagro 项目里的,不进行拦截(忽略租户) + TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName); + if (tableInfo == null) { + return true; + } + // 如果继承了 TenantBaseDO 基类,显然不忽略租户 + if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) { + return false; + } + // 如果添加了 @TenantIgnore 注解,则忽略租户 + TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class); + return tenantIgnore != null; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/job/TenantJob.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/job/TenantJob.java new file mode 100644 index 0000000..68b72ee --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/job/TenantJob.java @@ -0,0 +1,14 @@ +package cn.aagro.pp.framework.tenant.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 TenantJob { +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/job/TenantJobAspect.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/job/TenantJobAspect.java new file mode 100644 index 0000000..1d848d4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/job/TenantJobAspect.java @@ -0,0 +1,59 @@ +package cn.aagro.pp.framework.tenant.core.job; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.tenant.core.service.TenantFrameworkService; +import cn.aagro.pp.framework.tenant.core.util.TenantUtils; +import lombok.RequiredArgsConstructor; +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.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 多租户 JobHandler AOP + * 任务执行时,会按照租户逐个执行 Job 的逻辑 + * + * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。 + * + * @author 芋道源码 + */ +@Aspect +@RequiredArgsConstructor +@Slf4j +public class TenantJobAspect { + + private final TenantFrameworkService tenantFrameworkService; + + @Around("@annotation(tenantJob)") + public String around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) { + // 获得租户列表 + List tenantIds = tenantFrameworkService.getTenantIds(); + if (CollUtil.isEmpty(tenantIds)) { + return null; + } + + // 逐个租户,执行 Job + Map results = new ConcurrentHashMap<>(); + tenantIds.parallelStream().forEach(tenantId -> { + // TODO 芋艿:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况 + TenantUtils.execute(tenantId, () -> { + try { + Object result = joinPoint.proceed(); + results.put(tenantId, StrUtil.toStringOrEmpty(result)); + } catch (Throwable e) { + log.error("[execute][租户({}) 执行 Job 发生异常", tenantId, e); + results.put(tenantId, ExceptionUtil.getRootCauseMessage(e)); + } + }); + }); + return JsonUtils.toJsonString(results); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java new file mode 100644 index 0000000..eabea94 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.framework.tenant.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 TenantKafkaProducerInterceptor} 拦截器 + * + * @author 芋道源码 + */ +@Slf4j +public class TenantKafkaEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String PROPERTY_KEY_INTERCEPTOR_CLASSES = "spring.kafka.producer.properties.interceptor.classes"; + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 添加 TenantKafkaProducerInterceptor 拦截器 + try { + String value = environment.getProperty(PROPERTY_KEY_INTERCEPTOR_CLASSES); + if (StrUtil.isEmpty(value)) { + value = TenantKafkaProducerInterceptor.class.getName(); + } else { + value += "," + TenantKafkaProducerInterceptor.class.getName(); + } + environment.getSystemProperties().put(PROPERTY_KEY_INTERCEPTOR_CLASSES, value); + } catch (NoClassDefFoundError ignore) { + // 如果触发 NoClassDefFoundError 异常,说明 TenantKafkaProducerInterceptor 类不存在,即没引入 kafka-spring 依赖 + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java new file mode 100644 index 0000000..e19380e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.tenant.core.mq.kafka; + +import cn.hutool.core.util.ReflectUtil; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +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 cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Kafka 消息队列的多租户 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 芋道源码 + */ +public class TenantKafkaProducerInterceptor implements ProducerInterceptor { + + @Override + public ProducerRecord onSend(ProducerRecord record) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); // private 属性,没有 get 方法,智能反射 + headers.add(HEADER_TENANT_ID, tenantId.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/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java new file mode 100644 index 0000000..7c8ebc9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java @@ -0,0 +1,24 @@ +package cn.aagro.pp.framework.tenant.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 TenantRabbitMQInitializer implements BeanPostProcessor { + + @Override + @SuppressWarnings("PatternVariableCanBeUsed") + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof RabbitTemplate) { + RabbitTemplate rabbitTemplate = (RabbitTemplate) bean; + rabbitTemplate.addBeforePublishPostProcessors(new TenantRabbitMQMessagePostProcessor()); + } + return bean; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java new file mode 100644 index 0000000..453e753 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java @@ -0,0 +1,31 @@ +package cn.aagro.pp.framework.tenant.core.mq.rabbitmq; + +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +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 cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RabbitMQ 消息队列的多租户 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 芋道源码 + */ +public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor { + + @Override + public Message postProcessMessage(Message message) throws AmqpException { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId); + } + return message; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java new file mode 100644 index 0000000..70339eb --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java @@ -0,0 +1,42 @@ +package cn.aagro.pp.framework.tenant.core.mq.redis; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import cn.aagro.pp.framework.mq.redis.core.message.AbstractRedisMessage; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; + +import static cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * 多租户 {@link AbstractRedisMessage} 拦截器 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中 + * + * @author 芋道源码 + */ +public class TenantRedisMessageInterceptor implements RedisMessageInterceptor { + + @Override + public void sendMessageBefore(AbstractRedisMessage message) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + message.addHeader(HEADER_TENANT_ID, tenantId.toString()); + } + } + + @Override + public void consumeMessageBefore(AbstractRedisMessage message) { + String tenantIdStr = message.getHeader(HEADER_TENANT_ID); + if (StrUtil.isNotEmpty(tenantIdStr)) { + TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr)); + } + } + + @Override + public void consumeMessageAfter(AbstractRedisMessage message) { + // 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况 + TenantContextHolder.clear(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java new file mode 100644 index 0000000..9edef05 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.tenant.core.mq.rocketmq; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +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 cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RocketMQ 消息队列的多租户 {@link ConsumeMessageHook} 实现类 + * + * Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 芋道源码 + */ +public class TenantRocketMQConsumeMessageHook 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 tenantId = messages.get(0).getUserProperty(HEADER_TENANT_ID); + if (StrUtil.isNotEmpty(tenantId)) { + TenantContextHolder.setTenantId(Long.parseLong(tenantId)); + } + } + + @Override + public void consumeMessageAfter(ConsumeMessageContext context) { + TenantContextHolder.clear(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java new file mode 100644 index 0000000..1d3d34a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java @@ -0,0 +1,54 @@ +package cn.aagro.pp.framework.tenant.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 TenantRocketMQInitializer implements BeanPostProcessor { + + @Override + @SuppressWarnings("PatternVariableCanBeUsed") + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof DefaultRocketMQListenerContainer) { + DefaultRocketMQListenerContainer container = (DefaultRocketMQListenerContainer) bean; + initTenantConsumer(container.getConsumer()); + } else if (bean instanceof RocketMQTemplate) { + RocketMQTemplate template = (RocketMQTemplate) bean; + initTenantProducer(template.getProducer()); + } + return bean; + } + + private void initTenantProducer(DefaultMQProducer producer) { + if (producer == null) { + return; + } + DefaultMQProducerImpl producerImpl = producer.getDefaultMQProducerImpl(); + if (producerImpl == null) { + return; + } + producerImpl.registerSendMessageHook(new TenantRocketMQSendMessageHook()); + } + + private void initTenantConsumer(DefaultMQPushConsumer consumer) { + if (consumer == null) { + return; + } + DefaultMQPushConsumerImpl consumerImpl = consumer.getDefaultMQPushConsumerImpl(); + if (consumerImpl == null) { + return; + } + consumerImpl.registerConsumeMessageHook(new TenantRocketMQConsumeMessageHook()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java new file mode 100644 index 0000000..757f21c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.framework.tenant.core.mq.rocketmq; + +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import org.apache.rocketmq.client.hook.SendMessageContext; +import org.apache.rocketmq.client.hook.SendMessageHook; + +import static cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RocketMQ 消息队列的多租户 {@link SendMessageHook} 实现类 + * + * Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * + * @author 芋道源码 + */ +public class TenantRocketMQSendMessageHook implements SendMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void sendMessageBefore(SendMessageContext sendMessageContext) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + return; + } + sendMessageContext.getMessage().putUserProperty(HEADER_TENANT_ID, tenantId.toString()); + } + + @Override + public void sendMessageAfter(SendMessageContext sendMessageContext) { + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/redis/TenantRedisCacheManager.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/redis/TenantRedisCacheManager.java new file mode 100644 index 0000000..11f8a8d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/redis/TenantRedisCacheManager.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.tenant.core.redis; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.redis.core.TimeoutRedisCacheManager; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +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; + +import java.util.Set; + +/** + * 多租户的 {@link RedisCacheManager} 实现类 + * + * 操作指定 name 的 {@link Cache} 时,自动拼接租户后缀,格式为 name + ":" + tenantId + 后缀 + * + * @author airhead + */ +@Slf4j +public class TenantRedisCacheManager extends TimeoutRedisCacheManager { + + private final Set ignoreCaches; + + public TenantRedisCacheManager(RedisCacheWriter cacheWriter, + RedisCacheConfiguration defaultCacheConfiguration, + Set ignoreCaches) { + super(cacheWriter, defaultCacheConfiguration); + this.ignoreCaches = ignoreCaches; + } + + @Override + public Cache getCache(String name) { + // 如果开启多租户,则 name 拼接租户后缀 + if (!TenantContextHolder.isIgnore() + && TenantContextHolder.getTenantId() != null + && !CollUtil.contains(ignoreCaches, name)) { + name = name + ":" + TenantContextHolder.getTenantId(); + } + + // 继续基于父方法 + return super.getCache(name); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/security/TenantSecurityWebFilter.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/security/TenantSecurityWebFilter.java new file mode 100644 index 0000000..751d0f3 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/security/TenantSecurityWebFilter.java @@ -0,0 +1,134 @@ +package cn.aagro.pp.framework.tenant.core.security; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import cn.aagro.pp.framework.tenant.config.TenantProperties; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import cn.aagro.pp.framework.tenant.core.service.TenantFrameworkService; +import cn.aagro.pp.framework.web.config.WebProperties; +import cn.aagro.pp.framework.web.core.filter.ApiRequestFilter; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +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; +import java.util.Set; + +/** + * 多租户 Security Web 过滤器 + * 1. 如果是登陆的用户,校验是否有权限访问该租户,避免越权问题。 + * 2. 如果请求未带租户的编号,检查是否是忽略的 URL,否则也不允许访问。 + * 3. 校验租户是合法,例如说被禁用、到期 + * + * @author 芋道源码 + */ +@Slf4j +public class TenantSecurityWebFilter extends ApiRequestFilter { + + private final TenantProperties tenantProperties; + + /** + * 允许忽略租户的 URL 列表 + * + * 目的:解决 修改配置会导致 @TenantIgnore Controller 接口过滤失效 + */ + private final Set ignoreUrls; + + private final AntPathMatcher pathMatcher; + + private final GlobalExceptionHandler globalExceptionHandler; + private final TenantFrameworkService tenantFrameworkService; + + public TenantSecurityWebFilter(WebProperties webProperties, + TenantProperties tenantProperties, + Set ignoreUrls, + GlobalExceptionHandler globalExceptionHandler, + TenantFrameworkService tenantFrameworkService) { + super(webProperties); + this.tenantProperties = tenantProperties; + this.ignoreUrls = ignoreUrls; + this.pathMatcher = new AntPathMatcher(); + this.globalExceptionHandler = globalExceptionHandler; + this.tenantFrameworkService = tenantFrameworkService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Long tenantId = TenantContextHolder.getTenantId(); + // 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。 + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user != null) { + // 如果获取不到租户编号,则尝试使用登陆用户的租户编号 + if (tenantId == null) { + tenantId = user.getTenantId(); + TenantContextHolder.setTenantId(tenantId); + // 如果传递了租户编号,则进行比对租户编号,避免越权问题 + } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) { + log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]", + user.getTenantId(), user.getId(), user.getUserType(), + TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(), + "您无权访问该租户的数据")); + return; + } + } + + // 如果非允许忽略租户的 URL,则校验租户是否合法 + if (!isIgnoreUrl(request)) { + // 2. 如果请求未带租户的编号,不允许访问。 + if (tenantId == null) { + log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), + "请求的租户标识未传递,请进行排查")); + return; + } + // 3. 校验租户是合法,例如说被禁用、到期 + try { + tenantFrameworkService.validTenant(tenantId); + } catch (Throwable ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错 + if (tenantId == null) { + TenantContextHolder.setIgnore(true); + } + } + + // 继续过滤 + chain.doFilter(request, response); + } + + private boolean isIgnoreUrl(HttpServletRequest request) { + String apiUri = request.getRequestURI().substring(request.getContextPath().length()); + // 快速匹配,保证性能 + if (CollUtil.contains(tenantProperties.getIgnoreUrls(), apiUri) + || CollUtil.contains(ignoreUrls, apiUri)) { + return true; + } + // 逐个 Ant 路径匹配 + for (String url : tenantProperties.getIgnoreUrls()) { + if (pathMatcher.match(url, apiUri)) { + return true; + } + } + for (String url : ignoreUrls) { + if (pathMatcher.match(url, apiUri)) { + return true; + } + } + return false; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/service/TenantFrameworkService.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/service/TenantFrameworkService.java new file mode 100644 index 0000000..3899e56 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/service/TenantFrameworkService.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.framework.tenant.core.service; + +import java.util.List; + +/** + * Tenant 框架 Service 接口,定义获取租户信息 + * + * @author 芋道源码 + */ +public interface TenantFrameworkService { + + /** + * 获得所有租户 + * + * @return 租户编号数组 + */ + List getTenantIds(); + + /** + * 校验租户是否合法 + * + * @param id 租户编号 + */ + void validTenant(Long id); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/service/TenantFrameworkServiceImpl.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/service/TenantFrameworkServiceImpl.java new file mode 100644 index 0000000..e1cbc1d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/service/TenantFrameworkServiceImpl.java @@ -0,0 +1,73 @@ +package cn.aagro.pp.framework.tenant.core.service; + +import cn.aagro.pp.framework.common.biz.system.tenant.TenantCommonApi; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.util.cache.CacheUtils; +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; + +/** + * Tenant 框架 Service 实现类 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class TenantFrameworkServiceImpl implements TenantFrameworkService { + + private static final ServiceException SERVICE_EXCEPTION_NULL = new ServiceException(); + + private final TenantCommonApi tenantApi; + + /** + * 针对 {@link #getTenantIds()} 的缓存 + */ + private final LoadingCache> getTenantIdsCache = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader>() { + + @Override + public List load(Object key) { + return tenantApi.getTenantIdList(); + } + + }); + + /** + * 针对 {@link #validTenant(Long)} 的缓存 + */ + private final LoadingCache validTenantCache = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader() { + + @Override + public ServiceException load(Long id) { + try { + tenantApi.validateTenant(id); + return SERVICE_EXCEPTION_NULL; + } catch (ServiceException ex) { + return ex; + } + } + + }); + + @Override + @SneakyThrows + public List getTenantIds() { + return getTenantIdsCache.get(Boolean.TRUE); + } + + @Override + public void validTenant(Long id) { + ServiceException serviceException = validTenantCache.getUnchecked(id); + if (serviceException != SERVICE_EXCEPTION_NULL) { + throw serviceException; + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/util/TenantUtils.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/util/TenantUtils.java new file mode 100644 index 0000000..b2a9f7f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/util/TenantUtils.java @@ -0,0 +1,113 @@ +package cn.aagro.pp.framework.tenant.core.util; + +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; + +import java.util.Map; +import java.util.concurrent.Callable; + +import static cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * 多租户 Util + * + * @author 芋道源码 + */ +public class TenantUtils { + + /** + * 使用指定租户,执行对应的逻辑 + * + * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户 + * 当然,执行完成后,还是会恢复回去 + * + * @param tenantId 租户编号 + * @param runnable 逻辑 + */ + public static void execute(Long tenantId, Runnable runnable) { + Long oldTenantId = TenantContextHolder.getTenantId(); + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setTenantId(tenantId); + TenantContextHolder.setIgnore(false); + // 执行逻辑 + runnable.run(); + } finally { + TenantContextHolder.setTenantId(oldTenantId); + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 使用指定租户,执行对应的逻辑 + * + * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户 + * 当然,执行完成后,还是会恢复回去 + * + * @param tenantId 租户编号 + * @param callable 逻辑 + * @return 结果 + */ + public static V execute(Long tenantId, Callable callable) { + Long oldTenantId = TenantContextHolder.getTenantId(); + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setTenantId(tenantId); + TenantContextHolder.setIgnore(false); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + TenantContextHolder.setTenantId(oldTenantId); + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 忽略租户,执行对应的逻辑 + * + * @param runnable 逻辑 + */ + public static void executeIgnore(Runnable runnable) { + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setIgnore(true); + // 执行逻辑 + runnable.run(); + } finally { + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 忽略租户,执行对应的逻辑 + * + * @param callable 逻辑 + * @return 结果 + */ + public static V executeIgnore(Callable callable) { + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setIgnore(true); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 将多租户编号,添加到 header 中 + * + * @param headers HTTP 请求 headers + * @param tenantId 租户编号 + */ + public static void addTenantHeader(Map headers, Long tenantId) { + if (tenantId != null) { + headers.put(HEADER_TENANT_ID, tenantId.toString()); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/web/TenantContextWebFilter.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/web/TenantContextWebFilter.java new file mode 100644 index 0000000..53a1e8f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/web/TenantContextWebFilter.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.framework.tenant.core.web; + +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import cn.aagro.pp.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 TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。 + * + * @author 芋道源码 + */ +public class TenantContextWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 设置 + Long tenantId = WebFrameworkUtils.getTenantId(request); + if (tenantId != null) { + TenantContextHolder.setTenantId(tenantId); + } + try { + chain.doFilter(request, response); + } finally { + // 清理 + TenantContextHolder.clear(); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/web/TenantVisitContextInterceptor.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/web/TenantVisitContextInterceptor.java new file mode 100644 index 0000000..9dea766 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/core/web/TenantVisitContextInterceptor.java @@ -0,0 +1,66 @@ +package cn.aagro.pp.framework.tenant.core.web; + +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.service.SecurityFrameworkService; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import cn.aagro.pp.framework.tenant.config.TenantProperties; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static cn.aagro.pp.framework.common.exception.util.ServiceExceptionUtil.exception0; + +@RequiredArgsConstructor +@Slf4j +public class TenantVisitContextInterceptor implements HandlerInterceptor { + + private static final String PERMISSION = "system:tenant:visit"; + + private final TenantProperties tenantProperties; + + private final SecurityFrameworkService securityFrameworkService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 如果和当前租户编号一致,则直接跳过 + Long visitTenantId = WebFrameworkUtils.getVisitTenantId(request); + if (visitTenantId == null) { + return true; + } + if (ObjUtil.equal(visitTenantId, TenantContextHolder.getTenantId())) { + return true; + } + // 必须是登录用户 + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return true; + } + + // 校验用户是否可切换租户 + if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) { + throw exception0(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权切换租户"); + } + + // 【重点】切换租户编号 + loginUser.setVisitTenantId(visitTenantId); + TenantContextHolder.setTenantId(visitTenantId); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 【重点】清理切换,换回原租户编号 + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser != null && loginUser.getTenantId() != null) { + TenantContextHolder.setTenantId(loginUser.getTenantId()); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/package-info.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/package-info.java new file mode 100644 index 0000000..7540d40 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/cn/aagro/pp/framework/tenant/package-info.java @@ -0,0 +1,17 @@ +/** + * 多租户,支持如下层面: + * 1. DB:基于 MyBatis Plus 多租户的功能实现。 + * 2. Redis:通过在 Redis Key 上拼接租户编号的方式,进行隔离。 + * 3. Web:请求 HTTP API 时,解析 Header 的 tenant-id 租户编号,添加到租户上下文。 + * 4. Security:校验当前登陆的用户,是否越权访问其它租户的数据。 + * 5. Job:在 JobHandler 执行任务时,会按照每个租户,都独立并行执行一次。 + * 6. MQ:在 Producer 发送消息时,Header 带上 tenant-id 租户编号;在 Consumer 消费消息时,将 Header 的 tenant-id 租户编号,添加到租户上下文。 + * 7. Async:异步需要保证 ThreadLocal 的传递性,通过使用阿里开源的 TransmittableThreadLocal 实现。相关的改造点,可见: + * 1)Spring Async: + * {@link cn.aagro.pp.framework.quartz.config.AagroAsyncAutoConfiguration#threadPoolTaskExecutorBeanPostProcessor()} + * 2)Spring Security: + * TransmittableThreadLocalSecurityContextHolderStrategy + * 和 AagroSecurityAutoConfiguration#securityContextHolderMethodInvokingFactoryBean() 方法 + * + */ +package cn.aagro.pp.framework.tenant; diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java new file mode 100644 index 0000000..10e1a0f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/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 cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import cn.aagro.pp.framework.tenant.core.util.TenantUtils; +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 cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_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 中的 tenant-id 设置到 {@link TenantContextHolder} 中 + * 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 tenantId= parseTenantId(message); + if (tenantId == null) { + return doInvoke(args); + } + // 情况二:有租户的情况下 + return TenantUtils.execute(tenantId, () -> doInvoke(args)); + } + + private Long parseTenantId(Message message) { + Object tenantId = message.getHeaders().get(HEADER_TENANT_ID); + if (tenantId == null) { + return null; + } + if (tenantId instanceof Long) { + return (Long) tenantId; + } + if (tenantId instanceof Number) { + return ((Number) tenantId).longValue(); + } + if (tenantId instanceof String) { + return Long.parseLong((String) tenantId); + } + if (tenantId instanceof byte[]) { + return Long.parseLong(new String((byte[]) tenantId)); + } + throw new IllegalArgumentException("未知的数据类型:" + tenantId); + } + + /** + * 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/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring.factories b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..fd376d9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + cn.aagro.pp.framework.tenant.core.mq.kafka.TenantKafkaEnvironmentPostProcessor diff --git a/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..1c172b5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.aagro.pp.framework.tenant.config.AagroTenantAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-excel/pom.xml b/aagro-framework/aagro-spring-boot-starter-excel/pom.xml new file mode 100644 index 0000000..a2c625f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/pom.xml @@ -0,0 +1,74 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-excel + jar + + ${project.artifactId} + Excel 拓展 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework + spring-web + provided + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + + cn.idev.excel + fastexcel + + + + jakarta.validation + jakarta.validation-api + provided + + + + com.google.guava + guava + + + + cn.aagro.gg + aagro-spring-boot-starter-biz-ip + true + + + + + cn.aagro.gg + aagro-spring-boot-starter-test + test + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/config/AagroDictAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/config/AagroDictAutoConfiguration.java new file mode 100644 index 0000000..d48779e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/config/AagroDictAutoConfiguration.java @@ -0,0 +1,18 @@ +package cn.aagro.pp.framework.dict.config; + +import cn.aagro.pp.framework.common.biz.system.dict.DictDataCommonApi; +import cn.aagro.pp.framework.dict.core.DictFrameworkUtils; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class AagroDictAutoConfiguration { + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public DictFrameworkUtils dictUtils(DictDataCommonApi dictDataApi) { + DictFrameworkUtils.init(dictDataApi); + return new DictFrameworkUtils(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/core/DictFrameworkUtils.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/core/DictFrameworkUtils.java new file mode 100644 index 0000000..f3e58c7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/core/DictFrameworkUtils.java @@ -0,0 +1,84 @@ +package cn.aagro.pp.framework.dict.core; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.common.biz.system.dict.DictDataCommonApi; +import cn.aagro.pp.framework.common.util.cache.CacheUtils; +import cn.aagro.pp.framework.common.biz.system.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; +import java.util.List; +import java.util.Objects; + +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 字典工具类 + * + * @author 芋道源码 + */ +@Slf4j +public class DictFrameworkUtils { + + private static DictDataCommonApi dictDataApi; + + /** + * 针对 dictType 的字段数据缓存 + */ + private static final LoadingCache> GET_DICT_DATA_CACHE = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader>() { + + @Override + public List load(String dictType) { + return dictDataApi.getDictDataList(dictType); + } + + }); + + public static void init(DictDataCommonApi dictDataApi) { + DictFrameworkUtils.dictDataApi = dictDataApi; + log.info("[init][初始化 DictFrameworkUtils 成功]"); + } + + public static void clearCache() { + GET_DICT_DATA_CACHE.invalidateAll(); + } + + @SneakyThrows + public static String parseDictDataLabel(String dictType, Integer value) { + if (value == null) { + return null; + } + return parseDictDataLabel(dictType, String.valueOf(value)); + } + + @SneakyThrows + public static String parseDictDataLabel(String dictType, String value) { + List dictDatas = GET_DICT_DATA_CACHE.get(dictType); + DictDataRespDTO dictData = CollUtil.findOne(dictDatas, data -> Objects.equals(data.getValue(), value)); + return dictData != null ? dictData.getLabel(): null; + } + + @SneakyThrows + public static List getDictDataLabelList(String dictType) { + List dictDatas = GET_DICT_DATA_CACHE.get(dictType); + return convertList(dictDatas, DictDataRespDTO::getLabel); + } + + @SneakyThrows + public static String parseDictDataValue(String dictType, String label) { + List dictDatas = GET_DICT_DATA_CACHE.get(dictType); + DictDataRespDTO dictData = CollUtil.findOne(dictDatas, data -> Objects.equals(data.getLabel(), label)); + return dictData!= null ? dictData.getValue(): null; + } + + @SneakyThrows + public static List getDictDataValueList(String dictType) { + List dictDatas = GET_DICT_DATA_CACHE.get(dictType); + return convertList(dictDatas, DictDataRespDTO::getValue); + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/package-info.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/package-info.java new file mode 100644 index 0000000..c3f770c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/package-info.java @@ -0,0 +1,6 @@ +/** + * 字典数据模块,提供 {@link cn.aagro.pp.framework.dict.core.DictFrameworkUtils} 工具类 + * + * 通过将字典缓存在内存中,保证性能 + */ +package cn.aagro.pp.framework.dict; diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDict.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDict.java new file mode 100644 index 0000000..11f54f3 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDict.java @@ -0,0 +1,33 @@ +package cn.aagro.pp.framework.dict.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 = {InDictValidator.class, InDictCollectionValidator.class} +) +public @interface InDict { + + /** + * 数据字典 type + */ + String type(); + + String message() default "必须在指定范围 {value}"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDictCollectionValidator.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDictCollectionValidator.java new file mode 100644 index 0000000..582717a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDictCollectionValidator.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.framework.dict.validation; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.dict.core.DictFrameworkUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Collection; +import java.util.List; + +public class InDictCollectionValidator implements ConstraintValidator> { + + private String dictType; + + @Override + public void initialize(InDict annotation) { + this.dictType = annotation.type(); + } + + @Override + public boolean isValid(Collection list, ConstraintValidatorContext context) { + // 为空时,默认不校验,即认为通过 + if (CollUtil.isEmpty(list)) { + return true; + } + // 校验全部通过 + List dbValues = DictFrameworkUtils.getDictDataValueList(dictType); + boolean match = list.stream().allMatch(v -> dbValues.stream() + .anyMatch(dbValue -> dbValue.equalsIgnoreCase(v.toString()))); + if (match) { + return true; + } + + // 校验不通过,自定义提示语句 + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate().replaceAll("\\{value}", dbValues.toString()) + ).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDictValidator.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDictValidator.java new file mode 100644 index 0000000..8d3300e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/dict/validation/InDictValidator.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.framework.dict.validation; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.dict.core.DictFrameworkUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.List; + +public class InDictValidator implements ConstraintValidator { + + private String dictType; + + @Override + public void initialize(InDict annotation) { + this.dictType = annotation.type(); + } + + @Override + public boolean isValid(Object value, ConstraintValidatorContext context) { + // 为空时,默认不校验,即认为通过 + if (value == null) { + return true; + } + // 校验通过 + final List values = DictFrameworkUtils.getDictDataValueList(dictType); + boolean match = values.stream().anyMatch(v -> StrUtil.equalsIgnoreCase(v, value.toString())); + if (match) { + return true; + } + + // 校验不通过,自定义提示语句 + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate().replaceAll("\\{value}", values.toString()) + ).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/annotations/DictFormat.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/annotations/DictFormat.java new file mode 100644 index 0000000..29c0458 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/annotations/DictFormat.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/annotations/ExcelColumnSelect.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/annotations/ExcelColumnSelect.java new file mode 100644 index 0000000..40115d5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/annotations/ExcelColumnSelect.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.excel.core.annotations; + +import java.lang.annotation.*; + +/** + * 给 Excel 列添加下拉选择数据 + * + * 其中 {@link #dictType()} 和 {@link #functionName()} 二选一 + * + * @author HUIHUI + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface ExcelColumnSelect { + + /** + * @return 字典类型 + */ + String dictType() default ""; + + /** + * @return 获取下拉数据源的方法名称 + */ + String functionName() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/AreaConvert.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/AreaConvert.java new file mode 100644 index 0000000..51ad4b5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/AreaConvert.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import cn.aagro.pp.framework.ip.core.Area; +import cn.aagro.pp.framework.ip.core.utils.AreaUtils; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * Excel 数据地区转换器 + * + * @author HUIHUI + */ +@Slf4j +public class AreaConvert 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 label = readCellData.getStringValue(); + Area area = AreaUtils.parseArea(label); + if (area == null) { + log.error("[convertToJavaData][label({}) 解析不掉]", label); + return null; + } + // 将 value 转换成对应的属性 + Class fieldClazz = contentProperty.getField().getType(); + return Convert.convert(fieldClazz, area.getId()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/DictConvert.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/DictConvert.java new file mode 100644 index 0000000..a590ef5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/DictConvert.java @@ -0,0 +1,72 @@ +package cn.aagro.pp.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import cn.aagro.pp.framework.dict.core.DictFrameworkUtils; +import cn.aagro.pp.framework.excel.core.annotations.DictFormat; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.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.parseDictDataLabel(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/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/JsonConvert.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/JsonConvert.java new file mode 100644 index 0000000..6cc9976 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/JsonConvert.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.framework.excel.core.convert; + +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.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/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/MoneyConvert.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/MoneyConvert.java new file mode 100644 index 0000000..4bd8535 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/convert/MoneyConvert.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.framework.excel.core.convert; + +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.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/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/function/ExcelColumnSelectFunction.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/function/ExcelColumnSelectFunction.java new file mode 100644 index 0000000..df101ed --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/function/ExcelColumnSelectFunction.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.framework.excel.core.function; + +import java.util.List; + +/** + * Excel 列下拉数据源获取接口 + * + * 为什么不直接解析字典还搞个接口?考虑到有的下拉数据不是从字典中获取的所有需要做一个兼容 + + * @author HUIHUI + */ +public interface ExcelColumnSelectFunction { + + /** + * 获得方法名称 + * + * @return 方法名称 + */ + String getName(); + + /** + * 获得列下拉数据源 + * + * @return 下拉数据源 + */ + List getOptions(); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/handler/ColumnWidthMatchStyleStrategy.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/handler/ColumnWidthMatchStyleStrategy.java new file mode 100644 index 0000000..80f0ead --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/handler/ColumnWidthMatchStyleStrategy.java @@ -0,0 +1,78 @@ +package cn.aagro.pp.framework.excel.core.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.Head; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.util.MapUtils; +import cn.idev.excel.write.metadata.holder.WriteSheetHolder; +import cn.idev.excel.write.style.column.AbstractColumnWidthStyleStrategy; +import cn.idev.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; +import org.apache.poi.ss.usermodel.Cell; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Excel 自适应列宽处理器 + * + * 相比 {@link LongestMatchColumnWidthStyleStrategy} 来说,额外处理了 DATE 类型! + * + * @see 添加自适应列宽处理器,并替换默认列宽策略 + * @author hmb + */ +public class ColumnWidthMatchStyleStrategy extends AbstractColumnWidthStyleStrategy { + + private static final int MAX_COLUMN_WIDTH = 255; + + private final Map> cache = MapUtils.newHashMapWithExpectedSize(8); + + @Override + protected void setColumnWidth(WriteSheetHolder writeSheetHolder, List> cellDataList, Cell cell, + Head head, Integer relativeRowIndex, Boolean isHead) { + boolean needSetWidth = isHead || CollUtil.isNotEmpty(cellDataList); + if (!needSetWidth) { + return; + } + Map maxColumnWidthMap = cache.computeIfAbsent(writeSheetHolder.getSheetNo(), + key -> new HashMap<>(16)); + Integer columnWidth = dataLength(cellDataList, cell, isHead); + if (columnWidth < 0) { + return; + } + if (columnWidth > MAX_COLUMN_WIDTH) { + columnWidth = MAX_COLUMN_WIDTH; + } + Integer maxColumnWidth = maxColumnWidthMap.get(cell.getColumnIndex()); + if (maxColumnWidth == null || columnWidth > maxColumnWidth) { + maxColumnWidthMap.put(cell.getColumnIndex(), columnWidth); + writeSheetHolder.getSheet().setColumnWidth(cell.getColumnIndex(), columnWidth * 256); + } + } + + @SuppressWarnings("EnhancedSwitchMigration") + private Integer dataLength(List> cellDataList, Cell cell, Boolean isHead) { + if (isHead) { + return cell.getStringCellValue().getBytes().length; + } + WriteCellData cellData = cellDataList.get(0); + CellDataTypeEnum type = cellData.getType(); + if (type == null) { + return -1; + } + switch (type) { + case STRING: + return cellData.getStringValue().getBytes().length; + case BOOLEAN: + return cellData.getBooleanValue().toString().getBytes().length; + case NUMBER: + return cellData.getNumberValue().toString().getBytes().length; + case DATE: + return cellData.getDateValue().toString().getBytes().length; + default: + return -1; + } + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/handler/SelectSheetWriteHandler.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/handler/SelectSheetWriteHandler.java new file mode 100644 index 0000000..7f948de --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/handler/SelectSheetWriteHandler.java @@ -0,0 +1,187 @@ +package cn.aagro.pp.framework.excel.core.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.hutool.poi.excel.ExcelUtil; +import cn.aagro.pp.framework.common.core.KeyValue; +import cn.aagro.pp.framework.dict.core.DictFrameworkUtils; +import cn.aagro.pp.framework.excel.core.annotations.ExcelColumnSelect; +import cn.aagro.pp.framework.excel.core.function.ExcelColumnSelectFunction; +import cn.idev.excel.annotation.ExcelIgnore; +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.write.handler.SheetWriteHandler; +import cn.idev.excel.write.metadata.holder.WriteSheetHolder; +import cn.idev.excel.write.metadata.holder.WriteWorkbookHolder; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.hssf.usermodel.HSSFDataValidation; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddressList; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 基于固定 sheet 实现下拉框 + * + * @author HUIHUI + */ +@Slf4j +public class SelectSheetWriteHandler implements SheetWriteHandler { + + /** + * 数据起始行从 0 开始 + * + * 约定:本项目第一行有标题所以从 1 开始如果您的 Excel 有多行标题请自行更改 + */ + public static final int FIRST_ROW = 1; + /** + * 下拉列需要创建下拉框的行数,默认两千行如需更多请自行调整 + */ + public static final int LAST_ROW = 2000; + + private static final String DICT_SHEET_NAME = "字典sheet"; + + /** + * key: 列 value: 下拉数据源 + */ + private final Map> selectMap = new HashMap<>(); + + public SelectSheetWriteHandler(Class head) { + // 解析下拉数据 + int colIndex = 0; + boolean ignoreUnannotated = head.isAnnotationPresent(ExcelIgnoreUnannotated.class); + for (Field field : head.getDeclaredFields()) { + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/pull/853 + // 1.1 忽略 static final 或 transient 的字段 + if (isStaticFinalOrTransient(field) ) { + continue; + } + // 1.2 忽略的字段跳过 + if ((ignoreUnannotated && !field.isAnnotationPresent(ExcelProperty.class)) + || field.isAnnotationPresent(ExcelIgnore.class)) { + continue; + } + + // 2. 核心:处理有 ExcelColumnSelect 注解的字段 + if (field.isAnnotationPresent(ExcelColumnSelect.class)) { + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty != null && excelProperty.index() != -1) { + colIndex = excelProperty.index(); + } + getSelectDataList(colIndex, field); + } + colIndex++; + } + } + + /** + * 判断字段是否是静态的、最终的、 transient 的 + * 原因:FastExcel 默认是忽略 static final 或 transient 的字段,所以需要判断 + * + * @param field 字段 + * @return 是否是静态的、最终的、transient 的 + */ + private boolean isStaticFinalOrTransient(Field field) { + return (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) + || Modifier.isTransient(field.getModifiers()); + } + + + /** + * 获得下拉数据,并添加到 {@link #selectMap} 中 + * + * @param colIndex 列索引 + * @param field 字段 + */ + private void getSelectDataList(int colIndex, Field field) { + ExcelColumnSelect columnSelect = field.getAnnotation(ExcelColumnSelect.class); + String dictType = columnSelect.dictType(); + String functionName = columnSelect.functionName(); + Assert.isTrue(ObjectUtil.isNotEmpty(dictType) || ObjectUtil.isNotEmpty(functionName), + "Field({}) 的 @ExcelColumnSelect 注解,dictType 和 functionName 不能同时为空", field.getName()); + + // 情况一:使用 dictType 获得下拉数据 + if (StrUtil.isNotEmpty(dictType)) { // 情况一: 字典数据 (默认) + selectMap.put(colIndex, DictFrameworkUtils.getDictDataLabelList(dictType)); + return; + } + + // 情况二:使用 functionName 获得下拉数据 + Map functionMap = SpringUtil.getApplicationContext().getBeansOfType(ExcelColumnSelectFunction.class); + ExcelColumnSelectFunction function = CollUtil.findOne(functionMap.values(), item -> item.getName().equals(functionName)); + Assert.notNull(function, "未找到对应的 function({})", functionName); + selectMap.put(colIndex, function.getOptions()); + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + if (CollUtil.isEmpty(selectMap)) { + return; + } + + // 1. 获取相应操作对象 + DataValidationHelper helper = writeSheetHolder.getSheet().getDataValidationHelper(); // 需要设置下拉框的 sheet 页的数据验证助手 + Workbook workbook = writeWorkbookHolder.getWorkbook(); // 获得工作簿 + List>> keyValues = convertList(selectMap.entrySet(), entry -> new KeyValue<>(entry.getKey(), entry.getValue())); + keyValues.sort(Comparator.comparing(item -> item.getValue().size())); // 升序不然创建下拉会报错 + + // 2. 创建数据字典的 sheet 页 + Sheet dictSheet = workbook.createSheet(DICT_SHEET_NAME); + for (KeyValue> keyValue : keyValues) { + int rowLength = keyValue.getValue().size(); + // 2.1 设置字典 sheet 页的值,每一列一个字典项 + for (int i = 0; i < rowLength; i++) { + Row row = dictSheet.getRow(i); + if (row == null) { + row = dictSheet.createRow(i); + } + row.createCell(keyValue.getKey()).setCellValue(keyValue.getValue().get(i)); + } + // 2.2 设置单元格下拉选择 + setColumnSelect(writeSheetHolder, workbook, helper, keyValue); + } + } + + /** + * 设置单元格下拉选择 + */ + private static void setColumnSelect(WriteSheetHolder writeSheetHolder, Workbook workbook, DataValidationHelper helper, + KeyValue> keyValue) { + // 1.1 创建可被其他单元格引用的名称 + Name name = workbook.createName(); + String excelColumn = ExcelUtil.indexToColName(keyValue.getKey()); + // 1.2 下拉框数据来源 eg:字典sheet!$B1:$B2 + String refers = DICT_SHEET_NAME + "!$" + excelColumn + "$1:$" + excelColumn + "$" + keyValue.getValue().size(); + name.setNameName("dict" + keyValue.getKey()); // 设置名称的名字 + name.setRefersToFormula(refers); // 设置公式 + + // 2.1 设置约束 + DataValidationConstraint constraint = helper.createFormulaListConstraint("dict" + keyValue.getKey()); // 设置引用约束 + // 设置下拉单元格的首行、末行、首列、末列 + CellRangeAddressList rangeAddressList = new CellRangeAddressList(FIRST_ROW, LAST_ROW, + keyValue.getKey(), keyValue.getKey()); + DataValidation validation = helper.createValidation(constraint, rangeAddressList); + if (validation instanceof HSSFDataValidation) { + validation.setSuppressDropDownArrow(false); + } else { + validation.setSuppressDropDownArrow(true); + validation.setShowErrorBox(true); + } + // 2.2 阻止输入非下拉框的值 + validation.setErrorStyle(DataValidation.ErrorStyle.STOP); + validation.createErrorBox("提示", "此值不存在于下拉选择中!"); + // 2.3 添加下拉框约束 + writeSheetHolder.getSheet().addValidationData(validation); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/util/ExcelUtils.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/util/ExcelUtils.java new file mode 100644 index 0000000..1160874 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/core/util/ExcelUtils.java @@ -0,0 +1,52 @@ +package cn.aagro.pp.framework.excel.core.util; + +import cn.idev.excel.FastExcelFactory; +import cn.idev.excel.converters.longconverter.LongStringConverter; +import cn.aagro.pp.framework.common.util.http.HttpUtils; +import cn.aagro.pp.framework.excel.core.handler.ColumnWidthMatchStyleStrategy; +import cn.aagro.pp.framework.excel.core.handler.SelectSheetWriteHandler; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +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 + FastExcelFactory.write(response.getOutputStream(), head) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .registerWriteHandler(new ColumnWidthMatchStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度 + .registerWriteHandler(new SelectSheetWriteHandler(head)) // 基于固定 sheet 实现下拉框 + .registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度 + .sheet(sheetName).doWrite(data); + // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 + response.addHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename)); + response.setContentType("application/vnd.ms-excel;charset=UTF-8"); + } + + public static List read(MultipartFile file, Class head) throws IOException { + return FastExcelFactory.read(file.getInputStream(), head, null) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .doReadAllSync(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/package-info.java b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/package-info.java new file mode 100644 index 0000000..a8fa255 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/java/cn/aagro/pp/framework/excel/package-info.java @@ -0,0 +1,4 @@ +/** + * 基于 FastExcel 实现 Excel 相关的操作 + */ +package cn.aagro.pp.framework.excel; diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..322149a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.aagro.pp.framework.dict.config.AagroDictAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-excel/src/test/java/cn/aagro/pp/framework/dict/core/util/DictFrameworkUtilsTest.java b/aagro-framework/aagro-spring-boot-starter-excel/src/test/java/cn/aagro/pp/framework/dict/core/util/DictFrameworkUtilsTest.java new file mode 100644 index 0000000..55d8e6c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-excel/src/test/java/cn/aagro/pp/framework/dict/core/util/DictFrameworkUtilsTest.java @@ -0,0 +1,61 @@ +package cn.aagro.pp.framework.dict.core.util; + +import cn.hutool.core.collection.ListUtil; +import cn.aagro.pp.framework.common.biz.system.dict.DictDataCommonApi; +import cn.aagro.pp.framework.common.biz.system.dict.dto.DictDataRespDTO; +import cn.aagro.pp.framework.dict.core.DictFrameworkUtils; +import cn.aagro.pp.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.List; + +import static cn.aagro.pp.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link DictFrameworkUtils} 的单元测试 + */ +public class DictFrameworkUtilsTest extends BaseMockitoUnitTest { + + @Mock + private DictDataCommonApi dictDataApi; + + @BeforeEach + public void setUp() { + DictFrameworkUtils.init(dictDataApi); + DictFrameworkUtils.clearCache(); + } + + @Test + public void testParseDictDataLabel() { + // mock 数据 + List dictDatas = ListUtil.of( + randomPojo(DictDataRespDTO.class, o -> o.setDictType("animal").setValue("cat").setLabel("猫")), + randomPojo(DictDataRespDTO.class, o -> o.setDictType("animal").setValue("dog").setLabel("狗")) + ); + // mock 方法 + when(dictDataApi.getDictDataList(eq("animal"))).thenReturn(dictDatas); + + // 断言返回值 + assertEquals("狗", DictFrameworkUtils.parseDictDataLabel("animal", "dog")); + } + + @Test + public void testParseDictDataValue() { + // mock 数据 + List dictDatas = ListUtil.of( + randomPojo(DictDataRespDTO.class, o -> o.setDictType("animal").setValue("cat").setLabel("猫")), + randomPojo(DictDataRespDTO.class, o -> o.setDictType("animal").setValue("dog").setLabel("狗")) + ); + // mock 方法 + when(dictDataApi.getDictDataList(eq("animal"))).thenReturn(dictDatas); + + // 断言返回值 + assertEquals("dog", DictFrameworkUtils.parseDictDataValue("animal", "狗")); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/pom.xml b/aagro-framework/aagro-spring-boot-starter-job/pom.xml new file mode 100644 index 0000000..e225692 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/pom.xml @@ -0,0 +1,41 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-job + jar + + ${project.artifactId} + 任务拓展 + 1. 定时任务,基于 Quartz 拓展 + 2. 异步任务,基于 Spring Async 拓展 + + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.springframework.boot + spring-boot-starter-quartz + + + + + jakarta.validation + jakarta.validation-api + + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/config/AagroAsyncAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/config/AagroAsyncAutoConfiguration.java new file mode 100644 index 0000000..119a160 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/config/AagroAsyncAutoConfiguration.java @@ -0,0 +1,45 @@ +package cn.aagro.pp.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.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +/** + * 异步任务 Configuration + */ +@AutoConfiguration +@EnableAsync +public class AagroAsyncAutoConfiguration { + + @Bean + public BeanPostProcessor threadPoolTaskExecutorBeanPostProcessor() { + return new BeanPostProcessor() { + + @Override + @SuppressWarnings("PatternVariableCanBeUsed") + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + // 处理 ThreadPoolTaskExecutor + if (bean instanceof ThreadPoolTaskExecutor) { + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean; + executor.setTaskDecorator(TtlRunnable::get); + return executor; + } + // 处理 SimpleAsyncTaskExecutor + // 参考 https://t.zsxq.com/CBoks 增加 + if (bean instanceof SimpleAsyncTaskExecutor) { + SimpleAsyncTaskExecutor executor = (SimpleAsyncTaskExecutor) bean; + executor.setTaskDecorator(TtlRunnable::get); + return executor; + } + return bean; + } + + }; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/config/AagroQuartzAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/config/AagroQuartzAutoConfiguration.java new file mode 100644 index 0000000..f09e25c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/config/AagroQuartzAutoConfiguration.java @@ -0,0 +1,29 @@ +package cn.aagro.pp.framework.quartz.config; + +import cn.aagro.pp.framework.quartz.core.scheduler.SchedulerManager; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Scheduler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.Optional; + +/** + * 定时任务 Configuration + */ +@AutoConfiguration +@EnableScheduling // 开启 Spring 自带的定时任务 +@Slf4j +public class AagroQuartzAutoConfiguration { + + @Bean + public SchedulerManager schedulerManager(Optional scheduler) { + if (!scheduler.isPresent()) { + log.info("[定时任务 - 已禁用][参考 https://doc.iocoder.cn/job/ 开启]"); + return new SchedulerManager(null); + } + return new SchedulerManager(scheduler.get()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/enums/JobDataKeyEnum.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/enums/JobDataKeyEnum.java new file mode 100644 index 0000000..afcea32 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/enums/JobDataKeyEnum.java @@ -0,0 +1,14 @@ +package cn.aagro.pp.framework.quartz.core.enums; + +/** + * Quartz Job Data 的 key 枚举 + */ +public enum JobDataKeyEnum { + + JOB_ID, + JOB_HANDLER_NAME, + JOB_HANDLER_PARAM, + JOB_RETRY_COUNT, // 最大重试次数 + JOB_RETRY_INTERVAL, // 每次重试间隔 + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/handler/JobHandler.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/handler/JobHandler.java new file mode 100644 index 0000000..3665a90 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/handler/JobHandler.java @@ -0,0 +1,19 @@ +package cn.aagro.pp.framework.quartz.core.handler; + +/** + * 任务处理器 + * + * @author 芋道源码 + */ +public interface JobHandler { + + /** + * 执行任务 + * + * @param param 参数 + * @return 结果 + * @throws Exception 异常 + */ + String execute(String param) throws Exception; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/handler/JobHandlerInvoker.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/handler/JobHandlerInvoker.java new file mode 100644 index 0000000..afc8663 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/handler/JobHandlerInvoker.java @@ -0,0 +1,114 @@ +package cn.aagro.pp.framework.quartz.core.handler; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.thread.ThreadUtil; +import cn.aagro.pp.framework.quartz.core.enums.JobDataKeyEnum; +import cn.aagro.pp.framework.quartz.core.service.JobLogFrameworkService; +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.PersistJobDataAfterExecution; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +import static cn.hutool.core.exceptions.ExceptionUtil.getRootCauseMessage; + +/** + * 基础 Job 调用者,负责调用 {@link JobHandler#execute(String)} 执行任务 + * + * @author 芋道源码 + */ +@DisallowConcurrentExecution +@PersistJobDataAfterExecution +@Slf4j +public class JobHandlerInvoker extends QuartzJobBean { + + @Resource + private ApplicationContext applicationContext; + + @Resource + private JobLogFrameworkService jobLogFrameworkService; + + @Override + protected void executeInternal(JobExecutionContext executionContext) throws JobExecutionException { + // 第一步,获得 Job 数据 + Long jobId = executionContext.getMergedJobDataMap().getLong(JobDataKeyEnum.JOB_ID.name()); + String jobHandlerName = executionContext.getMergedJobDataMap().getString(JobDataKeyEnum.JOB_HANDLER_NAME.name()); + String jobHandlerParam = executionContext.getMergedJobDataMap().getString(JobDataKeyEnum.JOB_HANDLER_PARAM.name()); + int refireCount = executionContext.getRefireCount(); + int retryCount = (Integer) executionContext.getMergedJobDataMap().getOrDefault(JobDataKeyEnum.JOB_RETRY_COUNT.name(), 0); + int retryInterval = (Integer) executionContext.getMergedJobDataMap().getOrDefault(JobDataKeyEnum.JOB_RETRY_INTERVAL.name(), 0); + + // 第二步,执行任务 + Long jobLogId = null; + LocalDateTime startTime = LocalDateTime.now(); + String data = null; + Throwable exception = null; + try { + // 记录 Job 日志(初始) + jobLogId = jobLogFrameworkService.createJobLog(jobId, startTime, jobHandlerName, jobHandlerParam, refireCount + 1); + // 执行任务 + data = this.executeInternal(jobHandlerName, jobHandlerParam); + } catch (Throwable ex) { + exception = ex; + } + + // 第三步,记录执行日志 + this.updateJobLogResultAsync(jobLogId, startTime, data, exception, executionContext); + + // 第四步,处理有异常的情况 + handleException(exception, refireCount, retryCount, retryInterval); + } + + private String executeInternal(String jobHandlerName, String jobHandlerParam) throws Exception { + // 获得 JobHandler 对象 + JobHandler jobHandler = applicationContext.getBean(jobHandlerName, JobHandler.class); + Assert.notNull(jobHandler, "JobHandler 不会为空"); + // 执行任务 + return jobHandler.execute(jobHandlerParam); + } + + private void updateJobLogResultAsync(Long jobLogId, LocalDateTime startTime, String data, Throwable exception, + JobExecutionContext executionContext) { + LocalDateTime endTime = LocalDateTime.now(); + // 处理是否成功 + boolean success = exception == null; + if (!success) { + data = getRootCauseMessage(exception); + } + // 更新日志 + try { + jobLogFrameworkService.updateJobLogResultAsync(jobLogId, endTime, (int) LocalDateTimeUtil.between(startTime, endTime).toMillis(), success, data); + } catch (Exception ex) { + log.error("[executeInternal][Job({}) logId({}) 记录执行日志失败({}/{})]", + executionContext.getJobDetail().getKey(), jobLogId, success, data); + } + } + + private void handleException(Throwable exception, + int refireCount, int retryCount, int retryInterval) throws JobExecutionException { + // 如果有异常,则进行重试 + if (exception == null) { + return; + } + // 情况一:如果到达重试上限,则直接抛出异常即可 + if (refireCount >= retryCount) { + throw new JobExecutionException(exception); + } + + // 情况二:如果未到达重试上限,则 sleep 一定间隔时间,然后重试 + // 这里使用 sleep 来实现,主要还是希望实现比较简单。因为,同一时间,不会存在大量失败的 Job。 + if (retryInterval > 0) { + ThreadUtil.sleep(retryInterval); + } + // 第二个参数,refireImmediately = true,表示立即重试 + throw new JobExecutionException(exception, true); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/scheduler/SchedulerManager.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/scheduler/SchedulerManager.java new file mode 100644 index 0000000..b47c891 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/scheduler/SchedulerManager.java @@ -0,0 +1,150 @@ +package cn.aagro.pp.framework.quartz.core.scheduler; + +import cn.aagro.pp.framework.quartz.core.enums.JobDataKeyEnum; +import cn.aagro.pp.framework.quartz.core.handler.JobHandlerInvoker; +import org.quartz.*; + +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_IMPLEMENTED; +import static cn.aagro.pp.framework.common.exception.util.ServiceExceptionUtil.exception0; + +/** + * {@link org.quartz.Scheduler} 的管理器,负责创建任务 + * + * 考虑到实现的简洁性,我们使用 jobHandlerName 作为唯一标识,即: + * 1. Job 的 {@link JobDetail#getKey()} + * 2. Trigger 的 {@link Trigger#getKey()} + * + * 另外,jobHandlerName 对应到 Spring Bean 的名字,直接调用 + * + * @author 芋道源码 + */ +public class SchedulerManager { + + private final Scheduler scheduler; + + public SchedulerManager(Scheduler scheduler) { + this.scheduler = scheduler; + } + + /** + * 添加 Job 到 Quartz 中 + * + * @param jobId 任务编号 + * @param jobHandlerName 任务处理器的名字 + * @param jobHandlerParam 任务处理器的参数 + * @param cronExpression CRON 表达式 + * @param retryCount 重试次数 + * @param retryInterval 重试间隔 + * @throws SchedulerException 添加异常 + */ + public void addJob(Long jobId, String jobHandlerName, String jobHandlerParam, String cronExpression, + Integer retryCount, Integer retryInterval) + throws SchedulerException { + validateScheduler(); + // 创建 JobDetail 对象 + JobDetail jobDetail = JobBuilder.newJob(JobHandlerInvoker.class) + .usingJobData(JobDataKeyEnum.JOB_ID.name(), jobId) + .usingJobData(JobDataKeyEnum.JOB_HANDLER_NAME.name(), jobHandlerName) + .withIdentity(jobHandlerName).build(); + // 创建 Trigger 对象 + Trigger trigger = this.buildTrigger(jobHandlerName, jobHandlerParam, cronExpression, retryCount, retryInterval); + // 新增 Job 调度 + scheduler.scheduleJob(jobDetail, trigger); + } + + /** + * 更新 Job 到 Quartz + * + * @param jobHandlerName 任务处理器的名字 + * @param jobHandlerParam 任务处理器的参数 + * @param cronExpression CRON 表达式 + * @param retryCount 重试次数 + * @param retryInterval 重试间隔 + * @throws SchedulerException 更新异常 + */ + public void updateJob(String jobHandlerName, String jobHandlerParam, String cronExpression, + Integer retryCount, Integer retryInterval) + throws SchedulerException { + validateScheduler(); + // 创建新 Trigger 对象 + Trigger newTrigger = this.buildTrigger(jobHandlerName, jobHandlerParam, cronExpression, retryCount, retryInterval); + // 修改调度 + scheduler.rescheduleJob(new TriggerKey(jobHandlerName), newTrigger); + } + + /** + * 删除 Quartz 中的 Job + * + * @param jobHandlerName 任务处理器的名字 + * @throws SchedulerException 删除异常 + */ + public void deleteJob(String jobHandlerName) throws SchedulerException { + validateScheduler(); + // 暂停 Trigger 对象 + scheduler.pauseTrigger(new TriggerKey(jobHandlerName)); + // 取消并删除 Job 调度 + scheduler.unscheduleJob(new TriggerKey(jobHandlerName)); + scheduler.deleteJob(new JobKey(jobHandlerName)); + } + + /** + * 暂停 Quartz 中的 Job + * + * @param jobHandlerName 任务处理器的名字 + * @throws SchedulerException 暂停异常 + */ + public void pauseJob(String jobHandlerName) throws SchedulerException { + validateScheduler(); + scheduler.pauseJob(new JobKey(jobHandlerName)); + } + + /** + * 启动 Quartz 中的 Job + * + * @param jobHandlerName 任务处理器的名字 + * @throws SchedulerException 启动异常 + */ + public void resumeJob(String jobHandlerName) throws SchedulerException { + validateScheduler(); + scheduler.resumeJob(new JobKey(jobHandlerName)); + scheduler.resumeTrigger(new TriggerKey(jobHandlerName)); + } + + /** + * 立即触发一次 Quartz 中的 Job + * + * @param jobId 任务编号 + * @param jobHandlerName 任务处理器的名字 + * @param jobHandlerParam 任务处理器的参数 + * @throws SchedulerException 触发异常 + */ + public void triggerJob(Long jobId, String jobHandlerName, String jobHandlerParam) + throws SchedulerException { + validateScheduler(); + // 触发任务 + JobDataMap data = new JobDataMap(); // 无需重试,所以不设置 retryCount 和 retryInterval + data.put(JobDataKeyEnum.JOB_ID.name(), jobId); + data.put(JobDataKeyEnum.JOB_HANDLER_NAME.name(), jobHandlerName); + data.put(JobDataKeyEnum.JOB_HANDLER_PARAM.name(), jobHandlerParam); + scheduler.triggerJob(new JobKey(jobHandlerName), data); + } + + private Trigger buildTrigger(String jobHandlerName, String jobHandlerParam, String cronExpression, + Integer retryCount, Integer retryInterval) { + return TriggerBuilder.newTrigger() + .withIdentity(jobHandlerName) + .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)) + .usingJobData(JobDataKeyEnum.JOB_HANDLER_PARAM.name(), jobHandlerParam) + .usingJobData(JobDataKeyEnum.JOB_RETRY_COUNT.name(), retryCount) + .usingJobData(JobDataKeyEnum.JOB_RETRY_INTERVAL.name(), retryInterval) + .build(); + } + + private void validateScheduler() { + if (scheduler == null) { + throw exception0(NOT_IMPLEMENTED.getCode(), + "[定时任务 - 已禁用][参考 https://doc.iocoder.cn/job/ 开启]"); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/service/JobLogFrameworkService.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/service/JobLogFrameworkService.java new file mode 100644 index 0000000..0887ac0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/service/JobLogFrameworkService.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.framework.quartz.core.service; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * Job 日志 Framework Service 接口 + * + * @author 芋道源码 + */ +public interface JobLogFrameworkService { + + /** + * 创建 Job 日志 + * + * @param jobId 任务编号 + * @param beginTime 开始时间 + * @param jobHandlerName Job 处理器的名字 + * @param jobHandlerParam Job 处理器的参数 + * @param executeIndex 第几次执行 + * @return Job 日志的编号 + */ + Long createJobLog(@NotNull(message = "任务编号不能为空") Long jobId, + @NotNull(message = "开始时间") LocalDateTime beginTime, + @NotEmpty(message = "Job 处理器的名字不能为空") String jobHandlerName, + String jobHandlerParam, + @NotNull(message = "第几次执行不能为空") Integer executeIndex); + + /** + * 更新 Job 日志的执行结果 + * + * @param logId 日志编号 + * @param endTime 结束时间。因为是异步,避免记录时间不准去 + * @param duration 运行时长,单位:毫秒 + * @param success 是否成功 + * @param result 成功数据 + */ + void updateJobLogResultAsync(@NotNull(message = "日志编号不能为空") Long logId, + @NotNull(message = "结束时间不能为空") LocalDateTime endTime, + @NotNull(message = "运行时长不能为空") Integer duration, + boolean success, String result); +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/util/CronUtils.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/util/CronUtils.java new file mode 100644 index 0000000..c5c314c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/core/util/CronUtils.java @@ -0,0 +1,60 @@ +package cn.aagro.pp.framework.quartz.core.util; + +import cn.hutool.core.date.LocalDateTimeUtil; +import org.quartz.CronExpression; + +import java.text.ParseException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Quartz Cron 表达式的工具类 + * + * @author 芋道源码 + */ +public class CronUtils { + + /** + * 校验 CRON 表达式是否有效 + * + * @param cronExpression CRON 表达式 + * @return 是否有效 + */ + public static boolean isValid(String cronExpression) { + return CronExpression.isValidExpression(cronExpression); + } + + /** + * 基于 CRON 表达式,获得下 n 个满足执行的时间 + * + * @param cronExpression CRON 表达式 + * @param n 数量 + * @return 满足条件的执行时间 + */ + public static List getNextTimes(String cronExpression, int n) { + // 1. 获得 CronExpression 对象 + CronExpression cron; + try { + cron = new CronExpression(cronExpression); + } catch (ParseException e) { + throw new IllegalArgumentException(e.getMessage()); + } + // 2. 从当前开始计算,n 个满足条件的 + Date now = new Date(); + List nextTimes = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Date nextTime = cron.getNextValidTimeAfter(now); + // 2.1 如果 nextTime 为 null,说明没有更多的有效时间,退出循环 + if (nextTime == null) { + break; + } + nextTimes.add(LocalDateTimeUtil.of(nextTime)); + // 2.2 切换现在,为下一个触发时间; + now = nextTime; + } + return nextTimes; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/package-info.java b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/package-info.java new file mode 100644 index 0000000..a12e32a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/java/cn/aagro/pp/framework/quartz/package-info.java @@ -0,0 +1,7 @@ +/** + * 1. 定时任务,采用 Quartz 实现进程内的任务执行。 + * 考虑到高可用,使用 Quartz 自带的 MySQL 集群方案。 + * + * 2. 异步任务,采用 Spring Async 异步执行。 + */ +package cn.aagro.pp.framework.quartz; diff --git a/aagro-framework/aagro-spring-boot-starter-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..7c85f95 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.aagro.pp.framework.quartz.config.AagroQuartzAutoConfiguration +cn.aagro.pp.framework.quartz.config.AagroAsyncAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-job/《芋道 Spring Boot 定时任务入门》.md b/aagro-framework/aagro-spring-boot-starter-job/《芋道 Spring Boot 定时任务入门》.md new file mode 100644 index 0000000..eff42db --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/《芋道 Spring Boot 定时任务入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-job/《芋道 Spring Boot 异步任务入门》.md b/aagro-framework/aagro-spring-boot-starter-job/《芋道 Spring Boot 异步任务入门》.md new file mode 100644 index 0000000..b827823 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-job/《芋道 Spring Boot 异步任务入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/pom.xml b/aagro-framework/aagro-spring-boot-starter-monitor/pom.xml new file mode 100644 index 0000000..0116f30 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/pom.xml @@ -0,0 +1,79 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-monitor + jar + + ${project.artifactId} + 服务监控,提供链路追踪、日志服务、指标收集等等功能 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springframework + spring-web + provided + + + + jakarta.servlet + jakarta.servlet-api + provided + + + + + io.opentracing + opentracing-util + true + + + org.apache.skywalking + apm-toolkit-trace + true + + + org.apache.skywalking + apm-toolkit-logback-1.x + true + + + org.apache.skywalking + apm-toolkit-opentracing + true + + + + + io.micrometer + micrometer-registry-prometheus + true + + + + de.codecentric + spring-boot-admin-starter-client + true + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/AagroMetricsAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/AagroMetricsAutoConfiguration.java new file mode 100644 index 0000000..43f952f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/AagroMetricsAutoConfiguration.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.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; + +/** + * Metrics 配置类 + * + * @author 芋道源码 + */ +@AutoConfiguration +@ConditionalOnClass({MeterRegistryCustomizer.class}) +@ConditionalOnProperty(prefix = "aagro.metrics", value = "enable", matchIfMissing = true) // 允许使用 aagro.metrics.enable=false 禁用 Metrics +public class AagroMetricsAutoConfiguration { + + @Bean + public MeterRegistryCustomizer metricsCommonTags( + @Value("${spring.application.name}") String applicationName) { + return registry -> registry.config().commonTags("application", applicationName); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/AagroTracerAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/AagroTracerAutoConfiguration.java new file mode 100644 index 0000000..0cd9504 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/AagroTracerAutoConfiguration.java @@ -0,0 +1,53 @@ +package cn.aagro.pp.framework.tracer.config; + +import cn.aagro.pp.framework.common.enums.WebFilterOrderEnum; +import cn.aagro.pp.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(name = { + "org.apache.skywalking.apm.toolkit.opentracing.SkywalkingTracer", // 来自 apm-toolkit-opentracing.jar +// "io.opentracing.Tracer", // 来自 opentracing-api.jar + "javax.servlet.Filter" +}) +@EnableConfigurationProperties(TracerProperties.class) +@ConditionalOnProperty(prefix = "aagro.tracer", value = "enable", matchIfMissing = true) +public class AagroTracerAutoConfiguration { + + // TODO @芋艿:skywalking 不兼容最新的 opentracing 版本。同时,opentracing 也停止了维护,尬住了!后续换 opentelemetry 即可! +// @Bean +// public BizTraceAspect bizTracingAop() { +// return new BizTraceAspect(tracer()); +// } +// +// @Bean +// public Tracer tracer() { +// // 创建 SkywalkingTracer 对象 +// SkywalkingTracer tracer = new SkywalkingTracer(); +// // 设置为 GlobalTracer 的追踪器 +// GlobalTracer.registerIfAbsent(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/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/TracerProperties.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/TracerProperties.java new file mode 100644 index 0000000..a770fb6 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/config/TracerProperties.java @@ -0,0 +1,14 @@ +package cn.aagro.pp.framework.tracer.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * BizTracer配置类 + * + * @author 麻薯 + */ +@ConfigurationProperties("aagro.tracer") +@Data +public class TracerProperties { +} diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/annotation/BizTrace.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/annotation/BizTrace.java new file mode 100644 index 0000000..efc518e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/annotation/BizTrace.java @@ -0,0 +1,42 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/aop/BizTraceAspect.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/aop/BizTraceAspect.java new file mode 100644 index 0000000..d521e00 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/aop/BizTraceAspect.java @@ -0,0 +1,77 @@ +package cn.aagro.pp.framework.tracer.core.aop; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.tracer.core.annotation.BizTrace; +import cn.aagro.pp.framework.common.util.spring.SpringExpressionUtils; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/filter/TraceFilter.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/filter/TraceFilter.java new file mode 100644 index 0000000..030400f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/filter/TraceFilter.java @@ -0,0 +1,33 @@ +package cn.aagro.pp.framework.tracer.core.filter; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/util/TracerFrameworkUtils.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/util/TracerFrameworkUtils.java new file mode 100644 index 0000000..dba0689 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/core/util/TracerFrameworkUtils.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/package-info.java b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/package-info.java new file mode 100644 index 0000000..0d2b427 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/java/cn/aagro/pp/framework/tracer/package-info.java @@ -0,0 +1,6 @@ +/** + * 使用 SkyWalking 组件,作为链路追踪、日志中心。 + * + * @author 芋道源码 + */ +package cn.aagro.pp.framework.tracer; diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..eb479ab --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.aagro.pp.framework.tracer.config.AagroTracerAutoConfiguration +cn.aagro.pp.framework.tracer.config.AagroMetricsAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 监控工具 Admin 入门》.md b/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 监控工具 Admin 入门》.md new file mode 100644 index 0000000..d465241 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 监控工具 Admin 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 监控端点 Actuator 入门》.md b/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 监控端点 Actuator 入门》.md new file mode 100644 index 0000000..dadb332 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 监控端点 Actuator 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 链路追踪 SkyWalking 入门》.md b/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 链路追踪 SkyWalking 入门》.md new file mode 100644 index 0000000..47b0e43 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-monitor/《芋道 Spring Boot 链路追踪 SkyWalking 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mq/pom.xml b/aagro-framework/aagro-spring-boot-starter-mq/pom.xml new file mode 100644 index 0000000..8b374ef --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/pom.xml @@ -0,0 +1,43 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-mq + jar + + ${project.artifactId} + 消息队列,支持 Redis、RocketMQ、RabbitMQ、Kafka 四种 + https://github.com/YunaiV/ruoyi-vue-pro + + + + + cn.aagro.gg + aagro-spring-boot-starter-redis + + + + + org.springframework.kafka + spring-kafka + true + + + org.springframework.amqp + spring-rabbit + true + + + org.apache.rocketmq + rocketmq-spring-boot-starter + true + + + + \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/package-info.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/package-info.java new file mode 100644 index 0000000..2e3deeb --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/package-info.java @@ -0,0 +1,4 @@ +/** + * 消息队列,支持 Redis、RocketMQ、RabbitMQ、Kafka 四种 + */ +package cn.aagro.pp.framework.mq; diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/config/AagroRabbitMQAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/config/AagroRabbitMQAutoConfiguration.java new file mode 100644 index 0000000..84f802e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/config/AagroRabbitMQAutoConfiguration.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.framework.mq.rabbitmq.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; + +/** + * RabbitMQ 消息队列配置类 + * + * @author 芋道源码 + */ +@AutoConfiguration +@Slf4j +@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") +public class AagroRabbitMQAutoConfiguration { + + /** + * Jackson2JsonMessageConverter Bean:使用 jackson 序列化消息 + */ + @Bean + public MessageConverter createMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/core/package-info.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/core/package-info.java new file mode 100644 index 0000000..672d239 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位符,无特殊逻辑 + */ +package cn.aagro.pp.framework.mq.rabbitmq.core; \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/package-info.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/package-info.java new file mode 100644 index 0000000..c5b66a8 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/rabbitmq/package-info.java @@ -0,0 +1,4 @@ +/** + * 消息队列,基于 RabbitMQ 提供 + */ +package cn.aagro.pp.framework.mq.rabbitmq; diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/config/AagroRedisMQConsumerAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/config/AagroRedisMQConsumerAutoConfiguration.java new file mode 100644 index 0000000..a66f084 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/config/AagroRedisMQConsumerAutoConfiguration.java @@ -0,0 +1,162 @@ +package cn.aagro.pp.framework.mq.redis.config; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.system.SystemUtil; +import cn.aagro.pp.framework.common.enums.DocumentEnum; +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.framework.mq.redis.core.job.RedisPendingMessageResendJob; +import cn.aagro.pp.framework.mq.redis.core.job.RedisStreamMessageCleanupJob; +import cn.aagro.pp.framework.mq.redis.core.pubsub.AbstractRedisChannelMessageListener; +import cn.aagro.pp.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +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 = AagroRedisAutoConfiguration.class) +public class AagroRedisMQConsumerAutoConfiguration { + + /** + * 创建 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, + RedissonClient redissonClient) { + return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient); + } + + /** + * 创建 Redis Stream 消息清理任务 + */ + @Bean + @ConditionalOnBean(AbstractRedisStreamMessageListener.class) + public RedisStreamMessageCleanupJob redisStreamMessageCleanupJob(List> listeners, + RedisMQTemplate redisTemplate, + RedissonClient redissonClient) { + return new RedisStreamMessageCleanupJob(listeners, redisTemplate, 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 消费者名字 + */ + public static String buildConsumerName() { + return String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); + } + + /** + * 校验 Redis 版本号,是否满足最低的版本号要求! + */ + public 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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/config/AagroRedisMQProducerAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/config/AagroRedisMQProducerAutoConfiguration.java new file mode 100644 index 0000000..19eb7ff --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/config/AagroRedisMQProducerAutoConfiguration.java @@ -0,0 +1,31 @@ +package cn.aagro.pp.framework.mq.redis.config; + +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +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 = AagroRedisAutoConfiguration.class) +public class AagroRedisMQProducerAutoConfiguration { + + @Bean + public RedisMQTemplate redisMQTemplate(StringRedisTemplate redisTemplate, + List interceptors) { + RedisMQTemplate redisMQTemplate = new RedisMQTemplate(redisTemplate); + // 添加拦截器 + interceptors.forEach(redisMQTemplate::addInterceptor); + return redisMQTemplate; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/RedisMQTemplate.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/RedisMQTemplate.java new file mode 100644 index 0000000..0f4ee5f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/RedisMQTemplate.java @@ -0,0 +1,87 @@ +package cn.aagro.pp.framework.mq.redis.core; + +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import cn.aagro.pp.framework.mq.redis.core.message.AbstractRedisMessage; +import cn.aagro.pp.framework.mq.redis.core.pubsub.AbstractRedisChannelMessage; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java new file mode 100644 index 0000000..fb162da --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.framework.mq.redis.core.interceptor; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/job/RedisPendingMessageResendJob.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/job/RedisPendingMessageResendJob.java new file mode 100644 index 0000000..fcda714 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/job/RedisPendingMessageResendJob.java @@ -0,0 +1,99 @@ +package cn.aagro.pp.framework.mq.redis.core.job; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.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:stream:pending-message-resend: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 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(), listener.getGroup())); + // 每个消费者的 pending 队列消息数量 + Map pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); + pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { + log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount); + // 每个消费者的 pending消息的详情信息 + PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(listener.getGroup(), 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(listener.getGroup(), records.get(0)); + log.info("[processPendingMessage][消息({})重新投递成功]", records.get(0).getId()); + }); + }); + }); + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/job/RedisStreamMessageCleanupJob.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/job/RedisStreamMessageCleanupJob.java new file mode 100644 index 0000000..4806cda --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/job/RedisStreamMessageCleanupJob.java @@ -0,0 +1,72 @@ +package cn.aagro.pp.framework.mq.redis.core.job; + +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.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.redis.core.StreamOperations; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.List; + +/** + * Redis Stream 消息清理任务 + * 用于定期清理已消费的消息,防止内存占用过大 + * + * @see 记一次 redis stream 数据类型内存不释放问题 + * + * @author 芋道源码 + */ +@Slf4j +@AllArgsConstructor +public class RedisStreamMessageCleanupJob { + + private static final String LOCK_KEY = "redis:stream:message-cleanup:lock"; + + /** + * 保留的消息数量,默认保留最近 10000 条消息 + */ + private static final long MAX_COUNT = 10000; + + private final List> listeners; + private final RedisMQTemplate redisTemplate; + private final RedissonClient redissonClient; + + /** + * 每小时执行一次清理任务 + */ + @Scheduled(cron = "0 0 * * * ?") + public void cleanup() { + RLock lock = redissonClient.getLock(LOCK_KEY); + // 尝试加锁 + if (lock.tryLock()) { + try { + execute(); + } catch (Exception ex) { + log.error("[cleanup][执行异常]", ex); + } finally { + lock.unlock(); + } + } + } + + /** + * 执行清理逻辑 + */ + private void execute() { + StreamOperations ops = redisTemplate.getRedisTemplate().opsForStream(); + listeners.forEach(listener -> { + try { + // 使用 XTRIM 命令清理消息,只保留最近的 MAX_LEN 条消息 + Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, true); + if (trimCount != null && trimCount > 0) { + log.info("[execute][Stream({}) 清理消息数量({})]", listener.getStreamKey(), trimCount); + } + } catch (Exception ex) { + log.error("[execute][Stream({}) 清理异常]", listener.getStreamKey(), ex); + } + }); + } +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/message/AbstractRedisMessage.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/message/AbstractRedisMessage.java new file mode 100644 index 0000000..dea0273 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/message/AbstractRedisMessage.java @@ -0,0 +1,29 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java new file mode 100644 index 0000000..1b00b63 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java @@ -0,0 +1,23 @@ +package cn.aagro.pp.framework.mq.redis.core.pubsub; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java new file mode 100644 index 0000000..cf07550 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java @@ -0,0 +1,103 @@ +package cn.aagro.pp.framework.mq.redis.core.pubsub; + +import cn.hutool.core.util.TypeUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java new file mode 100644 index 0000000..769a131 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java @@ -0,0 +1,23 @@ +package cn.aagro.pp.framework.mq.redis.core.stream; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java new file mode 100644 index 0000000..3bb07b2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java @@ -0,0 +1,119 @@ +package cn.aagro.pp.framework.mq.redis.core.stream; + +import cn.hutool.core.util.TypeUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import cn.aagro.pp.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(); + } + + protected AbstractRedisStreamMessageListener(String streamKey, String group) { + this.messageType = null; + this.streamKey = streamKey; + this.group = group; + } + + @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/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/package-info.java b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/package-info.java new file mode 100644 index 0000000..09be89b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/java/cn/aagro/pp/framework/mq/redis/package-info.java @@ -0,0 +1,6 @@ +/** + * 消息队列,基于 Redis 提供: + * 1. 基于 Pub/Sub 实现广播消费 + * 2. 基于 Stream 实现集群消费 + */ +package cn.aagro.pp.framework.mq.redis; diff --git a/aagro-framework/aagro-spring-boot-starter-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..78cb88d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +cn.aagro.pp.framework.mq.redis.config.AagroRedisMQProducerAutoConfiguration +cn.aagro.pp.framework.mq.redis.config.AagroRedisMQConsumerAutoConfiguration +cn.aagro.pp.framework.mq.rabbitmq.config.AagroRabbitMQAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 事件机制 Event 入门》.md b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 事件机制 Event 入门》.md new file mode 100644 index 0000000..ff68185 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 事件机制 Event 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 Kafka 入门》.md b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 Kafka 入门》.md new file mode 100644 index 0000000..279d22e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 Kafka 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 RabbitMQ 入门》.md b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 RabbitMQ 入门》.md new file mode 100644 index 0000000..9797ef7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 RabbitMQ 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 RocketMQ 入门》.md b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 RocketMQ 入门》.md new file mode 100644 index 0000000..ff68185 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mq/《芋道 Spring Boot 消息队列 RocketMQ 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/pom.xml b/aagro-framework/aagro-spring-boot-starter-mybatis/pom.xml new file mode 100644 index 0000000..dbea823 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/pom.xml @@ -0,0 +1,104 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-mybatis + jar + + ${project.artifactId} + 数据库连接池、多数据源、事务、MyBatis 拓展 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + cn.aagro.gg + aagro-spring-boot-starter-security + 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 + + + cn.com.kingbase + kingbase8 + true + + + org.opengauss + opengauss-jdbc + true + + + com.taosdata.jdbc + taos-jdbcdriver + true + + + + com.alibaba + druid-spring-boot-starter + + + com.baomidou + mybatis-plus-boot-starter + + + com.baomidou + mybatis-plus-jsqlparser-4.9 + + + com.baomidou + dynamic-datasource-spring-boot-starter + + + + com.github.yulichang + mybatis-plus-join-boot-starter + + + + com.fhs-opensource + easy-trans-spring-boot-starter + + + com.fhs-opensource + easy-trans-mybatis-plus-extend + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/config/AagroDataSourceAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/config/AagroDataSourceAutoConfiguration.java new file mode 100644 index 0000000..a01555f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/config/AagroDataSourceAutoConfiguration.java @@ -0,0 +1,40 @@ +package cn.aagro.pp.framework.datasource.config; + +import cn.aagro.pp.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 AagroDataSourceAutoConfiguration { + + /** + * 创建 DruidAdRemoveFilter 过滤器,过滤 common.js 的广告 + */ + @Bean + @ConditionalOnProperty(name = "spring.datasource.druid.stat-view-servlet.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/core/enums/DataSourceEnum.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/core/enums/DataSourceEnum.java new file mode 100644 index 0000000..9d6a895 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/core/enums/DataSourceEnum.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/core/filter/DruidAdRemoveFilter.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/core/filter/DruidAdRemoveFilter.java new file mode 100644 index 0000000..c72bef0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/core/filter/DruidAdRemoveFilter.java @@ -0,0 +1,38 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/package-info.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/package-info.java new file mode 100644 index 0000000..76bf4f6 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/datasource/package-info.java @@ -0,0 +1,5 @@ +/** + * 数据库连接池,采用 Druid + * 多数据源,采用爆米花 + */ +package cn.aagro.pp.framework.datasource; diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/config/AagroMybatisAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/config/AagroMybatisAutoConfiguration.java new file mode 100644 index 0000000..ce2ece5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/config/AagroMybatisAutoConfiguration.java @@ -0,0 +1,95 @@ +package cn.aagro.pp.framework.mybatis.config; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.mybatis.core.handler.DefaultDBFieldHandler; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.core.handlers.IJsonTypeHandler; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.baomidou.mybatisplus.extension.incrementer.*; +import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal; +import com.baomidou.mybatisplus.extension.parser.cache.JdkSerialCaffeineJsqlParseCache; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.annotations.Mapper; +import org.mybatis.spring.annotation.MapperScan; +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; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * MyBaits 配置类 + * + * @author 芋道源码 + */ +@AutoConfiguration(before = MybatisPlusAutoConfiguration.class) // 目的:先于 MyBatis Plus 自动配置,避免 @MapperScan 可能扫描不到 Mapper 打印 warn 日志 +@MapperScan(value = "${aagro.info.base-package}", annotationClass = Mapper.class, + lazyInitialization = "${mybatis.lazy-initialization:false}") // Mapper 懒加载,目前仅用于单元测试 +public class AagroMybatisAutoConfiguration { + + static { + // 动态 SQL 智能优化支持本地缓存加速解析,更完善的租户复杂 XML 动态 SQL 支持,静态注入缓存 + JsqlParserGlobal.setJsqlParseCache(new JdkSerialCaffeineJsqlParseCache( + (cache) -> cache.maximumSize(1024) + .expireAfterWrite(5, TimeUnit.SECONDS)) + ); + } + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); + mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 + // ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓ + // mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句 + 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)); + } + + @Bean // 特殊:返回结果使用 Object 而不用 JacksonTypeHandler 的原因,避免因为 JacksonTypeHandler 被 mybatis 全局使用! + public Object jacksonTypeHandler(List objectMappers) { + // 特殊:设置 JacksonTypeHandler 的 ObjectMapper! + ObjectMapper objectMapper = CollUtil.getFirst(objectMappers); + if (objectMapper == null) { + objectMapper = JsonUtils.getObjectMapper(); + } + JacksonTypeHandler.setObjectMapper(objectMapper); + return new JacksonTypeHandler(Object.class); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java new file mode 100644 index 0000000..dece206 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java @@ -0,0 +1,108 @@ +package cn.aagro.pp.framework.mybatis.config; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.collection.SetUtils; +import cn.aagro.pp.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); + + // 如果非 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、DM 达梦等直接自增的数据库 + 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; + case DM: + case KINGBASE_ES: + driverClass = "org.quartz.impl.jdbcjobstore.StdJDBCDelegate"; + 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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/dataobject/BaseDO.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/dataobject/BaseDO.java new file mode 100644 index 0000000..a1cff77 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/dataobject/BaseDO.java @@ -0,0 +1,66 @@ +package cn.aagro.pp.framework.mybatis.core.dataobject; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fhs.core.trans.vo.TransPojo; +import lombok.Data; +import org.apache.ibatis.type.JdbcType; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 基础实体对象 + * + * 为什么实现 {@link TransPojo} 接口? + * 因为使用 Easy-Trans TransType.SIMPLE 模式,集成 MyBatis Plus 查询 + * + * @author 芋道源码 + */ +@Data +@JsonIgnoreProperties(value = "transMap") // 由于 Easy-Trans 会添加 transMap 属性,避免 Jackson 在 Spring Cache 反序列化报错 +public abstract class BaseDO implements Serializable, TransPojo { + + /** + * 创建时间 + */ + @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; + + /** + * 把 creator、createTime、updateTime、updater 都清空,避免前端直接传递 creator 之类的字段,直接就被更新了 + */ + public void clean(){ + this.creator = null; + this.createTime = null; + this.updater = null; + this.updateTime = null; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/enums/DbTypeEnum.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/enums/DbTypeEnum.java new file mode 100644 index 0000000..924fc70 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/enums/DbTypeEnum.java @@ -0,0 +1,101 @@ +package cn.aagro.pp.framework.mybatis.core.enums; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.annotation.DbType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 针对 MyBatis Plus 的 {@link DbType} 增强,补充更多信息 + */ +@Getter +@AllArgsConstructor +public enum DbTypeEnum { + + /** + * H2 + * + * 注意:H2 不支持 find_in_set 函数 + */ + H2(DbType.H2, "H2", ""), + + /** + * MySQL + */ + MY_SQL(DbType.MYSQL, "MySQL", "FIND_IN_SET('#{value}', #{column}) <> 0"), + + /** + * Oracle + */ + ORACLE(DbType.ORACLE, "Oracle", "FIND_IN_SET('#{value}', #{column}) <> 0"), + + /** + * PostgreSQL + * + * 华为 openGauss 使用 ProductName 与 PostgreSQL 相同 + */ + POSTGRE_SQL(DbType.POSTGRE_SQL,"PostgreSQL", "POSITION('#{value}' IN #{column}) <> 0"), + + /** + * SQL Server + */ + SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"), + /** + * SQL Server 2005 + */ + SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"), + + /** + * 达梦 + */ + DM(DbType.DM, "DM DBMS", "FIND_IN_SET('#{value}', #{column}) <> 0"), + + /** + * 人大金仓 + */ + KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION('#{value}' IN #{column}) <> 0"), + + /** + * OceanBase + */ + OCEAN_BASE(DbType.OCEAN_BASE, "OceanBase", "FIND_IN_SET('#{value}', #{column}) <> 0") + + ; + + public static final Map MAP_BY_NAME = Arrays.stream(values()) + .collect(Collectors.toMap(DbTypeEnum::getProductName, Function.identity())); + + public static final Map MAP_BY_MP = Arrays.stream(values()) + .collect(Collectors.toMap(DbTypeEnum::getMpDbType, Function.identity())); + + /** + * MyBatis Plus 类型 + */ + private final DbType mpDbType; + /** + * 数据库产品名 + */ + private final String productName; + /** + * SQL FIND_IN_SET 模板 + */ + private final String findInSetTemplate; + + public static DbType find(String databaseProductName) { + if (StrUtil.isBlank(databaseProductName)) { + return null; + } + return MAP_BY_NAME.get(databaseProductName).getMpDbType(); + } + + public static String getFindInSetTemplate(DbType dbType) { + return Optional.of(MAP_BY_MP.get(dbType).getFindInSetTemplate()) + .orElseThrow(() -> new IllegalArgumentException("FIND_IN_SET not supported")); + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/handler/DefaultDBFieldHandler.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/handler/DefaultDBFieldHandler.java new file mode 100644 index 0000000..7b1bf78 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/handler/DefaultDBFieldHandler.java @@ -0,0 +1,63 @@ +package cn.aagro.pp.framework.mybatis.core.handler; + +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +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 + @SuppressWarnings("PatternVariableCanBeUsed") + 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 = SecurityFrameworkUtils.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 = SecurityFrameworkUtils.getLoginUserId(); + if (Objects.nonNull(userId) && Objects.isNull(modifier)) { + setFieldValByName("updater", userId.toString(), metaObject); + } + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/mapper/BaseMapperX.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/mapper/BaseMapperX.java new file mode 100644 index 0000000..dcf09b0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/mapper/BaseMapperX.java @@ -0,0 +1,226 @@ +package cn.aagro.pp.framework.mybatis.core.mapper; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.pojo.SortablePageParam; +import cn.aagro.pp.framework.common.pojo.SortingField; +import cn.aagro.pp.framework.mybatis.core.util.JdbcUtils; +import cn.aagro.pp.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.annotation.DbType; +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())) { + MyBatisUtils.addOrder(queryWrapper, sortingFields); + 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.getPageSize())) { + 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)); + } + + /** + * 获取满足条件的第 1 条记录 + * + * 目的:解决并发场景下,插入多条记录后,使用 selectOne 会报错的问题 + * + * @param field 字段名 + * @param value 字段值 + * @return 实体 + */ + default T selectFirstOne(SFunction field, Object value) { + // 如果明确使用 MySQL 等场景,可以考虑使用 LIMIT 1 进行优化 + List list = selectList(new LambdaQueryWrapper().eq(field, value)); + return CollUtil.getFirst(list); + } + + default T selectFirstOne(SFunction field1, Object value1, SFunction field2, Object value2) { + List list = selectList(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2)); + return CollUtil.getFirst(list); + } + + default T selectFirstOne(SFunction field1, Object value1, SFunction field2, Object value2, + SFunction field3, Object value3) { + List list = selectList(new LambdaQueryWrapper().eq(field1, value1).eq(field2, value2).eq(field3, value3)); + return CollUtil.getFirst(list); + } + + + 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)); + } + + 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) { + // 特殊:SQL Server 批量插入后,获取 id 会报错,因此通过循环处理 + DbType dbType = JdbcUtils.getDbType(); + if (JdbcUtils.isSQLServer(dbType)) { + entities.forEach(this::insert); + return CollUtil.isNotEmpty(entities); + } + return Db.saveBatch(entities); + } + + /** + * 批量插入,适合大量数据插入 + * + * @param entities 实体们 + * @param size 插入数量 Db.saveBatch 默认为 1000 + */ + default Boolean insertBatch(Collection entities, int size) { + // 特殊:SQL Server 批量插入后,获取 id 会报错,因此通过循环处理 + DbType dbType = JdbcUtils.getDbType(); + if (JdbcUtils.isSQLServer(dbType)) { + entities.forEach(this::insert); + return CollUtil.isNotEmpty(entities); + } + 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 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)); + } + + default int deleteBatch(SFunction field, Collection values) { + if (CollUtil.isEmpty(values)) { + return 0; + } + return delete(new LambdaQueryWrapper().in(field, values)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/LambdaQueryWrapperX.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/LambdaQueryWrapperX.java new file mode 100644 index 0000000..60b708b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/LambdaQueryWrapperX.java @@ -0,0 +1,135 @@ +package cn.aagro.pp.framework.mybatis.core.query; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/MPJLambdaWrapperX.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/MPJLambdaWrapperX.java new file mode 100644 index 0000000..df86925 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/MPJLambdaWrapperX.java @@ -0,0 +1,348 @@ +package cn.aagro.pp.framework.mybatis.core.query; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.aagro.pp.framework.common.util.collection.ArrayUtils; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +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 方法,用于判断值不存在的时候,不要拼接到条件中。 + * 2. SFunction column + 泛型:支持任意类字段(主表、子表、三表),推荐写法, 让编译器自动推断 S 类型 + * @param 数据类型 + */ +public class MPJLambdaWrapperX extends MPJLambdaWrapper { + + public MPJLambdaWrapperX likeIfPresent(SFunction column, String 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[] values) { + Object val1 = ArrayUtils.get(values, 0); + Object val2 = ArrayUtils.get(values, 1); + return betweenIfPresent(column, val1, val2); + } + + 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) super.ge(column, val1); + } + if (val2 != null) { + return (MPJLambdaWrapperX) super.le(column, val2); + } + return this; + } + + + // ========== 重写父类方法,方便链式调用 ========== + + @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) { + 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; + } + + // ========== 关键重写:使 leftJoin 返回当前类型 this ========== + @Override + public MPJLambdaWrapperX leftJoin(Class clazz, SFunction left, SFunction right) { + super.leftJoin(clazz, left, right); + return this; + } + + @Override + public MPJLambdaWrapperX rightJoin(Class clazz, SFunction left, SFunction right) { + super.rightJoin(clazz, left, right); + return this; + } + + @Override + public MPJLambdaWrapperX innerJoin(Class clazz, SFunction left, SFunction right) { + super.innerJoin(clazz, left, right); + return this; + } + + // ========== 添加扩展 Join 支持 ext 函数式参数 ========== + public MPJLambdaWrapperX leftJoin(Class clazz, SFunction left, SFunction right, Consumer> ext) { + super.leftJoin(clazz, left, right); + if (ext != null) ext.accept(this); + return this; + } + + public MPJLambdaWrapperX rightJoin(Class clazz, SFunction left, SFunction right, Consumer> ext) { + super.rightJoin(clazz, left, right); + if (ext != null) ext.accept(this); + return this; + } + + public MPJLambdaWrapperX innerJoin(Class clazz, SFunction left, SFunction right, Consumer> ext) { + super.innerJoin(clazz, left, right); + if (ext != null) ext.accept(this); + return this; + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/QueryWrapperX.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/QueryWrapperX.java new file mode 100644 index 0000000..6877c4b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/query/QueryWrapperX.java @@ -0,0 +1,166 @@ +package cn.aagro.pp.framework.mybatis.core.query; + +import cn.aagro.pp.framework.mybatis.core.util.JdbcUtils; +import com.baomidou.mybatisplus.annotation.DbType; +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) { + DbType dbType = JdbcUtils.getDbType(); + switch (dbType) { + case ORACLE: + case ORACLE_12C: + super.le("ROWNUM", n); + break; + case SQL_SERVER: + case SQL_SERVER2005: + super.select("TOP " + n + " *"); // 由于 SQL Server 是通过 SELECT TOP 1 实现限制一条,所以只好使用 * 查询剩余字段 + break; + default: // MySQL、PostgreSQL、DM 达梦、KingbaseES 大金都是采用 LIMIT 实现 + super.last("LIMIT " + n); + } + return this; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/EncryptTypeHandler.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/EncryptTypeHandler.java new file mode 100644 index 0000000..974d9a1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/EncryptTypeHandler.java @@ -0,0 +1,75 @@ +package cn.aagro.pp.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 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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/IntegerListTypeHandler.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/IntegerListTypeHandler.java new file mode 100644 index 0000000..37ae19a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/IntegerListTypeHandler.java @@ -0,0 +1,56 @@ +package cn.aagro.pp.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/LongListTypeHandler.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/LongListTypeHandler.java new file mode 100644 index 0000000..e171f3f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/LongListTypeHandler.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/LongSetTypeHandler.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/LongSetTypeHandler.java new file mode 100644 index 0000000..c5100b2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/LongSetTypeHandler.java @@ -0,0 +1,58 @@ +package cn.aagro.pp.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.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; +import java.util.Set; + +/** + * Set 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author 芋道源码 + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class LongSetTypeHandler implements TypeHandler> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, Set strings, JdbcType jdbcType) throws SQLException { + // 设置占位符 + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public Set getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public Set getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public Set getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private Set getResult(String value) { + if (value == null) { + return null; + } + return StrUtils.splitToLongSet(value, COMMA); + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/StringListTypeHandler.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/StringListTypeHandler.java new file mode 100644 index 0000000..94564a3 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/type/StringListTypeHandler.java @@ -0,0 +1,58 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/util/JdbcUtils.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/util/JdbcUtils.java new file mode 100644 index 0000000..a8895c6 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/util/JdbcUtils.java @@ -0,0 +1,89 @@ +package cn.aagro.pp.framework.mybatis.core.util; + +import cn.aagro.pp.framework.common.util.object.ObjectUtils; +import cn.aagro.pp.framework.common.util.spring.SpringUtils; +import cn.aagro.pp.framework.mybatis.core.enums.DbTypeEnum; +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.mybatisplus.annotation.DbType; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * 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) { + return com.baomidou.mybatisplus.extension.toolkit.JdbcUtils.getDbType(url); + } + + /** + * 通过当前数据库连接获得对应的 DB 类型 + * + * @return DB 类型 + */ + public static DbType getDbType() { + DataSource dataSource; + try { + DynamicRoutingDataSource dynamicRoutingDataSource = SpringUtils.getBean(DynamicRoutingDataSource.class); + dataSource = dynamicRoutingDataSource.determineDataSource(); + } catch (NoSuchBeanDefinitionException e) { + dataSource = SpringUtils.getBean(DataSource.class); + } + try (Connection conn = dataSource.getConnection()) { + return DbTypeEnum.find(conn.getMetaData().getDatabaseProductName()); + } catch (SQLException e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + /** + * 判断 JDBC 连接是否为 SQLServer 数据库 + * + * @param url JDBC 连接 + * @return 是否为 SQLServer 数据库 + */ + public static boolean isSQLServer(String url) { + DbType dbType = getDbType(url); + return isSQLServer(dbType); + } + + /** + * 判断 JDBC 连接是否为 SQLServer 数据库 + * + * @param dbType DB 类型 + * @return 是否为 SQLServer 数据库 + */ + public static boolean isSQLServer(DbType dbType) { + return ObjectUtils.equalsAny(dbType, DbType.SQL_SERVER, DbType.SQL_SERVER2005); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/util/MyBatisUtils.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/util/MyBatisUtils.java new file mode 100644 index 0000000..77cb6e7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/core/util/MyBatisUtils.java @@ -0,0 +1,156 @@ +package cn.aagro.pp.framework.mybatis.core.util; + +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.StrUtil; +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.pojo.SortingField; +import cn.aagro.pp.framework.mybatis.core.enums.DbTypeEnum; +import com.baomidou.mybatisplus.annotation.DbType; +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.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; + +/** + * 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 (CollUtil.isNotEmpty(sortingFields)) { + for (SortingField sortingField : sortingFields) { + page.addOrder(new OrderItem().setAsc(SortingField.ORDER_ASC.equals(sortingField.getOrder())) + .setColumn(StrUtil.toUnderlineCase(sortingField.getField()))); + } + } + return page; + } + + @SuppressWarnings("PatternVariableCanBeUsed") + public static void addOrder(Wrapper wrapper, Collection sortingFields) { + if (CollUtil.isEmpty(sortingFields)) { + return; + } + if (wrapper instanceof QueryWrapper) { + QueryWrapper query = (QueryWrapper) wrapper; + for (SortingField sortingField : sortingFields) { + query.orderBy(true, + SortingField.ORDER_ASC.equals(sortingField.getOrder()), + StrUtil.toUnderlineCase(sortingField.getField())); + } + } else if (wrapper instanceof LambdaQueryWrapper) { + // LambdaQueryWrapper 不直接支持字符串字段排序,使用 last 方法拼接 ORDER BY + LambdaQueryWrapper lambdaQuery = (LambdaQueryWrapper) wrapper; + StringBuilder orderBy = new StringBuilder(); + for (SortingField sortingField : sortingFields) { + if (StrUtil.isNotEmpty(orderBy)) { + orderBy.append(", "); + } + orderBy.append(StrUtil.toUnderlineCase(sortingField.getField())) + .append(" ") + .append(SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? "ASC" : "DESC"); + } + lambdaQuery.last("ORDER BY " + orderBy); + // 另外个思路:https://blog.csdn.net/m0_59084856/article/details/138450913 + } else { + throw new IllegalArgumentException("Unsupported wrapper type: " + wrapper.getClass().getName()); + } + + } + + /** + * 将拦截器添加到链中 + * 由于 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); + } + + /** + * 跨数据库的 find_in_set 实现 + * + * @param column 字段名称 + * @param value 查询值(不带单引号) + * @return sql + */ + public static String findInSet(String column, Object value) { + DbType dbType = JdbcUtils.getDbType(); + return DbTypeEnum.getFindInSetTemplate(dbType) + .replace("#{column}", column) + .replace("#{value}", StrUtil.toString(value)); + } + + /** + * 将驼峰命名转换为下划线命名 + * + * 使用场景: + * 1. fix:修复"商品统计聚合函数的别名与排序字段不符"导致的 SQL 异常 + * + * @param func 字段名函数(驼峰命名) + * @return 字段名(下划线命名) + */ + public static String toUnderlineCase(Func1 func) { + String fieldName = LambdaUtil.getFieldName(func); + return StrUtil.toUnderlineCase(fieldName); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/package-info.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/package-info.java new file mode 100644 index 0000000..5c30774 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/mybatis/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 MyBatis Plus 提升使用 MyBatis 的开发效率 + */ +package cn.aagro.pp.framework.mybatis; diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/config/AagroTranslateAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/config/AagroTranslateAutoConfiguration.java new file mode 100644 index 0000000..1463fd4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/config/AagroTranslateAutoConfiguration.java @@ -0,0 +1,18 @@ +package cn.aagro.pp.framework.translate.config; + +import cn.aagro.pp.framework.translate.core.TranslateUtils; +import com.fhs.trans.service.impl.TransService; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class AagroTranslateAutoConfiguration { + + @Bean + @SuppressWarnings({"InstantiationOfUtilityClass", "SpringJavaInjectionPointsAutowiringInspection"}) + public TranslateUtils translateUtils(TransService transService) { + TranslateUtils.init(transService); + return new TranslateUtils(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/core/TranslateUtils.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/core/TranslateUtils.java new file mode 100644 index 0000000..aae4d6e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/core/TranslateUtils.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.framework.translate.core; + +import cn.hutool.core.collection.CollUtil; +import com.fhs.core.trans.vo.VO; +import com.fhs.trans.service.impl.TransService; + +import java.util.List; + +/** + * VO 数据翻译 Utils + * + * @author 芋道源码 + */ +public class TranslateUtils { + + private static TransService transService; + + public static void init(TransService transService) { + TranslateUtils.transService = transService; + } + + /** + * 数据翻译 + * + * 使用场景:无法使用 @TransMethodResult 注解的场景,只能通过手动触发翻译 + * + * @param data 数据 + * @return 翻译结果 + */ + public static List translate(List data) { + if (CollUtil.isNotEmpty((data))) { + transService.transBatch(data); + } + return data; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/package-info.java b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/package-info.java new file mode 100644 index 0000000..f739fb4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/java/cn/aagro/pp/framework/translate/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 Easy-Trans 提升使用 VO 数据翻译的开发效率 + */ +package cn.aagro.pp.framework.translate; diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/resources/META-INF/spring.factories b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..b933be2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + cn.aagro.pp.framework.mybatis.config.IdTypeEnvironmentPostProcessor diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..ce0049b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +cn.aagro.pp.framework.datasource.config.AagroDataSourceAutoConfiguration +cn.aagro.pp.framework.mybatis.config.AagroMybatisAutoConfiguration +cn.aagro.pp.framework.translate.config.AagroTranslateAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot MyBatis 入门》.md b/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot MyBatis 入门》.md new file mode 100644 index 0000000..987a33b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot MyBatis 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot 多数据源(读写分离)入门》.md b/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot 多数据源(读写分离)入门》.md new file mode 100644 index 0000000..20a3216 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot 多数据源(读写分离)入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot 数据库连接池入门》.md b/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot 数据库连接池入门》.md new file mode 100644 index 0000000..69989f0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-mybatis/《芋道 Spring Boot 数据库连接池入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-protection/pom.xml b/aagro-framework/aagro-spring-boot-starter-protection/pom.xml new file mode 100644 index 0000000..4af3d60 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/pom.xml @@ -0,0 +1,47 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-protection + jar + + ${project.artifactId} + 服务保证,提供分布式锁、幂等、限流、熔断、API 签名等等功能 + https://github.com/YunaiV/ruoyi-vue-pro + + + + + cn.aagro.gg + aagro-spring-boot-starter-web + provided + + + + + cn.aagro.gg + aagro-spring-boot-starter-redis + + + + + com.baomidou + lock4j-redisson-spring-boot-starter + true + + + + + cn.aagro.gg + aagro-spring-boot-starter-test + test + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/config/AagroIdempotentConfiguration.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/config/AagroIdempotentConfiguration.java new file mode 100644 index 0000000..4b2bafe --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/config/AagroIdempotentConfiguration.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.idempotent.config; + +import cn.aagro.pp.framework.idempotent.core.aop.IdempotentAspect; +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.redis.IdempotentRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +@AutoConfiguration(after = AagroRedisAutoConfiguration.class) +public class AagroIdempotentConfiguration { + + @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 UserIdempotentKeyResolver userIdempotentKeyResolver() { + return new UserIdempotentKeyResolver(); + } + + @Bean + public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() { + return new ExpressionIdempotentKeyResolver(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/annotation/Idempotent.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/annotation/Idempotent.java new file mode 100644 index 0000000..6da7998 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/annotation/Idempotent.java @@ -0,0 +1,63 @@ +package cn.aagro.pp.framework.idempotent.core.annotation; + +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver; + +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 解析器 + * + * @see DefaultIdempotentKeyResolver 全局级别 + * @see UserIdempotentKeyResolver 用户级别 + * @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算 + */ + Class keyResolver() default DefaultIdempotentKeyResolver.class; + /** + * 使用的 Key 参数 + */ + String keyArg() default ""; + + /** + * 删除 Key,当发生异常时候 + * + * 问题:为什么发生异常时,需要删除 Key 呢? + * 回答:发生异常时,说明业务发生错误,此时需要删除 Key,避免下次请求无法正常执行。 + * + * 问题:为什么不搞 deleteWhenSuccess 执行成功时,需要删除 Key 呢? + * 回答:这种情况下,本质上是分布式锁,推荐使用 @Lock4j 注解 + */ + boolean deleteKeyWhenException() default true; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/aop/IdempotentAspect.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/aop/IdempotentAspect.java new file mode 100644 index 0000000..4cfeb4a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/aop/IdempotentAspect.java @@ -0,0 +1,68 @@ +package cn.aagro.pp.framework.idempotent.core.aop; + +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.util.collection.CollectionUtils; +import cn.aagro.pp.framework.idempotent.core.annotation.Idempotent; +import cn.aagro.pp.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import cn.aagro.pp.framework.idempotent.core.redis.IdempotentRedisDAO; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +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; + } + + @Around(value = "@annotation(idempotent)") + public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { + // 获得 IdempotentKeyResolver + IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver()); + Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver"); + // 解析 Key + String key = keyResolver.resolver(joinPoint, idempotent); + + // 1. 锁定 Key + boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit()); + // 锁定失败,抛出异常 + if (!success) { + log.info("[aroundPointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs()); + throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message()); + } + + // 2. 执行逻辑 + try { + return joinPoint.proceed(); + } catch (Throwable throwable) { + // 3. 异常时,删除 Key + // 参考美团 GTIS 思路:https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html + if (idempotent.deleteKeyWhenException()) { + idempotentRedisDAO.delete(key); + } + throw throwable; + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java new file mode 100644 index 0000000..6bbcf51 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.framework.idempotent.core.keyresolver; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java new file mode 100644 index 0000000..8258db7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java @@ -0,0 +1,25 @@ +package cn.aagro.pp.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.aagro.pp.framework.common.util.string.StrUtils; +import cn.aagro.pp.framework.idempotent.core.annotation.Idempotent; +import cn.aagro.pp.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 = StrUtils.joinMethodArgs(joinPoint); + return SecureUtil.md5(methodName + argsStr); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java new file mode 100644 index 0000000..40fe8c6 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java @@ -0,0 +1,63 @@ +package cn.aagro.pp.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.core.util.ArrayUtil; +import cn.aagro.pp.framework.idempotent.core.annotation.Idempotent; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java new file mode 100644 index 0000000..7f1195d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.aagro.pp.framework.common.util.string.StrUtils; +import cn.aagro.pp.framework.idempotent.core.annotation.Idempotent; +import cn.aagro.pp.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import org.aspectj.lang.JoinPoint; + +/** + * 用户级别的幂等 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author 芋道源码 + */ +public class UserIdempotentKeyResolver implements IdempotentKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, Idempotent idempotent) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtils.joinMethodArgs(joinPoint); + Long userId = WebFrameworkUtils.getLoginUserId(); + Integer userType = WebFrameworkUtils.getLoginUserType(); + return SecureUtil.md5(methodName + argsStr + userId + userType); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/redis/IdempotentRedisDAO.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/redis/IdempotentRedisDAO.java new file mode 100644 index 0000000..62dc76a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/core/redis/IdempotentRedisDAO.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.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); + } + + public void delete(String key) { + String redisKey = formatKey(key); + redisTemplate.delete(redisKey); + } + + private static String formatKey(String key) { + return String.format(IDEMPOTENT, key); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/package-info.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/idempotent/package-info.java new file mode 100644 index 0000000..be73393 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/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 cn.aagro.pp.framework.idempotent; diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/config/AagroLock4jConfiguration.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/config/AagroLock4jConfiguration.java new file mode 100644 index 0000000..6aa578c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/config/AagroLock4jConfiguration.java @@ -0,0 +1,18 @@ +package cn.aagro.pp.framework.lock4j.config; + +import cn.aagro.pp.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 AagroLock4jConfiguration { + + @Bean + public DefaultLockFailureStrategy lockFailureStrategy() { + return new DefaultLockFailureStrategy(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/core/DefaultLockFailureStrategy.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/core/DefaultLockFailureStrategy.java new file mode 100644 index 0000000..4f56097 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/core/DefaultLockFailureStrategy.java @@ -0,0 +1,21 @@ +package cn.aagro.pp.framework.lock4j.core; + +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/core/Lock4jRedisKeyConstants.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/core/Lock4jRedisKeyConstants.java new file mode 100644 index 0000000..c7858cc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/core/Lock4jRedisKeyConstants.java @@ -0,0 +1,19 @@ +package cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/package-info.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/package-info.java new file mode 100644 index 0000000..7cdb36a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/lock4j/package-info.java @@ -0,0 +1,4 @@ +/** + * 分布式锁组件,使用 https://gitee.com/baomidou/lock4j 开源项目 + */ +package cn.aagro.pp.framework.lock4j; diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/config/AagroRateLimiterConfiguration.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/config/AagroRateLimiterConfiguration.java new file mode 100644 index 0000000..515f729 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/config/AagroRateLimiterConfiguration.java @@ -0,0 +1,55 @@ +package cn.aagro.pp.framework.ratelimiter.config; + +import cn.aagro.pp.framework.ratelimiter.core.aop.RateLimiterAspect; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl.*; +import cn.aagro.pp.framework.ratelimiter.core.redis.RateLimiterRedisDAO; +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +import org.redisson.api.RedissonClient; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +@AutoConfiguration(after = AagroRedisAutoConfiguration.class) +public class AagroRateLimiterConfiguration { + + @Bean + public RateLimiterAspect rateLimiterAspect(List keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) { + return new RateLimiterAspect(keyResolvers, rateLimiterRedisDAO); + } + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public RateLimiterRedisDAO rateLimiterRedisDAO(RedissonClient redissonClient) { + return new RateLimiterRedisDAO(redissonClient); + } + + // ========== 各种 RateLimiterRedisDAO Bean ========== + + @Bean + public DefaultRateLimiterKeyResolver defaultRateLimiterKeyResolver() { + return new DefaultRateLimiterKeyResolver(); + } + + @Bean + public UserRateLimiterKeyResolver userRateLimiterKeyResolver() { + return new UserRateLimiterKeyResolver(); + } + + @Bean + public ClientIpRateLimiterKeyResolver clientIpRateLimiterKeyResolver() { + return new ClientIpRateLimiterKeyResolver(); + } + + @Bean + public ServerNodeRateLimiterKeyResolver serverNodeRateLimiterKeyResolver() { + return new ServerNodeRateLimiterKeyResolver(); + } + + @Bean + public ExpressionRateLimiterKeyResolver expressionRateLimiterKeyResolver() { + return new ExpressionRateLimiterKeyResolver(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/annotation/RateLimiter.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/annotation/RateLimiter.java new file mode 100644 index 0000000..caae029 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/annotation/RateLimiter.java @@ -0,0 +1,62 @@ +package cn.aagro.pp.framework.ratelimiter.core.annotation; + +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl.ClientIpRateLimiterKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl.DefaultRateLimiterKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl.ServerNodeRateLimiterKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl.UserRateLimiterKeyResolver; + +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 RateLimiter { + + /** + * 限流的时间,默认为 1 秒 + */ + int time() default 1; + /** + * 时间单位,默认为 SECONDS 秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * 限流次数 + */ + int count() default 100; + + /** + * 提示信息,请求过快的提示 + * + * @see GlobalErrorCodeConstants#TOO_MANY_REQUESTS + */ + String message() default ""; // 为空时,使用 TOO_MANY_REQUESTS 错误提示 + + /** + * 使用的 Key 解析器 + * + * @see DefaultRateLimiterKeyResolver 全局级别 + * @see UserRateLimiterKeyResolver 用户 ID 级别 + * @see ClientIpRateLimiterKeyResolver 用户 IP 级别 + * @see ServerNodeRateLimiterKeyResolver 服务器 Node 级别 + * @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算 + */ + Class keyResolver() default DefaultRateLimiterKeyResolver.class; + /** + * 使用的 Key 参数 + */ + String keyArg() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/aop/RateLimiterAspect.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/aop/RateLimiterAspect.java new file mode 100644 index 0000000..688ea04 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/aop/RateLimiterAspect.java @@ -0,0 +1,60 @@ +package cn.aagro.pp.framework.ratelimiter.core.aop; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.util.collection.CollectionUtils; +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import cn.aagro.pp.framework.ratelimiter.core.redis.RateLimiterRedisDAO; +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 RateLimiter} 注解的方法,实现限流操作 + * + * @author 芋道源码 + */ +@Aspect +@Slf4j +public class RateLimiterAspect { + + /** + * RateLimiterKeyResolver 集合 + */ + private final Map, RateLimiterKeyResolver> keyResolvers; + + private final RateLimiterRedisDAO rateLimiterRedisDAO; + + public RateLimiterAspect(List keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) { + this.keyResolvers = CollectionUtils.convertMap(keyResolvers, RateLimiterKeyResolver::getClass); + this.rateLimiterRedisDAO = rateLimiterRedisDAO; + } + + @Before("@annotation(rateLimiter)") + public void beforePointCut(JoinPoint joinPoint, RateLimiter rateLimiter) { + // 获得 RateLimiterKeyResolver 对象 + RateLimiterKeyResolver keyResolver = keyResolvers.get(rateLimiter.keyResolver()); + Assert.notNull(keyResolver, "找不到对应的 RateLimiterKeyResolver"); + // 解析 Key + String key = keyResolver.resolver(joinPoint, rateLimiter); + + // 获取 1 次限流 + boolean success = rateLimiterRedisDAO.tryAcquire(key, + rateLimiter.count(), rateLimiter.time(), rateLimiter.timeUnit()); + if (!success) { + log.info("[beforePointCut][方法({}) 参数({}) 请求过于频繁]", joinPoint.getSignature().toString(), joinPoint.getArgs()); + String message = StrUtil.blankToDefault(rateLimiter.message(), + GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getMsg()); + throw new ServiceException(GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getCode(), message); + } + } + +} + diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java new file mode 100644 index 0000000..680b0ea --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.framework.ratelimiter.core.keyresolver; + +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import org.aspectj.lang.JoinPoint; + +/** + * 限流 Key 解析器接口 + * + * @author 芋道源码 + */ +public interface RateLimiterKeyResolver { + + /** + * 解析一个 Key + * + * @param rateLimiter 限流注解 + * @param joinPoint AOP 切面 + * @return Key + */ + String resolver(JoinPoint joinPoint, RateLimiter rateLimiter); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java new file mode 100644 index 0000000..65e3109 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.common.util.string.StrUtils; +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * IP 级别的限流 Key 解析器,使用方法名 + 方法参数 + IP,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author 芋道源码 + */ +public class ClientIpRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtils.joinMethodArgs(joinPoint); + String clientIp = ServletUtils.getClientIP(); + return SecureUtil.md5(methodName + argsStr + clientIp); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java new file mode 100644 index 0000000..bad1879 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java @@ -0,0 +1,25 @@ +package cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.aagro.pp.framework.common.util.string.StrUtils; +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * 默认(全局级别)限流 Key 解析器,使用方法名 + 方法参数,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author 芋道源码 + */ +public class DefaultRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtils.joinMethodArgs(joinPoint); + return SecureUtil.md5(methodName + argsStr); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java new file mode 100644 index 0000000..4001db5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java @@ -0,0 +1,64 @@ +package cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.core.util.ArrayUtil; +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +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.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 表达式的 {@link RateLimiterKeyResolver} 实现类 + * + * @author 芋道源码 + */ +public class ExpressionRateLimiterKeyResolver implements RateLimiterKeyResolver { + + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + // 获得被拦截方法参数名列表 + 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(rateLimiter.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/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java new file mode 100644 index 0000000..97efd79 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.hutool.system.SystemUtil; +import cn.aagro.pp.framework.common.util.string.StrUtils; +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * Server 节点级别的限流 Key 解析器,使用方法名 + 方法参数 + IP,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author 芋道源码 + */ +public class ServerNodeRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtils.joinMethodArgs(joinPoint); + String serverNode = String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); + return SecureUtil.md5(methodName + argsStr + serverNode); + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java new file mode 100644 index 0000000..37aff3d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.aagro.pp.framework.common.util.string.StrUtils; +import cn.aagro.pp.framework.ratelimiter.core.annotation.RateLimiter; +import cn.aagro.pp.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import org.aspectj.lang.JoinPoint; + +/** + * 用户级别的限流 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author 芋道源码 + */ +public class UserRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtils.joinMethodArgs(joinPoint); + Long userId = WebFrameworkUtils.getLoginUserId(); + Integer userType = WebFrameworkUtils.getLoginUserType(); + return SecureUtil.md5(methodName + argsStr + userId + userType); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java new file mode 100644 index 0000000..b513901 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java @@ -0,0 +1,66 @@ +package cn.aagro.pp.framework.ratelimiter.core.redis; + +import lombok.AllArgsConstructor; +import org.redisson.api.*; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * 限流 Redis DAO + * + * @author 芋道源码 + */ +@AllArgsConstructor +public class RateLimiterRedisDAO { + + /** + * 限流操作 + * + * KEY 格式:rate_limiter:%s // 参数为 uuid + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String RATE_LIMITER = "rate_limiter:%s"; + + private final RedissonClient redissonClient; + + public Boolean tryAcquire(String key, int count, int time, TimeUnit timeUnit) { + // 1. 获得 RRateLimiter,并设置 rate 速率 + RRateLimiter rateLimiter = getRRateLimiter(key, count, time, timeUnit); + // 2. 尝试获取 1 个 + return rateLimiter.tryAcquire(); + } + + private static String formatKey(String key) { + return String.format(RATE_LIMITER, key); + } + + private RRateLimiter getRRateLimiter(String key, long count, int time, TimeUnit timeUnit) { + String redisKey = formatKey(key); + RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisKey); + long rateInterval = timeUnit.toSeconds(time); + Duration duration = Duration.ofSeconds(rateInterval); + // 1. 如果不存在,设置 rate 速率 + RateLimiterConfig config = rateLimiter.getConfig(); + if (config == null) { + rateLimiter.trySetRate(RateType.OVERALL, count, duration); + // 原因参见 https://t.zsxq.com/lcR0W + rateLimiter.expire(duration); + return rateLimiter; + } + // 2. 如果存在,并且配置相同,则直接返回 + if (config.getRateType() == RateType.OVERALL + && Objects.equals(config.getRate(), count) + && Objects.equals(config.getRateInterval(), TimeUnit.SECONDS.toMillis(rateInterval))) { + return rateLimiter; + } + // 3. 如果存在,并且配置不同,则进行新建 + rateLimiter.setRate(RateType.OVERALL, count, duration); + // 原因参见 https://t.zsxq.com/lcR0W + rateLimiter.expire(duration); + return rateLimiter; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/package-info.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/package-info.java new file mode 100644 index 0000000..1ee4c85 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/ratelimiter/package-info.java @@ -0,0 +1,4 @@ +/** + * 限流组件,基于 Redisson {@link org.redisson.api.RRateLimiter} 限流实现 + */ +package cn.aagro.pp.framework.ratelimiter; \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/config/AagroApiSignatureAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/config/AagroApiSignatureAutoConfiguration.java new file mode 100644 index 0000000..dc27d05 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/config/AagroApiSignatureAutoConfiguration.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.framework.signature.config; + +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +import cn.aagro.pp.framework.signature.core.aop.ApiSignatureAspect; +import cn.aagro.pp.framework.signature.core.redis.ApiSignatureRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * HTTP API 签名的自动配置类 + * + * @author Zhougang + */ +@AutoConfiguration(after = AagroRedisAutoConfiguration.class) +public class AagroApiSignatureAutoConfiguration { + + @Bean + public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) { + return new ApiSignatureAspect(signatureRedisDAO); + } + + @Bean + public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) { + return new ApiSignatureRedisDAO(stringRedisTemplate); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/annotation/ApiSignature.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/annotation/ApiSignature.java new file mode 100644 index 0000000..1546a44 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/annotation/ApiSignature.java @@ -0,0 +1,59 @@ +package cn.aagro.pp.framework.signature.core.annotation; + +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + + +/** + * HTTP API 签名注解 + * + * @author Zhougang + */ +@Inherited +@Documented +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiSignature { + + /** + * 同一个请求多长时间内有效 默认 60 秒 + */ + int timeout() default 60; + + /** + * 时间单位,默认为 SECONDS 秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + // ========================== 签名参数 ========================== + + /** + * 提示信息,签名失败的提示 + * + * @see GlobalErrorCodeConstants#BAD_REQUEST + */ + String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示 + + /** + * 签名字段:appId 应用ID + */ + String appId() default "appId"; + + /** + * 签名字段:timestamp 时间戳 + */ + String timestamp() default "timestamp"; + + /** + * 签名字段:nonce 随机数,10 位以上 + */ + String nonce() default "nonce"; + + /** + * sign 客户端签名 + */ + String sign() default "sign"; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/aop/ApiSignatureAspect.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/aop/ApiSignatureAspect.java new file mode 100644 index 0000000..0f5c8f0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/aop/ApiSignatureAspect.java @@ -0,0 +1,174 @@ +package cn.aagro.pp.framework.signature.core.aop; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.signature.core.annotation.ApiSignature; +import cn.aagro.pp.framework.signature.core.redis.ApiSignatureRedisDAO; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; + +/** + * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名 + * + * @author Zhougang + */ +@Aspect +@Slf4j +@AllArgsConstructor +public class ApiSignatureAspect { + + private final ApiSignatureRedisDAO signatureRedisDAO; + + @Before("@annotation(signature)") + public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) { + // 1. 验证通过,直接结束 + if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) { + return; + } + + // 2. 验证不通过,抛出异常 + log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(), + joinPoint.getArgs()); + throw new ServiceException(BAD_REQUEST.getCode(), + StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg())); + } + + public boolean verifySignature(ApiSignature signature, HttpServletRequest request) { + // 1.1 校验 Header + if (!verifyHeaders(signature, request)) { + return false; + } + // 1.2 校验 appId 是否能获取到对应的 appSecret + String appId = request.getHeader(signature.appId()); + String appSecret = signatureRedisDAO.getAppSecret(appId); + Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId); + + // 2. 校验签名【重要!】 + String clientSignature = request.getHeader(signature.sign()); // 客户端签名 + String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串 + String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名 + if (ObjUtil.notEqual(clientSignature, serverSignature)) { + return false; + } + + // 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 ) + String nonce = request.getHeader(signature.nonce()); + if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) { + String timestamp = request.getHeader(signature.timestamp()); + log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature); + throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求"); + } + return true; + } + + /** + * 校验请求头加签参数 + *

+ * 1. appId 是否为空 + * 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟 + * 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了 + * 4. sign 是否为空 + * + * @param signature signature + * @param request request + * @return 是否校验 Header 通过 + */ + private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) { + // 1. 非空校验 + String appId = request.getHeader(signature.appId()); + if (StrUtil.isBlank(appId)) { + return false; + } + String timestamp = request.getHeader(signature.timestamp()); + if (StrUtil.isBlank(timestamp)) { + return false; + } + String nonce = request.getHeader(signature.nonce()); + if (StrUtil.length(nonce) < 10) { + return false; + } + String sign = request.getHeader(signature.sign()); + if (StrUtil.isBlank(sign)) { + return false; + } + + // 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值) + long expireTime = signature.timeUnit().toMillis(signature.timeout()); + long requestTimestamp = Long.parseLong(timestamp); + long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp); + if (timestampDisparity > expireTime) { + return false; + } + + // 3. 检查 nonce 是否存在,有且仅能使用一次 + return signatureRedisDAO.getNonce(appId, nonce) == null; + } + + /** + * 构建签名字符串 + *

+ * 格式为 = 请求参数 + 请求体 + 请求头 + 密钥 + * + * @param signature signature + * @param request request + * @param appSecret appSecret + * @return 签名字符串 + */ + private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) { + SortedMap parameterMap = getRequestParameterMap(request); // 请求头 + SortedMap headerMap = getRequestHeaderMap(signature, request); // 请求参数 + String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体 + return MapUtil.join(parameterMap, "&", "=") + + requestBody + + MapUtil.join(headerMap, "&", "=") + + appSecret; + } + + /** + * 获取请求头加签参数 Map + * + * @param request 请求 + * @param signature 签名注解 + * @return signature params + */ + private static SortedMap getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) { + SortedMap sortedMap = new TreeMap<>(); + sortedMap.put(signature.appId(), request.getHeader(signature.appId())); + sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp())); + sortedMap.put(signature.nonce(), request.getHeader(signature.nonce())); + return sortedMap; + } + + /** + * 获取请求参数 Map + * + * @param request 请求 + * @return queryParams + */ + private static SortedMap getRequestParameterMap(HttpServletRequest request) { + SortedMap sortedMap = new TreeMap<>(); + for (Map.Entry entry : request.getParameterMap().entrySet()) { + sortedMap.put(entry.getKey(), entry.getValue()[0]); + } + return sortedMap; + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/redis/ApiSignatureRedisDAO.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/redis/ApiSignatureRedisDAO.java new file mode 100644 index 0000000..74485fa --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/core/redis/ApiSignatureRedisDAO.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.framework.signature.core.redis; + +import lombok.AllArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * HTTP API 签名 Redis DAO + * + * @author Zhougang + */ +@AllArgsConstructor +public class ApiSignatureRedisDAO { + + private final StringRedisTemplate stringRedisTemplate; + + /** + * 验签随机数 + *

+ * KEY 格式:signature_nonce:%s // 参数为 随机数 + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s"; + + /** + * 签名密钥 + *

+ * HASH 结构 + * KEY 格式:%s // 参数为 appid + * VALUE 格式:String + * 过期时间:永不过期(预加载到 Redis) + */ + private static final String SIGNATURE_APPID = "api_signature_app"; + + // ========== 验签随机数 ========== + + public String getNonce(String appId, String nonce) { + return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce)); + } + + public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) { + return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit); + } + + private static String formatNonceKey(String appId, String nonce) { + return String.format(SIGNATURE_NONCE, appId, nonce); + } + + // ========== 签名密钥 ========== + + public String getAppSecret(String appId) { + return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/package-info.java b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/package-info.java new file mode 100644 index 0000000..379f32b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-protection/src/main/java/cn/aagro/pp/framework/signature/package-info.java @@ -0,0 +1,6 @@ +/** + * HTTP API 签名,校验安全性 + * + * @see builder() + .put("v1", new String[]{"k1"}).put("k1", new String[]{"v1"}).build()); + when(request.getContentType()).thenReturn("application/json"); + when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test"))); + // mock 方法 + when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret); + when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true); + + // 调用 + boolean result = apiSignatureAspect.verifySignature(apiSignature, request); + // 断言结果 + assertTrue(result); + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-redis/pom.xml b/aagro-framework/aagro-spring-boot-starter-redis/pom.xml new file mode 100644 index 0000000..b49c8b1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/pom.xml @@ -0,0 +1,45 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-redis + jar + + ${project.artifactId} + Redis 封装拓展 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.redisson + redisson-spring-boot-starter + + + org.redisson + redisson-spring-data-27 + + + + org.springframework.boot + spring-boot-starter-cache + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroCacheAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroCacheAutoConfiguration.java new file mode 100644 index 0000000..589e278 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroCacheAutoConfiguration.java @@ -0,0 +1,82 @@ +package cn.aagro.pp.framework.redis.config; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.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 cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration.buildRedisSerializer; + +/** + * Cache 配置类,基于 Redis 实现 + */ +@AutoConfiguration +@EnableConfigurationProperties({CacheProperties.class, AagroCacheProperties.class}) +@EnableCaching +public class AagroCacheAutoConfiguration { + + /** + * 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/aagro-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, + AagroCacheProperties aagroCacheProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(aagroCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroCacheProperties.java b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroCacheProperties.java new file mode 100644 index 0000000..e337e9e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroCacheProperties.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.redis.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Cache 配置项 + * + * @author Wanwan + */ +@ConfigurationProperties("aagro.cache") +@Data +@Validated +public class AagroCacheProperties { + + /** + * {@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/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroRedisAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroRedisAutoConfiguration.java new file mode 100644 index 0000000..61d895b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/config/AagroRedisAutoConfiguration.java @@ -0,0 +1,45 @@ +package cn.aagro.pp.framework.redis.config; + +import cn.hutool.core.util.ReflectUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.redisson.spring.starter.RedissonAutoConfiguration; +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(before = RedissonAutoConfiguration.class) // 目的:使用自己定义的 RedisTemplate Bean +public class AagroRedisAutoConfiguration { + + /** + * 创建 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/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/core/TimeoutRedisCacheManager.java b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/core/TimeoutRedisCacheManager.java new file mode 100644 index 0000000..d4b7379 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/core/TimeoutRedisCacheManager.java @@ -0,0 +1,86 @@ +package cn.aagro.pp.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) { + // 移除 # 后面的 : 以及后面的内容,避免影响解析 + String ttlStr = StrUtil.subBefore(names[1], StrUtil.COLON, false); // 获得 ttlStr 时间部分 + names[1] = StrUtil.subAfter(names[1], ttlStr, false); // 移除掉 ttlStr 时间部分 + // 解析时间 + Duration duration = parseDuration(ttlStr); + cacheConfig = cacheConfig.entryTtl(duration); + } + + // 创建 RedisCache 对象,需要忽略掉 ttlStr + return super.createRedisCache(names[0] + names[1], 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/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/package-info.java b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/package-info.java new file mode 100644 index 0000000..11f64a8 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/src/main/java/cn/aagro/pp/framework/redis/package-info.java @@ -0,0 +1,4 @@ +/** + * 采用 Spring Data Redis 操作 Redis,底层使用 Redisson 作为客户端 + */ +package cn.aagro.pp.framework.redis; diff --git a/aagro-framework/aagro-spring-boot-starter-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..4085d5e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration +cn.aagro.pp.framework.redis.config.AagroCacheAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-redis/《芋道 Spring Boot Cache 入门》.md b/aagro-framework/aagro-spring-boot-starter-redis/《芋道 Spring Boot Cache 入门》.md new file mode 100644 index 0000000..2bfa7de --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/《芋道 Spring Boot Cache 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-redis/《芋道 Spring Boot Redis 入门》.md b/aagro-framework/aagro-spring-boot-starter-redis/《芋道 Spring Boot Redis 入门》.md new file mode 100644 index 0000000..f8c9c78 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-redis/《芋道 Spring Boot Redis 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-security/pom.xml b/aagro-framework/aagro-spring-boot-starter-security/pom.xml new file mode 100644 index 0000000..37ca3bc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/pom.xml @@ -0,0 +1,64 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-security + jar + + ${project.artifactId} + + 1. security:用户的认证、权限的校验,实现「谁」可以做「什么事」 + 2. operatelog:操作日志,实现「谁」在「什么时间」对「什么」做了「什么事」 + + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + cn.aagro.gg + aagro-spring-boot-starter-web + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-security + + + + + com.google.guava + guava + + + + + + io.github.mouzt + bizlog-sdk + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/config/AagroOperateLogConfiguration.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/config/AagroOperateLogConfiguration.java new file mode 100644 index 0000000..b72026f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/config/AagroOperateLogConfiguration.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.operatelog.config; + +import cn.aagro.pp.framework.operatelog.core.service.LogRecordServiceImpl; +import com.mzt.logapi.service.ILogRecordService; +import com.mzt.logapi.starter.annotation.EnableLogRecord; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +/** + * 操作日志配置类 + * + * @author HUIHUI + */ +@EnableLogRecord(tenant = "") // 貌似用不上 tenant 这玩意给个空好啦 +@AutoConfiguration +@Slf4j +public class AagroOperateLogConfiguration { + + @Bean + @Primary + public ILogRecordService iLogRecordServiceImpl() { + return new LogRecordServiceImpl(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/core/package-info.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/core/package-info.java new file mode 100644 index 0000000..e77527c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,无特殊作用 + */ +package cn.aagro.pp.framework.operatelog.core; \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/core/service/LogRecordServiceImpl.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/core/service/LogRecordServiceImpl.java new file mode 100644 index 0000000..9bbebd3 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/core/service/LogRecordServiceImpl.java @@ -0,0 +1,91 @@ +package cn.aagro.pp.framework.operatelog.core.service; + +import cn.aagro.pp.framework.common.biz.system.logger.OperateLogCommonApi; +import cn.aagro.pp.framework.common.biz.system.logger.dto.OperateLogCreateReqDTO; +import cn.aagro.pp.framework.common.util.monitor.TracerUtils; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import com.mzt.logapi.beans.LogRecord; +import com.mzt.logapi.service.ILogRecordService; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +/** + * 操作日志 ILogRecordService 实现类 + * + * 基于 {@link OperateLogCommonApi} 实现,记录操作日志 + * + * @author HUIHUI + */ +@Slf4j +public class LogRecordServiceImpl implements ILogRecordService { + + @Resource + private OperateLogCommonApi operateLogApi; + + @Override + public void record(LogRecord logRecord) { + OperateLogCreateReqDTO reqDTO = new OperateLogCreateReqDTO(); + try { + reqDTO.setTraceId(TracerUtils.getTraceId()); + // 补充用户信息 + fillUserFields(reqDTO); + // 补全模块信息 + fillModuleFields(reqDTO, logRecord); + // 补全请求信息 + fillRequestFields(reqDTO); + + // 2. 异步记录日志 + operateLogApi.createOperateLogAsync(reqDTO); + } catch (Throwable ex) { + // 由于 @Async 异步调用,这里打印下日志,更容易跟进 + log.error("[record][url({}) log({}) 发生异常]", reqDTO.getRequestUrl(), reqDTO, ex); + } + } + + private static void fillUserFields(OperateLogCreateReqDTO reqDTO) { + // 使用 SecurityFrameworkUtils。因为要考虑,rpc、mq、job,它其实不是 web; + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return; + } + reqDTO.setUserId(loginUser.getId()); + reqDTO.setUserType(loginUser.getUserType()); + } + + public static void fillModuleFields(OperateLogCreateReqDTO reqDTO, LogRecord logRecord) { + reqDTO.setType(logRecord.getType()); // 大模块类型,例如:CRM 客户 + reqDTO.setSubType(logRecord.getSubType());// 操作名称,例如:转移客户 + reqDTO.setBizId(Long.parseLong(logRecord.getBizNo())); // 业务编号,例如:客户编号 + reqDTO.setAction(logRecord.getAction());// 操作内容,例如:修改编号为 1 的用户信息,将性别从男改成女,将姓名从芋道改成源码。 + reqDTO.setExtra(logRecord.getExtra()); // 拓展字段,有些复杂的业务,需要记录一些字段 ( JSON 格式 ),例如说,记录订单编号,{ orderId: "1"} + } + + private static void fillRequestFields(OperateLogCreateReqDTO reqDTO) { + // 获得 Request 对象 + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + // 补全请求信息 + reqDTO.setRequestMethod(request.getMethod()); + reqDTO.setRequestUrl(request.getRequestURI()); + reqDTO.setUserIp(ServletUtils.getClientIP(request)); + reqDTO.setUserAgent(ServletUtils.getUserAgent(request)); + } + + @Override + public List queryLog(String bizNo, String type) { + throw new UnsupportedOperationException("使用 OperateLogApi 进行操作日志的查询"); + } + + @Override + public List queryLogByBizNo(String bizNo, String type, String subType) { + throw new UnsupportedOperationException("使用 OperateLogApi 进行操作日志的查询"); + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/package-info.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/package-info.java new file mode 100644 index 0000000..d86eb07 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/operatelog/package-info.java @@ -0,0 +1,7 @@ +/** + * 基于 mzt-log 框架 + * 实现操作日志功能 + * + * @author HUIHUI + */ +package cn.aagro.pp.framework.operatelog; diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AagroSecurityAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AagroSecurityAutoConfiguration.java new file mode 100644 index 0000000..ed143c8 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AagroSecurityAutoConfiguration.java @@ -0,0 +1,94 @@ +package cn.aagro.pp.framework.security.config; + +import cn.aagro.pp.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; +import cn.aagro.pp.framework.common.biz.system.permission.PermissionCommonApi; +import cn.aagro.pp.framework.security.core.context.TransmittableThreadLocalSecurityContextHolderStrategy; +import cn.aagro.pp.framework.security.core.filter.TokenAuthenticationFilter; +import cn.aagro.pp.framework.security.core.handler.AccessDeniedHandlerImpl; +import cn.aagro.pp.framework.security.core.handler.AuthenticationEntryPointImpl; +import cn.aagro.pp.framework.security.core.service.SecurityFrameworkService; +import cn.aagro.pp.framework.security.core.service.SecurityFrameworkServiceImpl; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +import javax.annotation.Resource; +import org.springframework.beans.factory.config.MethodInvokingFactoryBean; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; + +/** + * Spring Security 自动配置类,主要用于相关组件的配置 + * + * 注意,不能和 {@link AagroWebSecurityConfigurerAdapter} 用一个,原因是会导致初始化报错。 + * 参见 https://stackoverflow.com/questions/53847050/spring-boot-delegatebuilder-cannot-be-null-on-autowiring-authenticationmanager 文档。 + * + * @author 芋道源码 + */ +@AutoConfiguration +@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效 +@EnableConfigurationProperties(SecurityProperties.class) +public class AagroSecurityAutoConfiguration { + + @Resource + private SecurityProperties securityProperties; + + /** + * 认证失败处理类 Bean + */ + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new AuthenticationEntryPointImpl(); + } + + /** + * 权限不够处理器 Bean + */ + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new AccessDeniedHandlerImpl(); + } + + /** + * Spring Security 加密器 + * 考虑到安全性,这里采用 BCryptPasswordEncoder 加密器 + * + * @see Password Encoding with Spring Security + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(securityProperties.getPasswordEncoderLength()); + } + + /** + * Token 认证过滤器 Bean + */ + @Bean + public TokenAuthenticationFilter authenticationTokenFilter(GlobalExceptionHandler globalExceptionHandler, + OAuth2TokenCommonApi oauth2TokenApi) { + return new TokenAuthenticationFilter(securityProperties, globalExceptionHandler, oauth2TokenApi); + } + + @Bean("ss") // 使用 Spring Security 的缩写,方便使用 + public SecurityFrameworkService securityFrameworkService(PermissionCommonApi permissionApi) { + return new SecurityFrameworkServiceImpl(permissionApi); + } + + /** + * 声明调用 {@link SecurityContextHolder#setStrategyName(String)} 方法, + * 设置使用 {@link TransmittableThreadLocalSecurityContextHolderStrategy} 作为 Security 的上下文策略 + */ + @Bean + public MethodInvokingFactoryBean securityContextHolderMethodInvokingFactoryBean() { + MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean(); + methodInvokingFactoryBean.setTargetClass(SecurityContextHolder.class); + methodInvokingFactoryBean.setTargetMethod("setStrategyName"); + methodInvokingFactoryBean.setArguments(TransmittableThreadLocalSecurityContextHolderStrategy.class.getName()); + return methodInvokingFactoryBean; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AagroWebSecurityConfigurerAdapter.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AagroWebSecurityConfigurerAdapter.java new file mode 100644 index 0000000..f3eca0c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AagroWebSecurityConfigurerAdapter.java @@ -0,0 +1,221 @@ +package cn.aagro.pp.framework.security.config; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.security.core.filter.TokenAuthenticationFilter; +import cn.aagro.pp.framework.web.config.WebProperties; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPattern; + +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.servlet.DispatcherType; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 自定义的 Spring Security 配置适配器实现 + * + * @author 芋道源码 + */ +@AutoConfiguration +@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效 +@EnableMethodSecurity(securedEnabled = true) +public class AagroWebSecurityConfigurerAdapter { + + @Resource + private WebProperties webProperties; + @Resource + private SecurityProperties securityProperties; + + /** + * 认证失败处理类 Bean + */ + @Resource + private AuthenticationEntryPoint authenticationEntryPoint; + /** + * 权限不够处理器 Bean + */ + @Resource + private AccessDeniedHandler accessDeniedHandler; + /** + * Token 认证过滤器 Bean + */ + @Resource + private TokenAuthenticationFilter authenticationTokenFilter; + + /** + * 自定义的权限映射 Bean 们 + * + * @see #filterChain(HttpSecurity) + */ + @Resource + private List authorizeRequestsCustomizers; + + @Resource + private ApplicationContext applicationContext; + + /** + * 由于 Spring Security 创建 AuthenticationManager 对象时,没声明 @Bean 注解,导致无法被注入 + * 通过覆写父类的该方法,添加 @Bean 注解,解决该问题 + */ + @Bean + public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + /** + * 配置 URL 的安全配置 + * + * anyRequest | 匹配所有请求路径 + * access | SpringEl表达式结果为true时可以访问 + * anonymous | 匿名可以访问 + * denyAll | 用户不能访问 + * fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录) + * hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问 + * hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问 + * hasAuthority | 如果有参数,参数表示权限,则其权限可以访问 + * hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 + * hasRole | 如果有参数,参数表示角色,则其角色可以访问 + * permitAll | 用户可以任意访问 + * rememberMe | 允许通过remember-me登录的用户访问 + * authenticated | 用户登录后可访问 + */ + @Bean + protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + // 登出 + httpSecurity + // 开启跨域 + .cors(Customizer.withDefaults()) + // CSRF 禁用,因为不使用 Session + .csrf(AbstractHttpConfigurer::disable) + // 基于 token 机制,所以不需要 Session + .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + // 一堆自定义的 Spring Security 处理器 + .exceptionHandling(c -> c.authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)); + // 登录、登录暂时不使用 Spring Security 的拓展点,主要考虑一方面拓展多用户、多种登录方式相对复杂,一方面用户的学习成本较高 + + // 获得 @PermitAll 带来的 URL 列表,免登录 + Multimap permitAllUrls = getPermitAllUrlsFromAnnotations(); + // 设置每个请求的权限 + httpSecurity + // ①:全局共享规则 + .authorizeHttpRequests(c -> c + // 1.1 静态资源,可匿名访问 + .requestMatchers(HttpMethod.GET, "/*.html", "/*.css", "/*.js").permitAll() + // 1.2 设置 @PermitAll 无需认证 + .requestMatchers(HttpMethod.GET, permitAllUrls.get(HttpMethod.GET).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.POST, permitAllUrls.get(HttpMethod.POST).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.PUT, permitAllUrls.get(HttpMethod.PUT).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.DELETE, permitAllUrls.get(HttpMethod.DELETE).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.HEAD, permitAllUrls.get(HttpMethod.HEAD).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.PATCH, permitAllUrls.get(HttpMethod.PATCH).toArray(new String[0])).permitAll() + // 1.3 基于 aagro.security.permit-all-urls 无需认证 + .requestMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll() + ) + // ②:每个项目的自定义规则 + .authorizeHttpRequests(c -> authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(c))) + // ③:兜底规则,必须认证 + .authorizeHttpRequests(c -> c + .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() // WebFlux 异步请求,无需认证,目的:SSE 场景 + .anyRequest().authenticated()); + + // 添加 Token Filter + httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); + return httpSecurity.build(); + } + + private String buildAppApi(String url) { + return webProperties.getAppApi().getPrefix() + url; + } + + private Multimap getPermitAllUrlsFromAnnotations() { + Multimap result = HashMultimap.create(); + // 获得接口对应的 HandlerMethod 集合 + RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) + applicationContext.getBean("requestMappingHandlerMapping"); + Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); + // 获得有 @PermitAll 注解的接口 + for (Map.Entry entry : handlerMethodMap.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!handlerMethod.hasMethodAnnotation(PermitAll.class) // 方法级 + && !handlerMethod.getBeanType().isAnnotationPresent(PermitAll.class)) { // 接口级 + continue; + } + Set urls = new HashSet<>(); + if (entry.getKey().getPatternsCondition() != null) { + urls.addAll(entry.getKey().getPatternsCondition().getPatterns()); + } + if (entry.getKey().getPathPatternsCondition() != null) { + urls.addAll(convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); + } + if (urls.isEmpty()) { + continue; + } + + // 特殊:使用 @RequestMapping 注解,并且未写 method 属性,此时认为都需要免登录 + Set methods = entry.getKey().getMethodsCondition().getMethods(); + if (CollUtil.isEmpty(methods)) { + result.putAll(HttpMethod.GET, urls); + result.putAll(HttpMethod.POST, urls); + result.putAll(HttpMethod.PUT, urls); + result.putAll(HttpMethod.DELETE, urls); + result.putAll(HttpMethod.HEAD, urls); + result.putAll(HttpMethod.PATCH, urls); + continue; + } + // 根据请求方法,添加到 result 结果 + entry.getKey().getMethodsCondition().getMethods().forEach(requestMethod -> { + switch (requestMethod) { + case GET: + result.putAll(HttpMethod.GET, urls); + break; + case POST: + result.putAll(HttpMethod.POST, urls); + break; + case PUT: + result.putAll(HttpMethod.PUT, urls); + break; + case DELETE: + result.putAll(HttpMethod.DELETE, urls); + break; + case HEAD: + result.putAll(HttpMethod.HEAD, urls); + break; + case PATCH: + result.putAll(HttpMethod.PATCH, urls); + break; + } + }); + } + return result; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AuthorizeRequestsCustomizer.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AuthorizeRequestsCustomizer.java new file mode 100644 index 0000000..e63d819 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/AuthorizeRequestsCustomizer.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.framework.security.config; + +import cn.aagro.pp.framework.web.config.WebProperties; +import org.springframework.core.Ordered; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +import javax.annotation.Resource; + +/** + * 自定义的 URL 的安全配置 + * 目的:每个 Maven Module 可以自定义规则! + * + * @author 芋道源码 + */ +public abstract class AuthorizeRequestsCustomizer + implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered { + + @Resource + private WebProperties webProperties; + + protected String buildAdminApi(String url) { + return webProperties.getAdminApi().getPrefix() + url; + } + + protected String buildAppApi(String url) { + return webProperties.getAppApi().getPrefix() + url; + } + + @Override + public int getOrder() { + return 0; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/SecurityProperties.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/SecurityProperties.java new file mode 100644 index 0000000..2a65bfc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/config/SecurityProperties.java @@ -0,0 +1,51 @@ +package cn.aagro.pp.framework.security.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Collections; +import java.util.List; + +@ConfigurationProperties(prefix = "aagro.security") +@Validated +@Data +public class SecurityProperties { + + /** + * HTTP 请求时,访问令牌的请求 Header + */ + @NotEmpty(message = "Token Header 不能为空") + private String tokenHeader = "Authorization"; + /** + * HTTP 请求时,访问令牌的请求参数 + * + * 初始目的:解决 WebSocket 无法通过 header 传参,只能通过 token 参数拼接 + */ + @NotEmpty(message = "Token Parameter 不能为空") + private String tokenParameter = "token"; + + /** + * mock 模式的开关 + */ + @NotNull(message = "mock 模式的开关不能为空") + private Boolean mockEnable = false; + /** + * mock 模式的密钥 + * 一定要配置密钥,保证安全性 + */ + @NotEmpty(message = "mock 模式的密钥不能为空") // 这里设置了一个默认值,因为实际上只有 mockEnable 为 true 时才需要配置。 + private String mockSecret = "test"; + + /** + * 免登录的 URL 列表 + */ + private List permitAllUrls = Collections.emptyList(); + + /** + * PasswordEncoder 加密复杂度,越高开销越大 + */ + private Integer passwordEncoderLength = 4; +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/LoginUser.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/LoginUser.java new file mode 100644 index 0000000..c58a161 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/LoginUser.java @@ -0,0 +1,75 @@ +package cn.aagro.pp.framework.security.core; + +import cn.hutool.core.map.MapUtil; +import cn.aagro.pp.framework.common.enums.UserTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 登录用户信息 + * + * @author 芋道源码 + */ +@Data +public class LoginUser { + + public static final String INFO_KEY_NICKNAME = "nickname"; + public static final String INFO_KEY_DEPT_ID = "deptId"; + + /** + * 用户编号 + */ + private Long id; + /** + * 用户类型 + * + * 关联 {@link UserTypeEnum} + */ + private Integer userType; + /** + * 额外的用户信息 + */ + private Map info; + /** + * 租户编号 + */ + private Long tenantId; + /** + * 授权范围 + */ + private List scopes; + /** + * 过期时间 + */ + private LocalDateTime expiresTime; + + // ========== 上下文 ========== + /** + * 上下文字段,不进行持久化 + * + * 1. 用于基于 LoginUser 维度的临时缓存 + */ + @JsonIgnore + private Map context; + /** + * 访问的租户编号 + */ + private Long visitTenantId; + + public void setContext(String key, Object value) { + if (context == null) { + context = new HashMap<>(); + } + context.put(key, value); + } + + public T getContext(String key, Class type) { + return MapUtil.get(context, key, type); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java new file mode 100644 index 0000000..ac65e80 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java @@ -0,0 +1,48 @@ +package cn.aagro.pp.framework.security.core.context; + +import com.alibaba.ttl.TransmittableThreadLocal; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.util.Assert; + +/** + * 基于 TransmittableThreadLocal 实现的 Security Context 持有者策略 + * 目的是,避免 @Async 等异步执行时,原生 ThreadLocal 的丢失问题 + * + * @author 芋道源码 + */ +public class TransmittableThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy { + + /** + * 使用 TransmittableThreadLocal 作为上下文 + */ + private static final ThreadLocal CONTEXT_HOLDER = new TransmittableThreadLocal<>(); + + @Override + public void clearContext() { + CONTEXT_HOLDER.remove(); + } + + @Override + public SecurityContext getContext() { + SecurityContext ctx = CONTEXT_HOLDER.get(); + if (ctx == null) { + ctx = createEmptyContext(); + CONTEXT_HOLDER.set(ctx); + } + return ctx; + } + + @Override + public void setContext(SecurityContext context) { + Assert.notNull(context, "Only non-null SecurityContext instances are permitted"); + CONTEXT_HOLDER.set(context); + } + + @Override + public SecurityContext createEmptyContext() { + return new SecurityContextImpl(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/filter/TokenAuthenticationFilter.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..659fa29 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/filter/TokenAuthenticationFilter.java @@ -0,0 +1,119 @@ +package cn.aagro.pp.framework.security.core.filter; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; +import cn.aagro.pp.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.security.config.SecurityProperties; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +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; + +/** + * Token 过滤器,验证 token 的有效性 + * 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final SecurityProperties securityProperties; + + private final GlobalExceptionHandler globalExceptionHandler; + + private final OAuth2TokenCommonApi oauth2TokenApi; + + @Override + @SuppressWarnings("NullableProblems") + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String token = SecurityFrameworkUtils.obtainAuthorization(request, + securityProperties.getTokenHeader(), securityProperties.getTokenParameter()); + if (StrUtil.isNotEmpty(token)) { + Integer userType = WebFrameworkUtils.getLoginUserType(request); + try { + // 1.1 基于 token 构建登录用户 + LoginUser loginUser = buildLoginUserByToken(token, userType); + // 1.2 模拟 Login 功能,方便日常开发调试 + if (loginUser == null) { + loginUser = mockLoginUser(request, token, userType); + } + + // 2. 设置当前用户 + if (loginUser != null) { + SecurityFrameworkUtils.setLoginUser(loginUser, request); + } + } catch (Throwable ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } + + // 继续过滤链 + chain.doFilter(request, response); + } + + private LoginUser buildLoginUserByToken(String token, Integer userType) { + try { + OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token); + if (accessToken == null) { + return null; + } + // 用户类型不匹配,无权限 + // 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型 + // 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的 + if (userType != null + && ObjectUtil.notEqual(accessToken.getUserType(), userType)) { + throw new AccessDeniedException("错误的用户类型"); + } + // 构建登录用户 + return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType()) + .setInfo(accessToken.getUserInfo()) // 额外的用户信息 + .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes()) + .setExpiresTime(accessToken.getExpiresTime()); + } catch (ServiceException serviceException) { + // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可 + return null; + } + } + + /** + * 模拟登录用户,方便日常开发调试 + * + * 注意,在线上环境下,一定要关闭该功能!!! + * + * @param request 请求 + * @param token 模拟的 token,格式为 {@link SecurityProperties#getMockSecret()} + 用户编号 + * @param userType 用户类型 + * @return 模拟的 LoginUser + */ + private LoginUser mockLoginUser(HttpServletRequest request, String token, Integer userType) { + if (!securityProperties.getMockEnable()) { + return null; + } + // 必须以 mockSecret 开头 + if (!token.startsWith(securityProperties.getMockSecret())) { + return null; + } + // 构建模拟用户 + Long userId = Long.valueOf(token.substring(securityProperties.getMockSecret().length())); + return new LoginUser().setId(userId).setUserType(userType) + .setTenantId(WebFrameworkUtils.getTenantId(request)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/handler/AccessDeniedHandlerImpl.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/handler/AccessDeniedHandlerImpl.java new file mode 100644 index 0000000..137adc1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/handler/AccessDeniedHandlerImpl.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.framework.security.core.handler; + +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.stereotype.Component; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; + +/** + * 访问一个需要认证的 URL 资源,已经认证(登录)但是没有权限的情况下,返回 {@link GlobalErrorCodeConstants#FORBIDDEN} 错误码。 + * + * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#handleAccessDeniedException(HttpServletRequest, HttpServletResponse, FilterChain, AccessDeniedException)} 方法,调用当前类 + * + * @author 芋道源码 + */ +@Slf4j +@SuppressWarnings("JavadocReference") +public class AccessDeniedHandlerImpl implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) + throws IOException, ServletException { + // 打印 warn 的原因是,不定期合并 warn,看看有没恶意破坏 + log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(), + SecurityFrameworkUtils.getLoginUserId(), e); + // 返回 403 + ServletUtils.writeJSON(response, CommonResult.error(FORBIDDEN)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/handler/AuthenticationEntryPointImpl.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/handler/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..6397002 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/handler/AuthenticationEntryPointImpl.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.security.core.handler; + +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.ExceptionTranslationFilter; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; + +/** + * 访问一个需要认证的 URL 资源,但是此时自己尚未认证(登录)的情况下,返回 {@link GlobalErrorCodeConstants#UNAUTHORIZED} 错误码,从而使前端重定向到登录页 + * + * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#sendStartAuthentication(HttpServletRequest, HttpServletResponse, FilterChain, AuthenticationException)} 方法,调用当前类 + * + * @author ruoyi + */ +@Slf4j +@SuppressWarnings("JavadocReference") // 忽略文档引用报错 +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) { + log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e); + // 返回 401 + ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/service/SecurityFrameworkService.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/service/SecurityFrameworkService.java new file mode 100644 index 0000000..d9c342c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/service/SecurityFrameworkService.java @@ -0,0 +1,59 @@ +package cn.aagro.pp.framework.security.core.service; + +/** + * Security 框架 Service 接口,定义权限相关的校验操作 + * + * @author 芋道源码 + */ +public interface SecurityFrameworkService { + + /** + * 判断是否有权限 + * + * @param permission 权限 + * @return 是否 + */ + boolean hasPermission(String permission); + + /** + * 判断是否有权限,任一一个即可 + * + * @param permissions 权限 + * @return 是否 + */ + boolean hasAnyPermissions(String... permissions); + + /** + * 判断是否有角色 + * + * 注意,角色使用的是 SysRoleDO 的 code 标识 + * + * @param role 角色 + * @return 是否 + */ + boolean hasRole(String role); + + /** + * 判断是否有角色,任一一个即可 + * + * @param roles 角色数组 + * @return 是否 + */ + boolean hasAnyRoles(String... roles); + + /** + * 判断是否有授权 + * + * @param scope 授权 + * @return 是否 + */ + boolean hasScope(String scope); + + /** + * 判断是否有授权范围,任一一个即可 + * + * @param scope 授权范围数组 + * @return 是否 + */ + boolean hasAnyScopes(String... scope); +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/service/SecurityFrameworkServiceImpl.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/service/SecurityFrameworkServiceImpl.java new file mode 100644 index 0000000..4ec2365 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/service/SecurityFrameworkServiceImpl.java @@ -0,0 +1,84 @@ +package cn.aagro.pp.framework.security.core.service; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.common.biz.system.permission.PermissionCommonApi; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import lombok.AllArgsConstructor; + +import java.util.Arrays; + +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck; + +/** + * 默认的 {@link SecurityFrameworkService} 实现类 + * + * @author 芋道源码 + */ +@AllArgsConstructor +public class SecurityFrameworkServiceImpl implements SecurityFrameworkService { + + private final PermissionCommonApi permissionApi; + + @Override + public boolean hasPermission(String permission) { + return hasAnyPermissions(permission); + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + // 特殊:跨租户访问 + if (skipPermissionCheck()) { + return true; + } + + // 权限校验 + Long userId = getLoginUserId(); + if (userId == null) { + return false; + } + return permissionApi.hasAnyPermissions(userId, permissions); + } + + @Override + public boolean hasRole(String role) { + return hasAnyRoles(role); + } + + @Override + public boolean hasAnyRoles(String... roles) { + // 特殊:跨租户访问 + if (skipPermissionCheck()) { + return true; + } + + // 权限校验 + Long userId = getLoginUserId(); + if (userId == null) { + return false; + } + return permissionApi.hasAnyRoles(userId, roles); + } + + @Override + public boolean hasScope(String scope) { + return hasAnyScopes(scope); + } + + @Override + public boolean hasAnyScopes(String... scope) { + // 特殊:跨租户访问 + if (skipPermissionCheck()) { + return true; + } + + // 权限校验 + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null) { + return false; + } + return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/util/SecurityFrameworkUtils.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/util/SecurityFrameworkUtils.java new file mode 100644 index 0000000..4377944 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/core/util/SecurityFrameworkUtils.java @@ -0,0 +1,160 @@ +package cn.aagro.pp.framework.security.core.util; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; + +/** + * 安全服务工具类 + * + * @author 芋道源码 + */ +public class SecurityFrameworkUtils { + + /** + * HEADER 认证头 value 的前缀 + */ + public static final String AUTHORIZATION_BEARER = "Bearer"; + + private SecurityFrameworkUtils() {} + + /** + * 从请求中,获得认证 Token + * + * @param request 请求 + * @param headerName 认证 Token 对应的 Header 名字 + * @param parameterName 认证 Token 对应的 Parameter 名字 + * @return 认证 Token + */ + public static String obtainAuthorization(HttpServletRequest request, + String headerName, String parameterName) { + // 1. 获得 Token。优先级:Header > Parameter + String token = request.getHeader(headerName); + if (StrUtil.isEmpty(token)) { + token = request.getParameter(parameterName); + } + if (!StringUtils.hasText(token)) { + return null; + } + // 2. 去除 Token 中带的 Bearer + int index = token.indexOf(AUTHORIZATION_BEARER + " "); + return index >= 0 ? token.substring(index + 7).trim() : token; + } + + /** + * 获得当前认证信息 + * + * @return 认证信息 + */ + public static Authentication getAuthentication() { + SecurityContext context = SecurityContextHolder.getContext(); + if (context == null) { + return null; + } + return context.getAuthentication(); + } + + /** + * 获取当前用户 + * + * @return 当前用户 + */ + @Nullable + public static LoginUser getLoginUser() { + Authentication authentication = getAuthentication(); + if (authentication == null) { + return null; + } + return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null; + } + + /** + * 获得当前用户的编号,从上下文中 + * + * @return 用户编号 + */ + @Nullable + public static Long getLoginUserId() { + LoginUser loginUser = getLoginUser(); + return loginUser != null ? loginUser.getId() : null; + } + + /** + * 获得当前用户的昵称,从上下文中 + * + * @return 昵称 + */ + @Nullable + public static String getLoginUserNickname() { + LoginUser loginUser = getLoginUser(); + return loginUser != null ? MapUtil.getStr(loginUser.getInfo(), LoginUser.INFO_KEY_NICKNAME) : null; + } + + /** + * 获得当前用户的部门编号,从上下文中 + * + * @return 部门编号 + */ + @Nullable + public static Long getLoginUserDeptId() { + LoginUser loginUser = getLoginUser(); + return loginUser != null ? MapUtil.getLong(loginUser.getInfo(), LoginUser.INFO_KEY_DEPT_ID) : null; + } + + /** + * 设置当前用户 + * + * @param loginUser 登录用户 + * @param request 请求 + */ + public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) { + // 创建 Authentication,并设置到上下文 + Authentication authentication = buildAuthentication(loginUser, request); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号; + // 原因是,Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息 + if (request != null) { + WebFrameworkUtils.setLoginUserId(request, loginUser.getId()); + WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType()); + } + } + + private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) { + // 创建 UsernamePasswordAuthenticationToken 对象 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + loginUser, null, Collections.emptyList()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + return authenticationToken; + } + + /** + * 是否条件跳过权限校验,包括数据权限、功能权限 + * + * @return 是否跳过 + */ + public static boolean skipPermissionCheck() { + LoginUser loginUser = getLoginUser(); + if (loginUser == null) { + return false; + } + if (loginUser.getVisitTenantId() == null) { + return false; + } + // 重点:跨租户访问时,无法进行权限校验 + return ObjUtil.notEqual(loginUser.getVisitTenantId(), loginUser.getTenantId()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/package-info.java b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/package-info.java new file mode 100644 index 0000000..efe1a3b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/java/cn/aagro/pp/framework/security/package-info.java @@ -0,0 +1,7 @@ +/** + * 基于 Spring Security 框架 + * 实现安全认证功能 + * + * @author 芋道源码 + */ +package cn.aagro.pp.framework.security; diff --git a/aagro-framework/aagro-spring-boot-starter-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..2883885 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +cn.aagro.pp.framework.security.config.AagroSecurityAutoConfiguration +cn.aagro.pp.framework.security.config.AagroWebSecurityConfigurerAdapter +cn.aagro.pp.framework.operatelog.config.AagroOperateLogConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-security/《芋道 Spring Boot 安全框架 Spring Security 入门》.md b/aagro-framework/aagro-spring-boot-starter-security/《芋道 Spring Boot 安全框架 Spring Security 入门》.md new file mode 100644 index 0000000..8543599 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-security/《芋道 Spring Boot 安全框架 Spring Security 入门》.md @@ -0,0 +1,2 @@ +* 芋道 Spring Security 入门: +* Spring Security 基本概念: diff --git a/aagro-framework/aagro-spring-boot-starter-test/pom.xml b/aagro-framework/aagro-spring-boot-starter-test/pom.xml new file mode 100644 index 0000000..d00e6cf --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/pom.xml @@ -0,0 +1,60 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-test + jar + + ${project.artifactId} + 测试组件,用于单元测试、集成测试 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + cn.aagro.gg + aagro-spring-boot-starter-mybatis + + + + cn.aagro.gg + aagro-spring-boot-starter-redis + + + + + org.mockito + mockito-inline + + + org.springframework.boot + spring-boot-starter-test + + + + com.h2database + h2 + + + + com.github.fppt + jedis-mock + + + + uk.co.jemos.podam + podam + + + diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/config/RedisTestConfiguration.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/config/RedisTestConfiguration.java new file mode 100644 index 0000000..cd5f00a --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/config/RedisTestConfiguration.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.test.config; + +import com.github.fppt.jedismock.RedisServer; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +import java.io.IOException; + +/** + * Redis 测试 Configuration,主要实现内嵌 Redis 的启动 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +@Lazy(false) // 禁止延迟加载 +@EnableConfigurationProperties(RedisProperties.class) +public class RedisTestConfiguration { + + /** + * 创建模拟的 Redis Server 服务器 + */ + @Bean + public RedisServer redisServer(RedisProperties properties) throws IOException { + RedisServer redisServer = new RedisServer(properties.getPort()); + // 一次执行多个单元测试时,貌似创建多个 spring 容器,导致不进行 stop。这样,就导致端口被占用,无法启动。。。 + try { + redisServer.start(); + } catch (Exception ignore) {} + return redisServer; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/config/SqlInitializationTestConfiguration.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/config/SqlInitializationTestConfiguration.java new file mode 100644 index 0000000..89caea9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/config/SqlInitializationTestConfiguration.java @@ -0,0 +1,52 @@ +package cn.aagro.pp.framework.test.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; +import org.springframework.boot.sql.init.AbstractScriptDatabaseInitializer; +import org.springframework.boot.sql.init.DatabaseInitializationSettings; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +import javax.sql.DataSource; + +/** + * SQL 初始化的测试 Configuration + * + * 为什么不使用 org.springframework.boot.autoconfigure.sql.init.DataSourceInitializationConfiguration 呢? + * 因为我们在单元测试会使用 spring.main.lazy-initialization 为 true,开启延迟加载。此时,会导致 DataSourceInitializationConfiguration 初始化 + * 不过呢,当前类的实现代码,基本是复制 DataSourceInitializationConfiguration 的哈! + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnMissingBean(AbstractScriptDatabaseInitializer.class) +@ConditionalOnSingleCandidate(DataSource.class) +@ConditionalOnClass(name = "org.springframework.jdbc.datasource.init.DatabasePopulator") +@Lazy(value = false) // 禁止延迟加载 +@EnableConfigurationProperties(SqlInitializationProperties.class) +public class SqlInitializationTestConfiguration { + + @Bean + public DataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer(DataSource dataSource, + SqlInitializationProperties initializationProperties) { + DatabaseInitializationSettings settings = createFrom(initializationProperties); + return new DataSourceScriptDatabaseInitializer(dataSource, settings); + } + + static DatabaseInitializationSettings createFrom(SqlInitializationProperties properties) { + DatabaseInitializationSettings settings = new DatabaseInitializationSettings(); + settings.setSchemaLocations(properties.getSchemaLocations()); + settings.setDataLocations(properties.getDataLocations()); + settings.setContinueOnError(properties.isContinueOnError()); + settings.setSeparator(properties.getSeparator()); + settings.setEncoding(properties.getEncoding()); + settings.setMode(properties.getMode()); + return settings; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseDbAndRedisUnitTest.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseDbAndRedisUnitTest.java new file mode 100644 index 0000000..dbc34bf --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseDbAndRedisUnitTest.java @@ -0,0 +1,55 @@ +package cn.aagro.pp.framework.test.core.ut; + +import cn.hutool.extra.spring.SpringUtil; +import cn.aagro.pp.framework.datasource.config.AagroDataSourceAutoConfiguration; +import cn.aagro.pp.framework.mybatis.config.AagroMybatisAutoConfiguration; +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +import cn.aagro.pp.framework.test.config.RedisTestConfiguration; +import cn.aagro.pp.framework.test.config.SqlInitializationTestConfiguration; +import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import org.redisson.spring.starter.RedissonAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +/** + * 依赖内存 DB + Redis 的单元测试 + * + * 相比 {@link BaseDbUnitTest} 来说,额外增加了内存 Redis + * + * @author 芋道源码 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbAndRedisUnitTest.Application.class) +@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件 +@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 每个单元测试结束后,清理 DB +public class BaseDbAndRedisUnitTest { + + @Import({ + // DB 配置类 + AagroDataSourceAutoConfiguration.class, // 自己的 DB 配置类 + DataSourceAutoConfiguration.class, // Spring DB 自动配置类 + DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类 + DruidDataSourceAutoConfigure.class, // Druid 自动配置类 + SqlInitializationTestConfiguration.class, // SQL 初始化 + // MyBatis 配置类 + AagroMybatisAutoConfiguration.class, // 自己的 MyBatis 配置类 + MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类 + + // Redis 配置类 + RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer + AagroRedisAutoConfiguration.class, // 自己的 Redis 配置类 + RedisAutoConfiguration.class, // Spring Redis 自动配置类 + RedissonAutoConfiguration.class, // Redisson 自动配置类 + + // 其它配置类 + SpringUtil.class + }) + public static class Application { + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseDbUnitTest.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseDbUnitTest.java new file mode 100644 index 0000000..484baa4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseDbUnitTest.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.test.core.ut; + +import cn.hutool.extra.spring.SpringUtil; +import cn.aagro.pp.framework.datasource.config.AagroDataSourceAutoConfiguration; +import cn.aagro.pp.framework.mybatis.config.AagroMybatisAutoConfiguration; +import cn.aagro.pp.framework.test.config.SqlInitializationTestConfiguration; +import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +/** + * 依赖内存 DB 的单元测试 + * + * 注意,Service 层同样适用。对于 Service 层的单元测试,我们针对自己模块的 Mapper 走的是 H2 内存数据库,针对别的模块的 Service 走的是 Mock 方法 + * + * @author 芋道源码 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbUnitTest.Application.class) +@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件 +@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 每个单元测试结束后,清理 DB +public class BaseDbUnitTest { + + @Import({ + // DB 配置类 + AagroDataSourceAutoConfiguration.class, // 自己的 DB 配置类 + DataSourceAutoConfiguration.class, // Spring DB 自动配置类 + DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类 + DruidDataSourceAutoConfigure.class, // Druid 自动配置类 + SqlInitializationTestConfiguration.class, // SQL 初始化 + // MyBatis 配置类 + AagroMybatisAutoConfiguration.class, // 自己的 MyBatis 配置类 + MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类 + MybatisPlusJoinAutoConfiguration.class, // MyBatis 的Join配置类 + + // 其它配置类 + SpringUtil.class + }) + public static class Application { + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseMockitoUnitTest.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseMockitoUnitTest.java new file mode 100644 index 0000000..8890081 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseMockitoUnitTest.java @@ -0,0 +1,13 @@ +package cn.aagro.pp.framework.test.core.ut; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * 纯 Mockito 的单元测试 + * + * @author 芋道源码 + */ +@ExtendWith(MockitoExtension.class) +public class BaseMockitoUnitTest { +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseRedisUnitTest.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseRedisUnitTest.java new file mode 100644 index 0000000..5c07310 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/BaseRedisUnitTest.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.framework.test.core.ut; + +import cn.hutool.extra.spring.SpringUtil; +import cn.aagro.pp.framework.redis.config.AagroRedisAutoConfiguration; +import cn.aagro.pp.framework.test.config.RedisTestConfiguration; +import org.redisson.spring.starter.RedissonAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * 依赖内存 Redis 的单元测试 + * + * 相比 {@link BaseDbUnitTest} 来说,从内存 DB 改成了内存 Redis + * + * @author 芋道源码 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseRedisUnitTest.Application.class) +@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件 +public class BaseRedisUnitTest { + + @Import({ + // Redis 配置类 + RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer + RedisAutoConfiguration.class, // Spring Redis 自动配置类 + AagroRedisAutoConfiguration.class, // 自己的 Redis 配置类 + RedissonAutoConfiguration.class, // Redisson 自动配置类 + + // 其它配置类 + SpringUtil.class + }) + public static class Application { + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/package-info.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/package-info.java new file mode 100644 index 0000000..4bfd1fe --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/ut/package-info.java @@ -0,0 +1,4 @@ +/** + * 提供单元测试 Unit Test 的基类 + */ +package cn.aagro.pp.framework.test.core.ut; diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/util/AssertUtils.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/util/AssertUtils.java new file mode 100644 index 0000000..05cb57f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/util/AssertUtils.java @@ -0,0 +1,101 @@ +package cn.aagro.pp.framework.test.core.util; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.aagro.pp.framework.common.exception.ErrorCode; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.util.ServiceExceptionUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.function.Executable; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * 单元测试,assert 断言工具类 + * + * @author 芋道源码 + */ +public class AssertUtils { + + /** + * 比对两个对象的属性是否一致 + * + * 注意,如果 expected 存在的属性,actual 不存在的时候,会进行忽略 + * + * @param expected 期望对象 + * @param actual 实际对象 + * @param ignoreFields 忽略的属性数组 + */ + public static void assertPojoEquals(Object expected, Object actual, String... ignoreFields) { + Field[] expectedFields = ReflectUtil.getFields(expected.getClass()); + Arrays.stream(expectedFields).forEach(expectedField -> { + // 忽略 jacoco 自动生成的 $jacocoData 属性的情况 + if (expectedField.isSynthetic()) { + return; + } + // 如果是忽略的属性,则不进行比对 + if (ArrayUtil.contains(ignoreFields, expectedField.getName())) { + return; + } + // 忽略不存在的属性 + Field actualField = ReflectUtil.getField(actual.getClass(), expectedField.getName()); + if (actualField == null) { + return; + } + // 比对 + Assertions.assertEquals( + ReflectUtil.getFieldValue(expected, expectedField), + ReflectUtil.getFieldValue(actual, actualField), + String.format("Field(%s) 不匹配", expectedField.getName()) + ); + }); + } + + /** + * 比对两个对象的属性是否一致 + * + * 注意,如果 expected 存在的属性,actual 不存在的时候,会进行忽略 + * + * @param expected 期望对象 + * @param actual 实际对象 + * @param ignoreFields 忽略的属性数组 + * @return 是否一致 + */ + public static boolean isPojoEquals(Object expected, Object actual, String... ignoreFields) { + Field[] expectedFields = ReflectUtil.getFields(expected.getClass()); + return Arrays.stream(expectedFields).allMatch(expectedField -> { + // 如果是忽略的属性,则不进行比对 + if (ArrayUtil.contains(ignoreFields, expectedField.getName())) { + return true; + } + // 忽略不存在的属性 + Field actualField = ReflectUtil.getField(actual.getClass(), expectedField.getName()); + if (actualField == null) { + return true; + } + return Objects.equals(ReflectUtil.getFieldValue(expected, expectedField), + ReflectUtil.getFieldValue(actual, actualField)); + }); + } + + /** + * 执行方法,校验抛出的 Service 是否符合条件 + * + * @param executable 业务异常 + * @param errorCode 错误码对象 + * @param messageParams 消息参数 + */ + public static void assertServiceException(Executable executable, ErrorCode errorCode, Object... messageParams) { + // 调用方法 + ServiceException serviceException = assertThrows(ServiceException.class, executable); + // 校验错误码 + Assertions.assertEquals(errorCode.getCode(), serviceException.getCode(), "错误码不匹配"); + String message = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMsg(), messageParams); + Assertions.assertEquals(message, serviceException.getMessage(), "错误提示不匹配"); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/util/RandomUtils.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/util/RandomUtils.java new file mode 100644 index 0000000..9a0d569 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/core/util/RandomUtils.java @@ -0,0 +1,149 @@ +package cn.aagro.pp.framework.test.core.util; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import uk.co.jemos.podam.api.PodamFactory; +import uk.co.jemos.podam.api.PodamFactoryImpl; +import uk.co.jemos.podam.common.AttributeStrategy; + +import javax.validation.constraints.Email; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 随机工具类 + * + * @author 芋道源码 + */ +public class RandomUtils { + + private static final int RANDOM_STRING_LENGTH = 10; + + private static final int TINYINT_MAX = 127; + + private static final int RANDOM_DATE_MAX = 30; + + private static final int RANDOM_COLLECTION_LENGTH = 5; + + private static final PodamFactory PODAM_FACTORY = new PodamFactoryImpl(); + + static { + // 字符串 + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(String.class, + (dataProviderStrategy, attributeMetadata, map) -> randomString()); + // Integer + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(Integer.class, (dataProviderStrategy, attributeMetadata, map) -> { + // 如果是 status 的字段,返回 0 或 1 + if ("status".equals(attributeMetadata.getAttributeName())) { + return RandomUtil.randomEle(CommonStatusEnum.values()).getStatus(); + } + // 如果是 type、status 结尾的字段,返回 tinyint 范围 + if (StrUtil.endWithAnyIgnoreCase(attributeMetadata.getAttributeName(), + "type", "status", "category", "scope", "result")) { + return RandomUtil.randomInt(0, TINYINT_MAX + 1); + } + return RandomUtil.randomInt(); + }); + // LocalDateTime + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(LocalDateTime.class, + (dataProviderStrategy, attributeMetadata, map) -> randomLocalDateTime()); + // Boolean + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(Boolean.class, (dataProviderStrategy, attributeMetadata, map) -> { + // 如果是 deleted 的字段,返回非删除 + if ("deleted".equals(attributeMetadata.getAttributeName())) { + return false; + } + return RandomUtil.randomBoolean(); + }); + } + + public static String randomString() { + return RandomUtil.randomString(RANDOM_STRING_LENGTH); + } + + public static Long randomLongId() { + return RandomUtil.randomLong(0, Long.MAX_VALUE); + } + + public static Integer randomInteger() { + return RandomUtil.randomInt(0, Integer.MAX_VALUE); + } + + public static Date randomDate() { + return RandomUtil.randomDay(0, RANDOM_DATE_MAX); + } + + public static LocalDateTime randomLocalDateTime() { + // 设置 Nano 为零的原因,避免 MySQL、H2 存储不到时间戳 + return LocalDateTimeUtil.of(randomDate()).withNano(0); + } + + public static Short randomShort() { + return (short) RandomUtil.randomInt(0, Short.MAX_VALUE); + } + + public static Set randomSet(Class clazz) { + return Stream.iterate(0, i -> i).limit(RandomUtil.randomInt(1, RANDOM_COLLECTION_LENGTH)) + .map(i -> randomPojo(clazz)).collect(Collectors.toSet()); + } + + public static Integer randomCommonStatus() { + return RandomUtil.randomEle(CommonStatusEnum.values()).getStatus(); + } + + public static String randomEmail() { + return randomString() + "@qq.com"; + } + + public static String randomMobile() { + return "13800138" + RandomUtil.randomNumbers(3); + } + + public static String randomURL() { + return "https://www.iocoder.cn/" + randomString(); + } + + @SafeVarargs + public static T randomPojo(Class clazz, Consumer... consumers) { + T pojo = PODAM_FACTORY.manufacturePojo(clazz); + // 非空时,回调逻辑。通过它,可以实现 Pojo 的进一步处理 + if (ArrayUtil.isNotEmpty(consumers)) { + Arrays.stream(consumers).forEach(consumer -> consumer.accept(pojo)); + } + return pojo; + } + + @SafeVarargs + public static T randomPojo(Class clazz, Type type, Consumer... consumers) { + T pojo = PODAM_FACTORY.manufacturePojo(clazz, type); + // 非空时,回调逻辑。通过它,可以实现 Pojo 的进一步处理 + if (ArrayUtil.isNotEmpty(consumers)) { + Arrays.stream(consumers).forEach(consumer -> consumer.accept(pojo)); + } + return pojo; + } + + @SafeVarargs + public static List randomPojoList(Class clazz, Consumer... consumers) { + int size = RandomUtil.randomInt(1, RANDOM_COLLECTION_LENGTH); + return randomPojoList(clazz, size, consumers); + } + + @SafeVarargs + public static List randomPojoList(Class clazz, int size, Consumer... consumers) { + return Stream.iterate(0, i -> i).limit(size).map(o -> randomPojo(clazz, consumers)) + .collect(Collectors.toList()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/package-info.java b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/package-info.java new file mode 100644 index 0000000..f707650 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/src/main/java/cn/aagro/pp/framework/test/package-info.java @@ -0,0 +1,4 @@ +/** + * 测试组件,用于单元测试、集成测试等等 + */ +package cn.aagro.pp.framework.test; diff --git a/aagro-framework/aagro-spring-boot-starter-test/《芋道 Spring Boot 单元测试 Test 入门》.md b/aagro-framework/aagro-spring-boot-starter-test/《芋道 Spring Boot 单元测试 Test 入门》.md new file mode 100644 index 0000000..68376bf --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-test/《芋道 Spring Boot 单元测试 Test 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-web/pom.xml b/aagro-framework/aagro-spring-boot-starter-web/pom.xml new file mode 100644 index 0000000..62b32cc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/pom.xml @@ -0,0 +1,81 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-web + jar + + ${project.artifactId} + Web 框架,全局异常、API 日志、脱敏、错误码等 + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.aagro.gg + aagro-common + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.aspectj + aspectjweaver + provided + + + + com.github.xiaoymin + knife4j-openapi3-spring-boot-starter + + + org.springdoc + springdoc-openapi-ui + + + + org.springframework.security + spring-security-core + provided + + + + + com.google.guava + guava + provided + + + + org.jsoup + jsoup + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.mockito + mockito-inline + test + + + + diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/config/AagroApiLogAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/config/AagroApiLogAutoConfiguration.java new file mode 100644 index 0000000..a903dcc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/config/AagroApiLogAutoConfiguration.java @@ -0,0 +1,44 @@ +package cn.aagro.pp.framework.apilog.config; + +import cn.aagro.pp.framework.apilog.core.filter.ApiAccessLogFilter; +import cn.aagro.pp.framework.apilog.core.interceptor.ApiAccessLogInterceptor; +import cn.aagro.pp.framework.common.biz.infra.logger.ApiAccessLogCommonApi; +import cn.aagro.pp.framework.common.enums.WebFilterOrderEnum; +import cn.aagro.pp.framework.web.config.WebProperties; +import cn.aagro.pp.framework.web.config.AagroWebAutoConfiguration; +import javax.servlet.Filter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@AutoConfiguration(after = AagroWebAutoConfiguration.class) +public class AagroApiLogAutoConfiguration implements WebMvcConfigurer { + + /** + * 创建 ApiAccessLogFilter Bean,记录 API 请求日志 + */ + @Bean + @ConditionalOnProperty(prefix = "aagro.access-log", value = "enable", matchIfMissing = true) // 允许使用 aagro.access-log.enable=false 禁用访问日志 + public FilterRegistrationBean apiAccessLogFilter(WebProperties webProperties, + @Value("${spring.application.name}") String applicationName, + ApiAccessLogCommonApi apiAccessLogApi) { + ApiAccessLogFilter filter = new ApiAccessLogFilter(webProperties, applicationName, apiAccessLogApi); + return createFilterBean(filter, WebFilterOrderEnum.API_ACCESS_LOG_FILTER); + } + + private static FilterRegistrationBean createFilterBean(T filter, Integer order) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.setOrder(order); + return bean; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new ApiAccessLogInterceptor()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/annotation/ApiAccessLog.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/annotation/ApiAccessLog.java new file mode 100644 index 0000000..aaf5b98 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/annotation/ApiAccessLog.java @@ -0,0 +1,65 @@ +package cn.aagro.pp.framework.apilog.core.annotation; + +import cn.aagro.pp.framework.apilog.core.enums.OperateTypeEnum; + +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 ApiAccessLog { + + // ========== 开关字段 ========== + + /** + * 是否记录访问日志 + */ + boolean enable() default true; + /** + * 是否记录请求参数 + * + * 默认记录,主要考虑请求数据一般不大。可手动设置为 false 进行关闭 + */ + boolean requestEnable() default true; + /** + * 是否记录响应结果 + * + * 默认不记录,主要考虑响应数据可能比较大。可手动设置为 true 进行打开 + */ + boolean responseEnable() default false; + /** + * 敏感参数数组 + * + * 添加后,请求参数、响应结果不会记录该参数 + */ + String[] sanitizeKeys() default {}; + + // ========== 模块字段 ========== + + /** + * 操作模块 + * + * 为空时,会尝试读取 {@link io.swagger.v3.oas.annotations.tags.Tag#name()} 属性 + */ + String operateModule() default ""; + /** + * 操作名 + * + * 为空时,会尝试读取 {@link io.swagger.v3.oas.annotations.Operation#summary()} 属性 + */ + String operateName() default ""; + /** + * 操作分类 + * + * 实际并不是数组,因为枚举不能设置 null 作为默认值 + */ + OperateTypeEnum[] operateType() default {}; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/enums/OperateTypeEnum.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/enums/OperateTypeEnum.java new file mode 100644 index 0000000..a738ca4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/enums/OperateTypeEnum.java @@ -0,0 +1,51 @@ +package cn.aagro.pp.framework.apilog.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 操作日志的操作类型 + * + * @author ruoyi + */ +@Getter +@AllArgsConstructor +public enum OperateTypeEnum { + + /** + * 查询 + */ + GET(1), + /** + * 新增 + */ + CREATE(2), + /** + * 修改 + */ + UPDATE(3), + /** + * 删除 + */ + DELETE(4), + /** + * 导出 + */ + EXPORT(5), + /** + * 导入 + */ + IMPORT(6), + /** + * 其它 + * + * 在无法归类时,可以选择使用其它。因为还有操作名可以进一步标识 + */ + OTHER(0); + + /** + * 类型 + */ + private final Integer type; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/filter/ApiAccessLogFilter.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/filter/ApiAccessLogFilter.java new file mode 100644 index 0000000..edab9aa --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/filter/ApiAccessLogFilter.java @@ -0,0 +1,253 @@ +package cn.aagro.pp.framework.apilog.core.filter; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.apilog.core.annotation.ApiAccessLog; +import cn.aagro.pp.framework.apilog.core.enums.OperateTypeEnum; +import cn.aagro.pp.framework.common.biz.infra.logger.ApiAccessLogCommonApi; +import cn.aagro.pp.framework.common.biz.infra.logger.dto.ApiAccessLogCreateReqDTO; +import cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.common.util.monitor.TracerUtils; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.web.config.WebProperties; +import cn.aagro.pp.framework.web.core.filter.ApiRequestFilter; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.Map; + +import static cn.aagro.pp.framework.apilog.core.interceptor.ApiAccessLogInterceptor.ATTRIBUTE_HANDLER_METHOD; +import static cn.aagro.pp.framework.common.util.json.JsonUtils.toJsonString; + +/** + * API 访问日志 Filter + * + * 目的:记录 API 访问日志到数据库中 + * + * @author 芋道源码 + */ +@Slf4j +public class ApiAccessLogFilter extends ApiRequestFilter { + + private static final String[] SANITIZE_KEYS = new String[]{"password", "token", "accessToken", "refreshToken"}; + + private final String applicationName; + + private final ApiAccessLogCommonApi apiAccessLogApi; + + public ApiAccessLogFilter(WebProperties webProperties, String applicationName, ApiAccessLogCommonApi apiAccessLogApi) { + super(webProperties); + this.applicationName = applicationName; + this.apiAccessLogApi = apiAccessLogApi; + } + + @Override + @SuppressWarnings("NullableProblems") + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // 获得开始时间 + LocalDateTime beginTime = LocalDateTime.now(); + // 提前获得参数,避免 XssFilter 过滤处理 + Map queryString = ServletUtils.getParamMap(request); + String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null; + + try { + // 继续过滤器 + filterChain.doFilter(request, response); + // 正常执行,记录日志 + createApiAccessLog(request, beginTime, queryString, requestBody, null); + } catch (Exception ex) { + // 异常执行,记录日志 + createApiAccessLog(request, beginTime, queryString, requestBody, ex); + throw ex; + } + } + + private void createApiAccessLog(HttpServletRequest request, LocalDateTime beginTime, + Map queryString, String requestBody, Exception ex) { + ApiAccessLogCreateReqDTO accessLog = new ApiAccessLogCreateReqDTO(); + try { + boolean enable = buildApiAccessLog(accessLog, request, beginTime, queryString, requestBody, ex); + if (!enable) { + return; + } + apiAccessLogApi.createApiAccessLogAsync(accessLog); + } catch (Throwable th) { + log.error("[createApiAccessLog][url({}) log({}) 发生异常]", request.getRequestURI(), toJsonString(accessLog), th); + } + } + + private boolean buildApiAccessLog(ApiAccessLogCreateReqDTO accessLog, HttpServletRequest request, LocalDateTime beginTime, + Map queryString, String requestBody, Exception ex) { + // 判断:是否要记录操作日志 + HandlerMethod handlerMethod = (HandlerMethod) request.getAttribute(ATTRIBUTE_HANDLER_METHOD); + ApiAccessLog accessLogAnnotation = null; + if (handlerMethod != null) { + accessLogAnnotation = handlerMethod.getMethodAnnotation(ApiAccessLog.class); + if (accessLogAnnotation != null && BooleanUtil.isFalse(accessLogAnnotation.enable())) { + return false; + } + } + + // 处理用户信息 + accessLog.setUserId(WebFrameworkUtils.getLoginUserId(request)) + .setUserType(WebFrameworkUtils.getLoginUserType(request)); + // 设置访问结果 + CommonResult result = WebFrameworkUtils.getCommonResult(request); + if (result != null) { + accessLog.setResultCode(result.getCode()).setResultMsg(result.getMsg()); + } else if (ex != null) { + accessLog.setResultCode(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode()) + .setResultMsg(ExceptionUtil.getRootCauseMessage(ex)); + } else { + accessLog.setResultCode(GlobalErrorCodeConstants.SUCCESS.getCode()).setResultMsg(""); + } + // 设置请求字段 + accessLog.setTraceId(TracerUtils.getTraceId()).setApplicationName(applicationName) + .setRequestUrl(request.getRequestURI()).setRequestMethod(request.getMethod()) + .setUserAgent(ServletUtils.getUserAgent(request)).setUserIp(ServletUtils.getClientIP(request)); + String[] sanitizeKeys = accessLogAnnotation != null ? accessLogAnnotation.sanitizeKeys() : null; + Boolean requestEnable = accessLogAnnotation != null ? accessLogAnnotation.requestEnable() : Boolean.TRUE; + if (!BooleanUtil.isFalse(requestEnable)) { // 默认记录,所以判断 !false + Map requestParams = MapUtil.builder() + .put("query", sanitizeMap(queryString, sanitizeKeys)) + .put("body", sanitizeJson(requestBody, sanitizeKeys)).build(); + accessLog.setRequestParams(toJsonString(requestParams)); + } + Boolean responseEnable = accessLogAnnotation != null ? accessLogAnnotation.responseEnable() : Boolean.FALSE; + if (BooleanUtil.isTrue(responseEnable)) { // 默认不记录,默认强制要求 true + accessLog.setResponseBody(sanitizeJson(result, sanitizeKeys)); + } + // 持续时间 + accessLog.setBeginTime(beginTime).setEndTime(LocalDateTime.now()) + .setDuration((int) LocalDateTimeUtil.between(accessLog.getBeginTime(), accessLog.getEndTime(), ChronoUnit.MILLIS)); + + // 操作模块 + if (handlerMethod != null) { + Tag tagAnnotation = handlerMethod.getBeanType().getAnnotation(Tag.class); + Operation operationAnnotation = handlerMethod.getMethodAnnotation(Operation.class); + String operateModule = accessLogAnnotation != null && StrUtil.isNotBlank(accessLogAnnotation.operateModule()) ? + accessLogAnnotation.operateModule() : + tagAnnotation != null ? StrUtil.nullToDefault(tagAnnotation.name(), tagAnnotation.description()) : null; + String operateName = accessLogAnnotation != null && StrUtil.isNotBlank(accessLogAnnotation.operateName()) ? + accessLogAnnotation.operateName() : + operationAnnotation != null ? operationAnnotation.summary() : null; + OperateTypeEnum operateType = accessLogAnnotation != null && accessLogAnnotation.operateType().length > 0 ? + accessLogAnnotation.operateType()[0] : parseOperateLogType(request); + accessLog.setOperateModule(operateModule).setOperateName(operateName).setOperateType(operateType.getType()); + } + return true; + } + + // ========== 解析 @ApiAccessLog、@Swagger 注解 ========== + + private static OperateTypeEnum parseOperateLogType(HttpServletRequest request) { + RequestMethod requestMethod = ArrayUtil.firstMatch(method -> + StrUtil.equalsAnyIgnoreCase(method.name(), request.getMethod()), RequestMethod.values()); + if (requestMethod == null) { + return OperateTypeEnum.OTHER; + } + 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; + } + } + + // ========== 请求和响应的脱敏逻辑,移除类似 password、token 等敏感字段 ========== + + private static String sanitizeMap(Map map, String[] sanitizeKeys) { + if (CollUtil.isEmpty(map)) { + return null; + } + if (sanitizeKeys != null) { + MapUtil.removeAny(map, sanitizeKeys); + } + MapUtil.removeAny(map, SANITIZE_KEYS); + return JsonUtils.toJsonString(map); + } + + private static String sanitizeJson(String jsonString, String[] sanitizeKeys) { + if (StrUtil.isEmpty(jsonString)) { + return null; + } + try { + JsonNode rootNode = JsonUtils.parseTree(jsonString); + sanitizeJson(rootNode, sanitizeKeys); + return JsonUtils.toJsonString(rootNode); + } catch (Exception e) { + // 脱敏失败的情况下,直接忽略异常,避免影响用户请求 + log.error("[sanitizeJson][脱敏({}) 发生异常]", jsonString, e); + return jsonString; + } + } + + private static String sanitizeJson(CommonResult commonResult, String[] sanitizeKeys) { + if (commonResult == null) { + return null; + } + String jsonString = toJsonString(commonResult); + try { + JsonNode rootNode = JsonUtils.parseTree(jsonString); + sanitizeJson(rootNode.get("data"), sanitizeKeys); // 只处理 data 字段,不处理 code、msg 字段,避免错误被脱敏掉 + return JsonUtils.toJsonString(rootNode); + } catch (Exception e) { + // 脱敏失败的情况下,直接忽略异常,避免影响用户请求 + log.error("[sanitizeJson][脱敏({}) 发生异常]", jsonString, e); + return jsonString; + } + } + + private static void sanitizeJson(JsonNode node, String[] sanitizeKeys) { + // 情况一:数组,遍历处理 + if (node.isArray()) { + for (JsonNode childNode : node) { + sanitizeJson(childNode, sanitizeKeys); + } + return; + } + // 情况二:非 Object,只是某个值,直接返回 + if (!node.isObject()) { + return; + } + // 情况三:Object,遍历处理 + Iterator> iterator = node.fields(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (ArrayUtil.contains(sanitizeKeys, entry.getKey()) + || ArrayUtil.contains(SANITIZE_KEYS, entry.getKey())) { + iterator.remove(); + continue; + } + sanitizeJson(entry.getValue(), sanitizeKeys); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java new file mode 100644 index 0000000..3059def --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java @@ -0,0 +1,103 @@ +package cn.aagro.pp.framework.apilog.core.interceptor; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.common.util.spring.SpringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StopWatch; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; + +/** + * API 访问日志 Interceptor + * + * 目的:在非 prod 环境时,打印 request 和 response 两条日志到日志文件(控制台)中。 + * + * @author 芋道源码 + */ +@Slf4j +public class ApiAccessLogInterceptor implements HandlerInterceptor { + + public static final String ATTRIBUTE_HANDLER_METHOD = "HANDLER_METHOD"; + + private static final String ATTRIBUTE_STOP_WATCH = "ApiAccessLogInterceptor.StopWatch"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 记录 HandlerMethod,提供给 ApiAccessLogFilter 使用 + HandlerMethod handlerMethod = handler instanceof HandlerMethod ? (HandlerMethod) handler : null; + if (handlerMethod != null) { + request.setAttribute(ATTRIBUTE_HANDLER_METHOD, handlerMethod); + } + + // 打印 request 日志 + if (!SpringUtils.isProd()) { + Map queryString = ServletUtils.getParamMap(request); + String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null; + if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) { + log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI()); + } else { + log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(), + StrUtil.blankToDefault(requestBody, queryString.toString())); + } + // 计时 + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + request.setAttribute(ATTRIBUTE_STOP_WATCH, stopWatch); + // 打印 Controller 路径 + printHandlerMethodPosition(handlerMethod); + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 打印 response 日志 + if (!SpringUtils.isProd()) { + StopWatch stopWatch = (StopWatch) request.getAttribute(ATTRIBUTE_STOP_WATCH); + stopWatch.stop(); + log.info("[afterCompletion][完成请求 URL({}) 耗时({} ms)]", + request.getRequestURI(), stopWatch.getTotalTimeMillis()); + } + } + + /** + * 打印 Controller 方法路径 + */ + private void printHandlerMethodPosition(HandlerMethod handlerMethod) { + if (handlerMethod == null) { + return; + } + Method method = handlerMethod.getMethod(); + Class clazz = method.getDeclaringClass(); + try { + // 获取 method 的 lineNumber + List clazzContents = FileUtil.readUtf8Lines( + ResourceUtil.getResource(null, clazz).getPath().replace("/target/classes/", "/src/main/java/") + + clazz.getSimpleName() + ".java"); + Optional lineNumber = IntStream.range(0, clazzContents.size()) + .filter(i -> clazzContents.get(i).contains(" " + method.getName() + "(")) // 简单匹配,不考虑方法重名 + .mapToObj(i -> i + 1) // 行号从 1 开始 + .findFirst(); + if (!lineNumber.isPresent()) { + return; + } + // 打印结果 + System.out.printf("\tController 方法路径:%s(%s.java:%d)\n", clazz.getName(), clazz.getSimpleName(), lineNumber.get()); + } catch (Exception ignore) { + // 忽略异常。原因:仅仅打印,非重要逻辑 + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/package-info.java new file mode 100644 index 0000000..9902164 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/apilog/package-info.java @@ -0,0 +1,8 @@ +/** + * API 日志:包含两类 + * 1. API 访问日志:记录用户访问 API 的访问日志,定期归档历史日志。 + * 2. 异常日志:记录用户访问 API 的系统异常,方便日常排查问题与告警。 + * + * @author 芋道源码 + */ +package cn.aagro.pp.framework.apilog; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/config/AagroBannerAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/config/AagroBannerAutoConfiguration.java new file mode 100644 index 0000000..bf04e70 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/config/AagroBannerAutoConfiguration.java @@ -0,0 +1,20 @@ +package cn.aagro.pp.framework.banner.config; + +import cn.aagro.pp.framework.banner.core.BannerApplicationRunner; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Banner 的自动配置类 + * + * @author 芋道源码 + */ +@AutoConfiguration +public class AagroBannerAutoConfiguration { + + @Bean + public BannerApplicationRunner bannerApplicationRunner() { + return new BannerApplicationRunner(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/core/BannerApplicationRunner.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/core/BannerApplicationRunner.java new file mode 100644 index 0000000..a7464c9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/core/BannerApplicationRunner.java @@ -0,0 +1,76 @@ +package cn.aagro.pp.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 org.springframework.util.ClassUtils; + +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" + + "项目启动成功!\n\t" + + "接口文档: \t{} \n\t" + + "开发文档: \t{} \n\t" + + "视频教程: \t{} \n" + + "----------------------------------------------------------", + "https://doc.iocoder.cn/api-doc/", + "https://doc.iocoder.cn", + "https://t.zsxq.com/02Yf6M7Qn"); + + // 数据报表 + if (isNotPresent("cn.aagro.pp.module.report.framework.security.config.SecurityConfiguration")) { + System.out.println("[报表模块 aagro-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]"); + } + // 工作流 + if (isNotPresent("cn.aagro.pp.module.bpm.framework.flowable.config.BpmFlowableConfiguration")) { + System.out.println("[工作流模块 aagro-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]"); + } + // 商城系统 + if (isNotPresent("cn.aagro.pp.module.trade.framework.web.config.TradeWebConfiguration")) { + System.out.println("[商城系统 aagro-module-mall - 已禁用][参考 https://doc.iocoder.cn/mall/build/ 开启]"); + } + // ERP 系统 + if (isNotPresent("cn.aagro.pp.module.erp.framework.web.config.ErpWebConfiguration")) { + System.out.println("[ERP 系统 aagro-module-erp - 已禁用][参考 https://doc.iocoder.cn/erp/build/ 开启]"); + } + // CRM 系统 + if (isNotPresent("cn.aagro.pp.module.crm.framework.web.config.CrmWebConfiguration")) { + System.out.println("[CRM 系统 aagro-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]"); + } + // 微信公众号 + if (isNotPresent("cn.aagro.pp.module.mp.framework.mp.config.MpConfiguration")) { + System.out.println("[微信公众号 aagro-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]"); + } + // 支付平台 + if (isNotPresent("cn.aagro.pp.module.pay.framework.pay.config.PayConfiguration")) { + System.out.println("[支付系统 aagro-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]"); + } + // AI 大模型 + if (isNotPresent("cn.aagro.pp.module.ai.framework.web.config.AiWebConfiguration")) { + System.out.println("[AI 大模型 aagro-module-ai - 已禁用][参考 https://doc.iocoder.cn/ai/build/ 开启]"); + } + // IoT 物联网 + if (isNotPresent("cn.aagro.pp.module.iot.framework.web.config.IotWebConfiguration")) { + System.out.println("[IoT 物联网 aagro-module-iot - 已禁用][参考 https://doc.iocoder.cn/iot/build/ 开启]"); + } + }); + } + + private static boolean isNotPresent(String className) { + return !ClassUtils.isPresent(className, ClassUtils.getDefaultClassLoader()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/package-info.java new file mode 100644 index 0000000..630308f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/banner/package-info.java @@ -0,0 +1,6 @@ +/** + * Banner 用于在 console 控制台,打印开发文档、接口文档等 + * + * @author 芋道源码 + */ +package cn.aagro.pp.framework.banner; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/annotation/DesensitizeBy.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/annotation/DesensitizeBy.java new file mode 100644 index 0000000..bd9a045 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/annotation/DesensitizeBy.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.desensitize.core.base.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.handler.DesensitizationHandler; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/handler/DesensitizationHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/handler/DesensitizationHandler.java new file mode 100644 index 0000000..70e5b0e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/handler/DesensitizationHandler.java @@ -0,0 +1,40 @@ +package cn.aagro.pp.framework.desensitize.core.base.handler; + +import cn.hutool.core.util.ReflectUtil; + +import java.lang.annotation.Annotation; + +/** + * 脱敏处理器接口 + * + * @author gaibu + */ +public interface DesensitizationHandler { + + /** + * 脱敏 + * + * @param origin 原始字符串 + * @param annotation 注解信息 + * @return 脱敏后的字符串 + */ + String desensitize(String origin, T annotation); + + /** + * 是否禁用脱敏的 Spring EL 表达式 + * + * 如果返回 true 则跳过脱敏 + * + * @param annotation 注解信息 + * @return 是否禁用脱敏的 Spring EL 表达式 + */ + default String getDisable(T annotation) { + // 约定:默认就是 enable() 属性。如果不符合,子类重写 + try { + return (String) ReflectUtil.invoke(annotation, "disable"); + } catch (Exception ex) { + return ""; + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java new file mode 100644 index 0000000..63e3dab --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java @@ -0,0 +1,92 @@ +package cn.aagro.pp.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 cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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.currentValue(); + Class currentValueClass = currentValue.getClass(); + return ReflectUtil.getField(currentValueClass, currentName); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/annotation/EmailDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/annotation/EmailDesensitize.java new file mode 100644 index 0000000..230c5e7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/annotation/EmailDesensitize.java @@ -0,0 +1,44 @@ +package cn.aagro.pp.framework.desensitize.core.regex.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/annotation/RegexDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/annotation/RegexDesensitize.java new file mode 100644 index 0000000..397be8f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/annotation/RegexDesensitize.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.desensitize.core.regex.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "******"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java new file mode 100644 index 0000000..cd608f1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.desensitize.core.regex.handler; + +import cn.aagro.pp.framework.common.util.spring.SpringExpressionUtils; +import cn.aagro.pp.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) { + // 1. 判断是否禁用脱敏 + Object disable = SpringExpressionUtils.parseExpression(getDisable(annotation)); + if (Boolean.TRUE.equals(disable)) { + return origin; + } + + // 2. 执行脱敏 + 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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java new file mode 100644 index 0000000..ef101d1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.desensitize.core.regex.handler; + +import cn.aagro.pp.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(); + } + + @Override + public String getDisable(RegexDesensitize annotation) { + return annotation.disable(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java new file mode 100644 index 0000000..5921dcc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.framework.desensitize.core.regex.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/BankCardDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/BankCardDesensitize.java new file mode 100644 index 0000000..9dba957 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/BankCardDesensitize.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java new file mode 100644 index 0000000..4ae1880 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java new file mode 100644 index 0000000..2386347 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java new file mode 100644 index 0000000..bc92e16 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/IdCardDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/IdCardDesensitize.java new file mode 100644 index 0000000..2fa5ac2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/IdCardDesensitize.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/MobileDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/MobileDesensitize.java new file mode 100644 index 0000000..ba87ec1 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/MobileDesensitize.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/PasswordDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/PasswordDesensitize.java new file mode 100644 index 0000000..014356d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/PasswordDesensitize.java @@ -0,0 +1,49 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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 "*"; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/SliderDesensitize.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/SliderDesensitize.java new file mode 100644 index 0000000..677f0de --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/annotation/SliderDesensitize.java @@ -0,0 +1,51 @@ +package cn.aagro.pp.framework.desensitize.core.slider.annotation; + +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +import cn.aagro.pp.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; + + /** + * 是否禁用脱敏 + * + * 支持 Spring EL 表达式,如果返回 true 则跳过脱敏 + */ + String disable() default ""; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java new file mode 100644 index 0000000..b723172 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java @@ -0,0 +1,78 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.spring.SpringExpressionUtils; +import cn.aagro.pp.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) { + // 1. 判断是否禁用脱敏 + Object disable = SpringExpressionUtils.parseExpression(getDisable(annotation)); + if (Boolean.TRUE.equals(disable)) { + return origin; + } + + // 2. 执行脱敏 + int prefixKeep = getPrefixKeep(annotation); + int suffixKeep = getSuffixKeep(annotation); + String replacer = getReplacer(annotation); + int length = origin.length(); + int interval = length - prefixKeep - suffixKeep; + + // 情况一:原始字符串长度小于等于前后缀保留字符串长度,则原始字符串全部替换 + if (interval <= 0) { + return buildReplacerByLength(replacer, length); + } + + // 情况二:原始字符串长度大于前后缀保留字符串长度,则替换中间字符串 + return origin.substring(0, prefixKeep) + + buildReplacerByLength(replacer, interval) + + origin.substring(prefixKeep + interval); + } + + /** + * 根据长度循环构建替换符 + * + * @param replacer 替换符 + * @param length 长度 + * @return 构建后的替换符 + */ + private String buildReplacerByLength(String replacer, int length) { + return StrUtil.repeat(replacer, length); + } + + /** + * 前缀保留长度 + * + * @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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/BankCardDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/BankCardDesensitization.java new file mode 100644 index 0000000..b893f2c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/BankCardDesensitization.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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(); + } + + @Override + public String getDisable(BankCardDesensitize annotation) { + return ""; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java new file mode 100644 index 0000000..90a63c3 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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(); + } + + @Override + public String getDisable(CarLicenseDesensitize annotation) { + return annotation.disable(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java new file mode 100644 index 0000000..ced1e8b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java new file mode 100644 index 0000000..7ab5be0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java new file mode 100644 index 0000000..1d88f4b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/IdCardDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/IdCardDesensitization.java new file mode 100644 index 0000000..50bc124 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/IdCardDesensitization.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/MobileDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/MobileDesensitization.java new file mode 100644 index 0000000..ec02fdf --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/MobileDesensitization.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/PasswordDesensitization.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/PasswordDesensitization.java new file mode 100644 index 0000000..a042544 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/core/slider/handler/PasswordDesensitization.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.framework.desensitize.core.slider.handler; + +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/package-info.java new file mode 100644 index 0000000..6b96198 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/desensitize/package-info.java @@ -0,0 +1,4 @@ +/** + * 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 + */ +package cn.aagro.pp.framework.desensitize; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/config/AagroApiEncryptAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/config/AagroApiEncryptAutoConfiguration.java new file mode 100644 index 0000000..5a40d14 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/config/AagroApiEncryptAutoConfiguration.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.framework.encrypt.config; + +import cn.aagro.pp.framework.common.enums.WebFilterOrderEnum; +import cn.aagro.pp.framework.encrypt.core.filter.ApiEncryptFilter; +import cn.aagro.pp.framework.web.config.WebProperties; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +import lombok.extern.slf4j.Slf4j; +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.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import static cn.aagro.pp.framework.web.config.AagroWebAutoConfiguration.createFilterBean; + +@AutoConfiguration +@Slf4j +@EnableConfigurationProperties(ApiEncryptProperties.class) +@ConditionalOnProperty(prefix = "aagro.api-encrypt", name = "enable", havingValue = "true") +public class AagroApiEncryptAutoConfiguration { + + @Bean + public FilterRegistrationBean apiEncryptFilter(WebProperties webProperties, + ApiEncryptProperties apiEncryptProperties, + RequestMappingHandlerMapping requestMappingHandlerMapping, + GlobalExceptionHandler globalExceptionHandler) { + ApiEncryptFilter filter = new ApiEncryptFilter(webProperties, apiEncryptProperties, + requestMappingHandlerMapping, globalExceptionHandler); + return createFilterBean(filter, WebFilterOrderEnum.API_ENCRYPT_FILTER); + + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/config/ApiEncryptProperties.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/config/ApiEncryptProperties.java new file mode 100644 index 0000000..881d898 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/config/ApiEncryptProperties.java @@ -0,0 +1,71 @@ +package cn.aagro.pp.framework.encrypt.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * HTTP API 加解密配置 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "aagro.api-encrypt") +@Validated +@Data +public class ApiEncryptProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enable; + + /** + * 请求头(响应头)名称 + * + * 1. 如果该请求头非空,则表示请求参数已被「前端」加密,「后端」需要解密 + * 2. 如果该响应头非空,则表示响应结果已被「后端」加密,「前端」需要解密 + */ + @NotEmpty(message = "请求头(响应头)名称不能为空") + private String header = "X-Api-Encrypt"; + + /** + * 对称加密算法,用于请求/响应的加解密 + * + * 目前支持 + * 【对称加密】: + * 1. {@link cn.hutool.crypto.symmetric.SymmetricAlgorithm#AES} + * 2. {@link cn.hutool.crypto.symmetric.SM4#ALGORITHM_NAME} (需要自己二次开发,成本低) + * 【非对称加密】 + * 1. {@link cn.hutool.crypto.asymmetric.AsymmetricAlgorithm#RSA} + * 2. {@link cn.hutool.crypto.asymmetric.SM2} (需要自己二次开发,成本低) + * + * @see 什么是公钥和私钥? + */ + @NotEmpty(message = "对称加密算法不能为空") + private String algorithm; + + /** + * 请求的解密密钥 + * + * 注意: + * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。 + * 2. 如果是【非对称加密】时,它「后端」对应的是“私钥”。对应的,「前端」对应的是“公钥”。(重要!!!) + */ + @NotEmpty(message = "请求的解密密钥不能为空") + private String requestKey; + + /** + * 响应的加密密钥 + * + * 注意: + * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。 + * 2. 如果是【非对称加密】时,它「后端」对应的是“公钥”。对应的,「前端」对应的是“私钥”。(重要!!!) + */ + @NotEmpty(message = "响应的加密密钥不能为空") + private String responseKey; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/annotation/ApiEncrypt.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/annotation/ApiEncrypt.java new file mode 100644 index 0000000..cc5c0eb --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/annotation/ApiEncrypt.java @@ -0,0 +1,23 @@ +package cn.aagro.pp.framework.encrypt.core.annotation; + +import java.lang.annotation.*; + +/** + * HTTP API 加解密注解 + */ +@Documented +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiEncrypt { + + /** + * 是否对请求参数进行解密,默认 true + */ + boolean request() default true; + + /** + * 是否对响应结果进行加密,默认 true + */ + boolean response() default true; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java new file mode 100644 index 0000000..6608d51 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java @@ -0,0 +1,86 @@ +package cn.aagro.pp.framework.encrypt.core.filter; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.asymmetric.AsymmetricDecryptor; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.symmetric.SymmetricDecryptor; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; + +/** + * 解密请求 {@link HttpServletRequestWrapper} 实现类 + * + * @author 芋道源码 + */ +public class ApiDecryptRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public ApiDecryptRequestWrapper(HttpServletRequest request, + SymmetricDecryptor symmetricDecryptor, + AsymmetricDecryptor asymmetricDecryptor) throws IOException { + super(request); + // 读取 body,允许 HEX、BASE64 传输 + String requestBody = StrUtil.utf8Str( + IoUtil.readBytes(request.getInputStream(), false)); + + // 解密 body + body = symmetricDecryptor != null ? symmetricDecryptor.decrypt(requestBody) + : asymmetricDecryptor.decrypt(requestBody, KeyType.PrivateKey); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(this.getInputStream())); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream stream = new ByteArrayInputStream(body); + return new ServletInputStream() { + + @Override + public int read() { + return stream.read(); + } + + @Override + public int available() { + return body.length; + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + }; + } +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiEncryptFilter.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiEncryptFilter.java new file mode 100644 index 0000000..0e92fb5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiEncryptFilter.java @@ -0,0 +1,161 @@ +package cn.aagro.pp.framework.encrypt.core.filter; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.asymmetric.AsymmetricDecryptor; +import cn.hutool.crypto.asymmetric.AsymmetricEncryptor; +import cn.hutool.crypto.symmetric.SymmetricDecryptor; +import cn.hutool.crypto.symmetric.SymmetricEncryptor; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.object.ObjectUtils; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.encrypt.config.ApiEncryptProperties; +import cn.aagro.pp.framework.encrypt.core.annotation.ApiEncrypt; +import cn.aagro.pp.framework.web.config.WebProperties; +import cn.aagro.pp.framework.web.core.filter.ApiRequestFilter; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.ServletRequestPathUtils; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static cn.aagro.pp.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * API 加密过滤器,处理 {@link ApiEncrypt} 注解。 + * + * 1. 解密请求参数 + * 2. 加密响应结果 + * + * 疑问:为什么不使用 SpringMVC 的 RequestBodyAdvice 或 ResponseBodyAdvice 机制呢? + * 回答:考虑到项目中会记录访问日志、异常日志,以及 HTTP API 签名等场景,最好是全局级、且提前做解析!!! + * + * @author 芋道源码 + */ +@Slf4j +public class ApiEncryptFilter extends ApiRequestFilter { + + private final ApiEncryptProperties apiEncryptProperties; + + private final RequestMappingHandlerMapping requestMappingHandlerMapping; + + private final GlobalExceptionHandler globalExceptionHandler; + + private final SymmetricDecryptor requestSymmetricDecryptor; + private final AsymmetricDecryptor requestAsymmetricDecryptor; + + private final SymmetricEncryptor responseSymmetricEncryptor; + private final AsymmetricEncryptor responseAsymmetricEncryptor; + + public ApiEncryptFilter(WebProperties webProperties, + ApiEncryptProperties apiEncryptProperties, + RequestMappingHandlerMapping requestMappingHandlerMapping, + GlobalExceptionHandler globalExceptionHandler) { + super(webProperties); + this.apiEncryptProperties = apiEncryptProperties; + this.requestMappingHandlerMapping = requestMappingHandlerMapping; + this.globalExceptionHandler = globalExceptionHandler; + if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "AES")) { + this.requestSymmetricDecryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getRequestKey())); + this.requestAsymmetricDecryptor = null; + this.responseSymmetricEncryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getResponseKey())); + this.responseAsymmetricEncryptor = null; + } else if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "RSA")) { + this.requestSymmetricDecryptor = null; + this.requestAsymmetricDecryptor = SecureUtil.rsa(apiEncryptProperties.getRequestKey(), null); + this.responseSymmetricEncryptor = null; + this.responseAsymmetricEncryptor = SecureUtil.rsa(null, apiEncryptProperties.getResponseKey()); + } else { + // 补充说明:如果要支持 SM2、SM4 等算法,可在此处增加对应实例的创建,并添加相应的 Maven 依赖即可。 + throw new IllegalArgumentException("不支持的加密算法:" + apiEncryptProperties.getAlgorithm()); + } + } + + @Override + @SuppressWarnings("NullableProblems") + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 获取 @ApiEncrypt 注解 + ApiEncrypt apiEncrypt = getApiEncrypt(request); + boolean requestEnable = apiEncrypt != null && apiEncrypt.request(); + boolean responseEnable = apiEncrypt != null && apiEncrypt.response(); + String encryptHeader = request.getHeader(apiEncryptProperties.getHeader()); + if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) { + chain.doFilter(request, response); + return; + } + + // 1. 解密请求 + if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()), + HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) { + try { + if (StrUtil.isNotBlank(encryptHeader)) { + request = new ApiDecryptRequestWrapper(request, + requestSymmetricDecryptor, requestAsymmetricDecryptor); + } else if (requestEnable) { + throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头"); + } + } catch (Exception ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } + + // 2. 执行过滤器链 + if (responseEnable) { + // 特殊:仅包装,最后执行。目的:Response 内容可以被重复读取!!! + response = new ApiEncryptResponseWrapper(response); + } + chain.doFilter(request, response); + + // 3. 加密响应(真正执行) + if (responseEnable) { + ((ApiEncryptResponseWrapper) response).encrypt(apiEncryptProperties, + responseSymmetricEncryptor, responseAsymmetricEncryptor); + } + } + + /** + * 获取 @ApiEncrypt 注解 + * + * @param request 请求 + */ + @SuppressWarnings("PatternVariableCanBeUsed") + private ApiEncrypt getApiEncrypt(HttpServletRequest request) { + try { + // 特殊:兼容 SpringBoot 2.X 版本会报错的问题 https://t.zsxq.com/kqyiB + if (!ServletRequestPathUtils.hasParsedRequestPath(request)) { + ServletRequestPathUtils.parseAndCache(request); + } + + // 解析 @ApiEncrypt 注解 + HandlerExecutionChain mappingHandler = requestMappingHandlerMapping.getHandler(request); + if (mappingHandler == null) { + return null; + } + Object handler = mappingHandler.getHandler(); + if (handler instanceof HandlerMethod) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + ApiEncrypt annotation = handlerMethod.getMethodAnnotation(ApiEncrypt.class); + if (annotation == null) { + annotation = handlerMethod.getBeanType().getAnnotation(ApiEncrypt.class); + } + return annotation; + } + } catch (Exception e) { + log.error("[getApiEncrypt][url({}/{}) 获取 @ApiEncrypt 注解失败]", + request.getRequestURI(), request.getMethod(), e); + } + return null; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java new file mode 100644 index 0000000..7725623 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java @@ -0,0 +1,109 @@ +package cn.aagro.pp.framework.encrypt.core.filter; + +import cn.hutool.crypto.asymmetric.AsymmetricEncryptor; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.symmetric.SymmetricEncryptor; +import cn.aagro.pp.framework.encrypt.config.ApiEncryptProperties; + +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; + +/** + * 加密响应 {@link HttpServletResponseWrapper} 实现类 + * + * @author 芋道源码 + */ +public class ApiEncryptResponseWrapper extends HttpServletResponseWrapper { + + private final ByteArrayOutputStream byteArrayOutputStream; + private final ServletOutputStream servletOutputStream; + private final PrintWriter printWriter; + + public ApiEncryptResponseWrapper(HttpServletResponse response) { + super(response); + this.byteArrayOutputStream = new ByteArrayOutputStream(); + this.servletOutputStream = this.getOutputStream(); + this.printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream)); + } + + public void encrypt(ApiEncryptProperties properties, + SymmetricEncryptor symmetricEncryptor, + AsymmetricEncryptor asymmetricEncryptor) throws IOException { + // 1.1 清空 body + HttpServletResponse response = (HttpServletResponse) this.getResponse(); + response.resetBuffer(); + // 1.2 获取 body + this.flushBuffer(); + byte[] body = byteArrayOutputStream.toByteArray(); + + // 2. 加密 body + String encryptedBody = symmetricEncryptor != null ? symmetricEncryptor.encryptBase64(body) + : asymmetricEncryptor.encryptBase64(body, KeyType.PublicKey); + response.getWriter().write(encryptedBody); + + // 3. 添加加密 header 标识 + this.addHeader(properties.getHeader(), "true"); + // 特殊:特殊:https://juejin.cn/post/6867327674675625992 + this.addHeader("Access-Control-Expose-Headers", properties.getHeader()); + } + + @Override + public PrintWriter getWriter() { + return printWriter; + } + + @Override + public void flushBuffer() throws IOException { + if (servletOutputStream != null) { + servletOutputStream.flush(); + } + if (printWriter != null) { + printWriter.flush(); + } + } + + @Override + public void reset() { + byteArrayOutputStream.reset(); + } + + @Override + public ServletOutputStream getOutputStream() { + return new ServletOutputStream() { + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + } + + @Override + public void write(int b) { + byteArrayOutputStream.write(b); + } + + @Override + @SuppressWarnings("NullableProblems") + public void write(byte[] b) throws IOException { + byteArrayOutputStream.write(b); + } + + @Override + @SuppressWarnings("NullableProblems") + public void write(byte[] b, int off, int len) { + byteArrayOutputStream.write(b, off, len); + } + + }; + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/package-info.java new file mode 100644 index 0000000..8294e6c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/encrypt/package-info.java @@ -0,0 +1,4 @@ +/** + * HTTP API 加密组件:支持 Request 和 Response 的加密、解密 + */ +package cn.aagro.pp.framework.encrypt; \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/jackson/config/AagroJacksonAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/jackson/config/AagroJacksonAutoConfiguration.java new file mode 100644 index 0000000..052dfc5 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/jackson/config/AagroJacksonAutoConfiguration.java @@ -0,0 +1,78 @@ +package cn.aagro.pp.framework.jackson.config; + +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.common.util.json.databind.NumberSerializer; +import cn.aagro.pp.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer; +import cn.aagro.pp.framework.common.util.json.databind.TimestampLocalDateTimeSerializer; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@AutoConfiguration(after = JacksonAutoConfiguration.class) +@Slf4j +public class AagroJacksonAutoConfiguration { + + /** + * 从 Builder 源头定制(关键:使用 *ByType,避免 handledType 要求) + */ + @Bean + public Jackson2ObjectMapperBuilderCustomizer ldtEpochMillisCustomizer() { + return builder -> builder + // Long -> Number + .serializerByType(Long.class, NumberSerializer.INSTANCE) + .serializerByType(Long.TYPE, NumberSerializer.INSTANCE) + // LocalDate / LocalTime + .serializerByType(LocalDate.class, LocalDateSerializer.INSTANCE) + .deserializerByType(LocalDate.class, LocalDateDeserializer.INSTANCE) + .serializerByType(LocalTime.class, LocalTimeSerializer.INSTANCE) + .deserializerByType(LocalTime.class, LocalTimeDeserializer.INSTANCE) + // LocalDateTime < - > EpochMillis + .serializerByType(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE) + .deserializerByType(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + } + + /** + * 以 Bean 形式暴露 Module(Boot 会自动注册到所有 ObjectMapper) + */ + @Bean + public Module timestampSupportModuleBean() { + SimpleModule m = new SimpleModule("TimestampSupportModule"); + // Long -> Number,避免前端精度丢失 + m.addSerializer(Long.class, NumberSerializer.INSTANCE); + m.addSerializer(Long.TYPE, NumberSerializer.INSTANCE); + // LocalDate / LocalTime + m.addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE); + m.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE); + m.addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE); + m.addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE); + // LocalDateTime < - > EpochMillis + m.addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE); + m.addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + return m; + } + + /** + * 初始化全局 JsonUtils,直接使用主 ObjectMapper + */ + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public JsonUtils jsonUtils(ObjectMapper objectMapper) { + JsonUtils.init(objectMapper); + log.debug("[init][初始化 JsonUtils 成功]"); + return new JsonUtils(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/jackson/core/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/jackson/core/package-info.java new file mode 100644 index 0000000..1882c46 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/jackson/core/package-info.java @@ -0,0 +1 @@ +package cn.aagro.pp.framework.jackson.core; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/package-info.java new file mode 100644 index 0000000..324e6c0 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/package-info.java @@ -0,0 +1,4 @@ +/** + * Web 框架,全局异常、API 日志等 + */ +package cn.aagro.pp.framework; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/AagroSwaggerAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/AagroSwaggerAutoConfiguration.java new file mode 100644 index 0000000..f5b23ab --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/AagroSwaggerAutoConfiguration.java @@ -0,0 +1,182 @@ +package cn.aagro.pp.framework.swagger.config; + +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jAutoConfiguration; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.*; +import org.springdoc.core.customizers.OpenApiBuilderCustomizer; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.customizers.ServerBaseUrlCustomizer; +import org.springdoc.core.providers.JavadocProvider; +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.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static cn.aagro.pp.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Swagger 自动配置类,基于 OpenAPI + Springdoc 实现。 + * + * 友情提示: + * 1. Springdoc 文档地址:仓库 + * 2. Swagger 规范,于 2015 更名为 OpenAPI 规范,本质是一个东西 + * + * @author 芋道源码 + */ +@AutoConfiguration(before = Knife4jAutoConfiguration.class) // before 原因,保证覆写的 Knife4jOpenApiCustomizer 先生效!相关 https://github.com/YunaiV/ruoyi-vue-pro/issues/954 讨论 +@ConditionalOnClass({OpenAPI.class}) +@EnableConfigurationProperties(SwaggerProperties.class) +@ConditionalOnProperty(prefix = "springdoc.api-docs", name = "enabled", havingValue = "true", matchIfMissing = true) // 设置为 false 时,禁用 +@Import(Knife4jOpenApiCustomizer.class) +public class AagroSwaggerAutoConfiguration { + + // ========== 全局 OpenAPI 配置 ========== + + @Bean + public OpenAPI createApi(SwaggerProperties properties) { + Map securitySchemas = buildSecuritySchemes(); + OpenAPI openAPI = new OpenAPI() + // 接口信息 + .info(buildInfo(properties)) + // 接口安全配置 + .components(new Components().securitySchemes(securitySchemas)) + .addSecurityItem(new SecurityRequirement().addList(HttpHeaders.AUTHORIZATION)); + securitySchemas.keySet().forEach(key -> openAPI.addSecurityItem(new SecurityRequirement().addList(key))); + return openAPI; + } + + /** + * API 摘要信息 + */ + private Info buildInfo(SwaggerProperties properties) { + return new Info() + .title(properties.getTitle()) + .description(properties.getDescription()) + .version(properties.getVersion()) + .contact(new Contact().name(properties.getAuthor()).url(properties.getUrl()).email(properties.getEmail())) + .license(new License().name(properties.getLicense()).url(properties.getLicenseUrl())); + } + + /** + * 安全模式,这里配置通过请求头 Authorization 传递 token 参数 + */ + private Map buildSecuritySchemes() { + Map securitySchemes = new HashMap<>(); + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) // 类型 + .name(HttpHeaders.AUTHORIZATION) // 请求头的 name + .in(SecurityScheme.In.HEADER); // token 所在位置 + securitySchemes.put(HttpHeaders.AUTHORIZATION, securityScheme); + return securitySchemes; + } + + /** + * 自定义 OpenAPI 处理器 + */ + @Bean + @Primary // 目的:以我们创建的 OpenAPIService Bean 为主,避免一键改包后,启动报错! + public OpenAPIService openApiBuilder(Optional openAPI, + SecurityService securityParser, + SpringDocConfigProperties springDocConfigProperties, + PropertyResolverUtils propertyResolverUtils, + Optional> openApiBuilderCustomizers, + Optional> serverBaseUrlCustomizers, + Optional javadocProvider) { + return new OpenAPIService(openAPI, securityParser, springDocConfigProperties, + propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider); + } + + // ========== 分组 OpenAPI 配置 ========== + + /** + * 所有模块的 API 分组 + */ + @Bean + public GroupedOpenApi allGroupedOpenApi() { + return buildGroupedOpenApi("all", ""); + } + + public static GroupedOpenApi buildGroupedOpenApi(String group) { + return buildGroupedOpenApi(group, group); + } + + public static GroupedOpenApi buildGroupedOpenApi(String group, String path) { + return GroupedOpenApi.builder() + .group(group) + .pathsToMatch("/admin-api/" + path + "/**", "/app-api/" + path + "/**") + .addOperationCustomizer((operation, handlerMethod) -> operation + .addParametersItem(buildTenantHeaderParameter()) + .addParametersItem(buildSecurityHeaderParameter())) + .addOperationCustomizer(buildOperationIdCustomizer()) + .build(); + } + + /** + * 构建 Tenant 租户编号请求头参数 + * + * @return 多租户参数 + */ + private static Parameter buildTenantHeaderParameter() { + return new Parameter() + .name(HEADER_TENANT_ID) // header 名 + .description("租户编号") // 描述 + .in(String.valueOf(SecurityScheme.In.HEADER)) // 请求 header + .schema(new IntegerSchema()._default(1L).name(HEADER_TENANT_ID).description("租户编号")); // 默认:使用租户编号为 1 + } + + /** + * 构建 Authorization 认证请求头参数 + * + * 解决 Knife4j Authorize 未生效,请求header里未包含参数 + * + * @return 认证参数 + */ + private static Parameter buildSecurityHeaderParameter() { + return new Parameter() + .name(HttpHeaders.AUTHORIZATION) // header 名 + .description("认证 Token") // 描述 + .in(String.valueOf(SecurityScheme.In.HEADER)) // 请求 header + .schema(new StringSchema()._default("Bearer test1").name(HEADER_TENANT_ID).description("认证 Token")); // 默认:使用用户编号为 1 + } + + /** + * 核心:自定义OperationId生成规则,组合「类名前缀 + 方法名」 + * + * @see app-api 前缀不生效,都是使用 admin-api + */ + private static OperationCustomizer buildOperationIdCustomizer() { + return (operation, handlerMethod) -> { + // 1. 获取控制器类名(如 UserController) + String className = handlerMethod.getBeanType().getSimpleName(); + // 2. 提取类名前缀(去除 Controller 后缀,如 UserController -> User) + String classPrefix = className.replaceAll("Controller$", ""); + // 3. 获取方法名(如 list) + String methodName = handlerMethod.getMethod().getName(); + // 4. 组合生成 operationId(如 User_list) + String operationId = classPrefix + "_" + methodName; + // 5. 设置自定义 operationId + operation.setOperationId(operationId); + return operation; + }; + } + +} + diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/Knife4jOpenApiCustomizer.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/Knife4jOpenApiCustomizer.java new file mode 100644 index 0000000..3f59080 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/Knife4jOpenApiCustomizer.java @@ -0,0 +1,146 @@ +package cn.aagro.pp.framework.swagger.config; + +import com.github.xiaoymin.knife4j.annotations.ApiSupport; +import com.github.xiaoymin.knife4j.core.conf.ExtensionsConstants; +import com.github.xiaoymin.knife4j.core.conf.GlobalConstants; +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jProperties; +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jSetting; +import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.OpenAPI; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.springdoc.core.SpringDocConfigProperties; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.Annotation; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 增强扩展属性支持 + * + * 参考 Spring Boot 3.4 以上版本 /v3/api-docs 解决接口报错,依赖修复 + * + * @since 4.1.0 + * @author xiaoymin@foxmail.com + * 2022/12/11 22:40 + */ +@Primary +@Configuration +@Slf4j +public class Knife4jOpenApiCustomizer extends com.github.xiaoymin.knife4j.spring.extension.Knife4jOpenApiCustomizer + implements GlobalOpenApiCustomizer { + + final Knife4jProperties knife4jProperties; + final SpringDocConfigProperties properties; + + public Knife4jOpenApiCustomizer(Knife4jProperties knife4jProperties, SpringDocConfigProperties properties) { + super(knife4jProperties,properties); + this.knife4jProperties = knife4jProperties; + this.properties = properties; + } + + @Override + public void customise(OpenAPI openApi) { + if (knife4jProperties.isEnable()) { + Knife4jSetting setting = knife4jProperties.getSetting(); + OpenApiExtensionResolver openApiExtensionResolver = new OpenApiExtensionResolver(setting, knife4jProperties.getDocuments()); + // 解析初始化 + openApiExtensionResolver.start(); + Map objectMap = new HashMap<>(); + objectMap.put(GlobalConstants.EXTENSION_OPEN_SETTING_NAME, setting); + objectMap.put(GlobalConstants.EXTENSION_OPEN_MARKDOWN_NAME, openApiExtensionResolver.getMarkdownFiles()); + openApi.addExtension(GlobalConstants.EXTENSION_OPEN_API_NAME, objectMap); + addOrderExtension(openApi); + } + } + + /** + * 往 OpenAPI 内 tags 字段添加 x-order 属性 + * + * @param openApi openApi + */ + private void addOrderExtension(OpenAPI openApi) { + if (CollectionUtils.isEmpty(properties.getGroupConfigs())) { + return; + } + // 获取包扫描路径 + Set packagesToScan = + properties.getGroupConfigs().stream() + .map(SpringDocConfigProperties.GroupConfig::getPackagesToScan) + .filter(toScan -> !CollectionUtils.isEmpty(toScan)) + .flatMap(List::stream) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(packagesToScan)) { + return; + } + // 扫描包下被 ApiSupport 注解的 RestController Class + Set> classes = packagesToScan.stream() + .map(packageToScan -> scanPackageByAnnotation(packageToScan, RestController.class)) + .flatMap(Set::stream) + .filter(clazz -> clazz.isAnnotationPresent(ApiSupport.class)) + .collect(Collectors.toSet()); + if (!CollectionUtils.isEmpty(classes)) { + // ApiSupport oder 值存入 tagSortMap + Map tagOrderMap = new HashMap<>(); + classes.forEach(clazz -> { + Tag tag = getTag(clazz); + if (Objects.nonNull(tag)) { + ApiSupport apiSupport = clazz.getAnnotation(ApiSupport.class); + tagOrderMap.putIfAbsent(tag.name(), apiSupport.order()); + } + }); + // 往 openApi tags 字段添加 x-order 增强属性 + if (openApi.getTags() != null) { + openApi.getTags().forEach(tag -> { + if (tagOrderMap.containsKey(tag.getName())) { + tag.addExtension(ExtensionsConstants.EXTENSION_ORDER, tagOrderMap.get(tag.getName())); + } + }); + } + } + } + + private Tag getTag(Class clazz) { + // 从类上获取 + Tag tag = clazz.getAnnotation(Tag.class); + if (Objects.isNull(tag)) { + // 从接口上获取 + Class[] interfaces = clazz.getInterfaces(); + if (ArrayUtils.isNotEmpty(interfaces)) { + for (Class interfaceClazz : interfaces) { + Tag anno = interfaceClazz.getAnnotation(Tag.class); + if (Objects.nonNull(anno)) { + tag = anno; + break; + } + } + } + } + return tag; + } + + private Set> scanPackageByAnnotation(String packageName, final Class annotationClass) { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(annotationClass)); + Set> classes = new HashSet<>(); + for (BeanDefinition beanDefinition : scanner.findCandidateComponents(packageName)) { + try { + Class clazz = Class.forName(beanDefinition.getBeanClassName()); + classes.add(clazz); + } catch (ClassNotFoundException ignore) { + } + } + return classes; + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/SwaggerProperties.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/SwaggerProperties.java new file mode 100644 index 0000000..bcb05ff --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/config/SwaggerProperties.java @@ -0,0 +1,60 @@ +package cn.aagro.pp.framework.swagger.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import javax.validation.constraints.NotEmpty; + +/** + * Swagger 配置属性 + * + * @author 芋道源码 + */ +@ConfigurationProperties("aagro.swagger") +@Data +public class SwaggerProperties { + + /** + * 标题 + */ + @NotEmpty(message = "标题不能为空") + private String title; + /** + * 描述 + */ + @NotEmpty(message = "描述不能为空") + private String description; + /** + * 作者 + */ + @NotEmpty(message = "作者不能为空") + private String author; + /** + * 版本 + */ + @NotEmpty(message = "版本不能为空") + private String version; + /** + * url + */ + @NotEmpty(message = "扫描的 package 不能为空") + private String url; + /** + * email + */ + @NotEmpty(message = "扫描的 email 不能为空") + private String email; + + /** + * license + */ + @NotEmpty(message = "扫描的 license 不能为空") + private String license; + + /** + * license-url + */ + @NotEmpty(message = "扫描的 license-url 不能为空") + private String licenseUrl; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/package-info.java new file mode 100644 index 0000000..d7bb8fb --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/swagger/package-info.java @@ -0,0 +1,6 @@ +/** + * 基于 Swagger + Knife4j 实现 API 接口文档 + * + * @author 芋道源码 + */ +package cn.aagro.pp.framework.swagger; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/config/AagroWebAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/config/AagroWebAutoConfiguration.java new file mode 100644 index 0000000..83b52ab --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/config/AagroWebAutoConfiguration.java @@ -0,0 +1,153 @@ +package cn.aagro.pp.framework.web.config; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import cn.aagro.pp.framework.common.enums.WebFilterOrderEnum; +import cn.aagro.pp.framework.web.core.filter.CacheRequestBodyFilter; +import cn.aagro.pp.framework.web.core.filter.DemoFilter; +import cn.aagro.pp.framework.web.core.handler.GlobalExceptionHandler; +import cn.aagro.pp.framework.web.core.handler.GlobalResponseBodyHandler; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import com.google.common.collect.Maps; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import javax.servlet.Filter; +import java.util.Map; +import java.util.function.Predicate; + +@AutoConfiguration +@EnableConfigurationProperties(WebProperties.class) +public class AagroWebAutoConfiguration { + + /** + * 应用名 + */ + @Value("${spring.application.name}") + private String applicationName; + + @Bean + public WebMvcRegistrations webMvcRegistrations(WebProperties webProperties) { + return new WebMvcRegistrations() { + + @Override + public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { + RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); + // 实例化时就带上前缀 + mapping.setPathPrefixes(buildPathPrefixes(webProperties)); + return mapping; + } + + /** + * 构建 prefix → 匹配条件的映射 + */ + private Map>> buildPathPrefixes(WebProperties webProperties) { + AntPathMatcher antPathMatcher = new AntPathMatcher("."); + Map>> pathPrefixes = Maps.newLinkedHashMapWithExpectedSize(2); + putPathPrefix(pathPrefixes, webProperties.getAdminApi(), antPathMatcher); + putPathPrefix(pathPrefixes, webProperties.getAppApi(), antPathMatcher); + return pathPrefixes; + } + + /** + * 设置 API 前缀,仅仅匹配 controller 包下的 + */ + private void putPathPrefix(Map>> pathPrefixes, WebProperties.Api api, AntPathMatcher matcher) { + if (api == null || StrUtil.isEmpty(api.getPrefix())) { + return; + } + pathPrefixes.put(api.getPrefix(), // api 前缀 + clazz -> clazz.isAnnotationPresent(RestController.class) + && matcher.match(api.getController(), clazz.getPackage().getName())); + } + + }; + } + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) { + return new GlobalExceptionHandler(applicationName, apiErrorLogApi); + } + + @Bean + public GlobalResponseBodyHandler globalResponseBodyHandler() { + return new GlobalResponseBodyHandler(); + } + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { + // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean + return new WebFrameworkUtils(webProperties); + } + + // ========== Filter 相关 ========== + + /** + * 创建 CorsFilter Bean,解决跨域问题 + */ + @Bean + public FilterRegistrationBean corsFilterBean() { + // 创建 CorsConfiguration 对象 + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); // 设置访问源地址 + config.addAllowedHeader("*"); // 设置访问源请求头 + config.addAllowedMethod("*"); // 设置访问源请求方法 + // 创建 UrlBasedCorsConfigurationSource 对象 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 + return createFilterBean(new CorsFilter(source), WebFilterOrderEnum.CORS_FILTER); + } + + /** + * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容 + */ + @Bean + public FilterRegistrationBean requestBodyCacheFilter() { + return createFilterBean(new CacheRequestBodyFilter(), WebFilterOrderEnum.REQUEST_BODY_CACHE_FILTER); + } + + /** + * 创建 DemoFilter Bean,演示模式 + */ + @Bean + @ConditionalOnProperty(value = "aagro.demo", havingValue = "true") + public FilterRegistrationBean demoFilter() { + return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); + } + + public static FilterRegistrationBean createFilterBean(T filter, Integer order) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.setOrder(order); + return bean; + } + + /** + * 创建 RestTemplate 实例 + * + * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} + */ + @Bean + @ConditionalOnMissingBean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder.build(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/config/WebProperties.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/config/WebProperties.java new file mode 100644 index 0000000..a40fc9c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/config/WebProperties.java @@ -0,0 +1,66 @@ +package cn.aagro.pp.framework.web.config; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +@ConfigurationProperties(prefix = "aagro.web") +@Validated +@Data +public class WebProperties { + + @NotNull(message = "APP API 不能为空") + private Api appApi = new Api("/app-api", "**.controller.app.**"); + @NotNull(message = "Admin API 不能为空") + private Api adminApi = new Api("/admin-api", "**.controller.admin.**"); + + @NotNull(message = "Admin UI 不能为空") + private Ui adminUi; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Valid + public static class Api { + + /** + * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀 + * + * + * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题 + * 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。 + * + * @see AagroWebAutoConfiguration#configurePathMatch(PathMatchConfigurer) + */ + @NotEmpty(message = "API 前缀不能为空") + private String prefix; + + /** + * Controller 所在包的 Ant 路径规则 + * + * 主要目的是,给该 Controller 设置指定的 {@link #prefix} + */ + @NotEmpty(message = "Controller 所在包不能为空") + private String controller; + + } + + @Data + @Valid + public static class Ui { + + /** + * 访问地址 + */ + private String url; + + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/ApiRequestFilter.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/ApiRequestFilter.java new file mode 100644 index 0000000..62b517c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/ApiRequestFilter.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.web.config.WebProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.http.HttpServletRequest; + +/** + * 过滤 /admin-api、/app-api 等 API 请求的过滤器 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public abstract class ApiRequestFilter extends OncePerRequestFilter { + + protected final WebProperties webProperties; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 只过滤 API 请求的地址 + String apiUri = request.getRequestURI().substring(request.getContextPath().length()); + return !StrUtil.startWithAny(apiUri, webProperties.getAdminApi().getPrefix(), webProperties.getAppApi().getPrefix()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/CacheRequestBodyFilter.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/CacheRequestBodyFilter.java new file mode 100644 index 0000000..1f496ed --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/CacheRequestBodyFilter.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +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; + +/** + * Request Body 缓存 Filter,实现它的可重复读取 + * + * @author 芋道源码 + */ +public class CacheRequestBodyFilter extends OncePerRequestFilter { + + /** + * 需要排除的 URI + * + * 1. 排除 Spring Boot Admin 相关请求,避免客户端连接中断导致的异常。 + * 例如说:795 ISSUE + */ + private static final String[] IGNORE_URIS = {"/admin/", "/actuator/"}; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + filterChain.doFilter(new CacheRequestBodyWrapper(request), response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 1. 校验是否为排除的 URL + String requestURI = request.getRequestURI(); + if (StrUtil.startWithAny(requestURI, IGNORE_URIS)) { + return true; + } + + // 2. 只处理 json 请求内容 + return !ServletUtils.isJsonRequest(request); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/CacheRequestBodyWrapper.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/CacheRequestBodyWrapper.java new file mode 100644 index 0000000..f503650 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/CacheRequestBodyWrapper.java @@ -0,0 +1,77 @@ +package cn.aagro.pp.framework.web.core.filter; + +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; + +/** + * Request Body 缓存 Wrapper + * + * @author 芋道源码 + */ +public class CacheRequestBodyWrapper extends HttpServletRequestWrapper { + + /** + * 缓存的内容 + */ + private final byte[] body; + + public CacheRequestBodyWrapper(HttpServletRequest request) { + super(request); + body = ServletUtils.getBodyBytes(request); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(this.getInputStream())); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + + @Override + public ServletInputStream getInputStream() { + final ByteArrayInputStream inputStream = new ByteArrayInputStream(body); + // 返回 ServletInputStream + return new ServletInputStream() { + + @Override + public int read() { + return inputStream.read(); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) {} + + @Override + public int available() { + return body.length; + } + + }; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/DemoFilter.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/DemoFilter.java new file mode 100644 index 0000000..f577f7b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/filter/DemoFilter.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.DEMO_DENY; + +/** + * 演示 Filter,禁止用户发起写操作,避免影响测试数据 + * + * @author 芋道源码 + */ +public class DemoFilter extends OncePerRequestFilter { + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String method = request.getMethod(); + return !StrUtil.equalsAnyIgnoreCase(method, "POST", "PUT", "DELETE") // 写操作时,不进行过滤率 + || WebFrameworkUtils.getLoginUserId(request) == null; // 非登录用户时,不进行过滤 + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { + // 直接返回 DEMO_DENY 的结果。即,请求不继续 + ServletUtils.writeJSON(response, CommonResult.error(DEMO_DENY)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/handler/GlobalExceptionHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..f4f43d7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/handler/GlobalExceptionHandler.java @@ -0,0 +1,440 @@ +package cn.aagro.pp.framework.web.core.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import cn.aagro.pp.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO; +import cn.aagro.pp.framework.common.exception.ServiceException; +import cn.aagro.pp.framework.common.exception.util.ServiceExceptionUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.util.collection.SetUtils; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.common.util.monitor.TracerUtils; +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.google.common.util.concurrent.UncheckedExecutionException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.util.Assert; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.ValidationException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.aagro.pp.framework.common.exception.enums.GlobalErrorCodeConstants.*; + +/** + * 全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号 + * + * @author 芋道源码 + */ +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class GlobalExceptionHandler { + + /** + * 忽略的 ServiceException 错误提示,避免打印过多 logger + */ + public static final Set IGNORE_ERROR_MESSAGES = SetUtils.asSet("无效的刷新令牌"); + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + private final String applicationName; + + private final ApiErrorLogCommonApi apiErrorLogApi; + + /** + * 处理所有异常,主要是提供给 Filter 使用 + * 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。 + * + * @param request 请求 + * @param ex 异常 + * @return 通用返回 + */ + public CommonResult allExceptionHandler(HttpServletRequest request, Throwable ex) { + if (ex instanceof MissingServletRequestParameterException) { + return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex); + } + if (ex instanceof MethodArgumentTypeMismatchException) { + return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex); + } + if (ex instanceof MethodArgumentNotValidException) { + return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex); + } + if (ex instanceof BindException) { + return bindExceptionHandler((BindException) ex); + } + if (ex instanceof ConstraintViolationException) { + return constraintViolationExceptionHandler((ConstraintViolationException) ex); + } + if (ex instanceof ValidationException) { + return validationException((ValidationException) ex); + } + if (ex instanceof MaxUploadSizeExceededException) { + return maxUploadSizeExceededExceptionHandler((MaxUploadSizeExceededException) ex); + } + if (ex instanceof NoHandlerFoundException) { + return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex); + } + if (ex instanceof HttpRequestMethodNotSupportedException) { + return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex); + } + if (ex instanceof HttpMediaTypeNotSupportedException) { + return httpMediaTypeNotSupportedExceptionHandler((HttpMediaTypeNotSupportedException) ex); + } + if (ex instanceof ServiceException) { + return serviceExceptionHandler((ServiceException) ex); + } + if (ex instanceof AccessDeniedException) { + return accessDeniedExceptionHandler(request, (AccessDeniedException) ex); + } + return defaultExceptionHandler(request, ex); + } + + /** + * 处理 SpringMVC 请求参数缺失 + * + * 例如说,接口上设置了 @RequestParam("xx") 参数,结果并未传递 xx 参数 + */ + @ExceptionHandler(value = MissingServletRequestParameterException.class) + public CommonResult missingServletRequestParameterExceptionHandler(MissingServletRequestParameterException ex) { + log.warn("[missingServletRequestParameterExceptionHandler]", ex); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数缺失:%s", ex.getParameterName())); + } + + /** + * 处理 SpringMVC 请求参数类型错误 + * + * 例如说,接口上设置了 @RequestParam("xx") 参数为 Integer,结果传递 xx 参数类型为 String + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public CommonResult methodArgumentTypeMismatchExceptionHandler(MethodArgumentTypeMismatchException ex) { + log.warn("[methodArgumentTypeMismatchExceptionHandler]", ex); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", ex.getMessage())); + } + + /** + * 处理 SpringMVC 参数校验不正确 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public CommonResult methodArgumentNotValidExceptionExceptionHandler(MethodArgumentNotValidException ex) { + log.warn("[methodArgumentNotValidExceptionExceptionHandler]", ex); + // 获取 errorMessage + String errorMessage = null; + FieldError fieldError = ex.getBindingResult().getFieldError(); + if (fieldError == null) { + // 组合校验,参考自 https://t.zsxq.com/3HVTx + List allErrors = ex.getBindingResult().getAllErrors(); + if (CollUtil.isNotEmpty(allErrors)) { + errorMessage = allErrors.get(0).getDefaultMessage(); + } + } else { + errorMessage = fieldError.getDefaultMessage(); + } + // 转换 CommonResult + if (StrUtil.isEmpty(errorMessage)) { + return CommonResult.error(BAD_REQUEST); + } + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", errorMessage)); + } + + /** + * 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验 + */ + @ExceptionHandler(BindException.class) + public CommonResult bindExceptionHandler(BindException ex) { + log.warn("[handleBindException]", ex); + FieldError fieldError = ex.getFieldError(); + assert fieldError != null; // 断言,避免告警 + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage())); + } + + /** + * 处理 SpringMVC 请求参数类型错误 + * + * 例如说,接口上设置了 @RequestBody 实体中 xx 属性类型为 Integer,结果传递 xx 参数类型为 String + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + @SuppressWarnings("PatternVariableCanBeUsed") + public CommonResult methodArgumentTypeInvalidFormatExceptionHandler(HttpMessageNotReadableException ex) { + log.warn("[methodArgumentTypeInvalidFormatExceptionHandler]", ex); + if (ex.getCause() instanceof InvalidFormatException) { + InvalidFormatException invalidFormatException = (InvalidFormatException) ex.getCause(); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", invalidFormatException.getValue())); + } + if (StrUtil.startWith(ex.getMessage(), "Required request body is missing")) { + return CommonResult.error(BAD_REQUEST.getCode(), "请求参数类型错误: request body 缺失"); + } + return defaultExceptionHandler(ServletUtils.getRequest(), ex); + } + + /** + * 处理 Validator 校验不通过产生的异常 + */ + @ExceptionHandler(value = ConstraintViolationException.class) + public CommonResult constraintViolationExceptionHandler(ConstraintViolationException ex) { + log.warn("[constraintViolationExceptionHandler]", ex); + ConstraintViolation constraintViolation = ex.getConstraintViolations().iterator().next(); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage())); + } + + /** + * 处理 Dubbo Consumer 本地参数校验时,抛出的 ValidationException 异常 + */ + @ExceptionHandler(value = ValidationException.class) + public CommonResult validationException(ValidationException ex) { + log.warn("[constraintViolationExceptionHandler]", ex); + // 无法拼接明细的错误信息,因为 Dubbo Consumer 抛出 ValidationException 异常时,是直接的字符串信息,且人类不可读 + return CommonResult.error(BAD_REQUEST); + } + + /** + * 处理上传文件过大异常 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public CommonResult maxUploadSizeExceededExceptionHandler(MaxUploadSizeExceededException ex) { + return CommonResult.error(BAD_REQUEST.getCode(), "上传文件过大,请调整后重试"); + } + + /** + * 处理 SpringMVC 请求地址不存在 + * + * 注意,它需要设置如下两个配置项: + * 1. spring.mvc.throw-exception-if-no-handler-found 为 true + * 2. spring.mvc.static-path-pattern 为 /statics/** + */ + @ExceptionHandler(NoHandlerFoundException.class) + public CommonResult noHandlerFoundExceptionHandler(NoHandlerFoundException ex) { + log.warn("[noHandlerFoundExceptionHandler]", ex); + return CommonResult.error(NOT_FOUND.getCode(), String.format("请求地址不存在:%s", ex.getRequestURL())); + } + + /** + * 处理 SpringMVC 请求方法不正确 + * + * 例如说,A 接口的方法为 GET 方式,结果请求方法为 POST 方式,导致不匹配 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public CommonResult httpRequestMethodNotSupportedExceptionHandler(HttpRequestMethodNotSupportedException ex) { + log.warn("[httpRequestMethodNotSupportedExceptionHandler]", ex); + return CommonResult.error(METHOD_NOT_ALLOWED.getCode(), String.format("请求方法不正确:%s", ex.getMessage())); + } + + /** + * 处理 SpringMVC 请求的 Content-Type 不正确 + * + * 例如说,A 接口的 Content-Type 为 application/json,结果请求的 Content-Type 为 application/octet-stream,导致不匹配 + */ + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public CommonResult httpMediaTypeNotSupportedExceptionHandler(HttpMediaTypeNotSupportedException ex) { + log.warn("[httpMediaTypeNotSupportedExceptionHandler]", ex); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求类型不正确:%s", ex.getMessage())); + } + + /** + * 处理 Spring Security 权限不足的异常 + * + * 来源是,使用 @PreAuthorize 注解,AOP 进行权限拦截 + */ + @ExceptionHandler(value = AccessDeniedException.class) + public CommonResult accessDeniedExceptionHandler(HttpServletRequest req, AccessDeniedException ex) { + log.warn("[accessDeniedExceptionHandler][userId({}) 无法访问 url({})]", WebFrameworkUtils.getLoginUserId(req), + req.getRequestURL(), ex); + return CommonResult.error(FORBIDDEN); + } + + /** + * 处理 Guava UncheckedExecutionException + * + * 例如说,缓存加载报错,可见 https://t.zsxq.com/UszdH + */ + @ExceptionHandler(value = UncheckedExecutionException.class) + public CommonResult uncheckedExecutionExceptionHandler(HttpServletRequest req, UncheckedExecutionException ex) { + return allExceptionHandler(req, ex.getCause()); + } + + /** + * 处理业务异常 ServiceException + * + * 例如说,商品库存不足,用户手机号已存在。 + */ + @ExceptionHandler(value = ServiceException.class) + public CommonResult serviceExceptionHandler(ServiceException ex) { + // 不包含的时候,才进行打印,避免 ex 堆栈过多 + if (!IGNORE_ERROR_MESSAGES.contains(ex.getMessage())) { + // 即使打印,也只打印第一层 StackTraceElement,并且使用 warn 在控制台输出,更容易看到 + try { + StackTraceElement[] stackTraces = ex.getStackTrace(); + for (StackTraceElement stackTrace : stackTraces) { + if (ObjUtil.notEqual(stackTrace.getClassName(), ServiceExceptionUtil.class.getName())) { + log.warn("[serviceExceptionHandler]\n\t{}", stackTrace); + break; + } + } + } catch (Exception ignored) { + // 忽略日志,避免影响主流程 + } + } + return CommonResult.error(ex.getCode(), ex.getMessage()); + } + + /** + * 处理系统异常,兜底处理所有的一切 + */ + @ExceptionHandler(value = Exception.class) + public CommonResult defaultExceptionHandler(HttpServletRequest req, Throwable ex) { + // 特殊:如果是 ServiceException 的异常,则直接返回 + // 例如说:https://gitee.com/zhijiantianya/aagro-cloud/issues/ICSSRM、https://gitee.com/zhijiantianya/aagro-cloud/issues/ICT6FM + if (ex.getCause() != null && ex.getCause() instanceof ServiceException) { + return serviceExceptionHandler((ServiceException) ex.getCause()); + } + + // 情况一:处理表不存在的异常 + CommonResult tableNotExistsResult = handleTableNotExists(ex); + if (tableNotExistsResult != null) { + return tableNotExistsResult; + } + + // 情况二:处理异常 + log.error("[defaultExceptionHandler]", ex); + // 插入异常日志 + createExceptionLog(req, ex); + // 返回 ERROR CommonResult + return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + } + + private void createExceptionLog(HttpServletRequest req, Throwable e) { + // 插入错误日志 + ApiErrorLogCreateReqDTO errorLog = new ApiErrorLogCreateReqDTO(); + try { + // 初始化 errorLog + buildExceptionLog(errorLog, req, e); + // 执行插入 errorLog + apiErrorLogApi.createApiErrorLogAsync(errorLog); + } catch (Throwable th) { + log.error("[createExceptionLog][url({}) log({}) 发生异常]", req.getRequestURI(), JsonUtils.toJsonString(errorLog), th); + } + } + + private void buildExceptionLog(ApiErrorLogCreateReqDTO errorLog, HttpServletRequest request, Throwable e) { + // 处理用户信息 + errorLog.setUserId(WebFrameworkUtils.getLoginUserId(request)); + errorLog.setUserType(WebFrameworkUtils.getLoginUserType(request)); + // 设置异常字段 + errorLog.setExceptionName(e.getClass().getName()); + errorLog.setExceptionMessage(ExceptionUtil.getMessage(e)); + errorLog.setExceptionRootCauseMessage(ExceptionUtil.getRootCauseMessage(e)); + errorLog.setExceptionStackTrace(ExceptionUtil.stacktraceToString(e)); + StackTraceElement[] stackTraceElements = e.getStackTrace(); + Assert.notEmpty(stackTraceElements, "异常 stackTraceElements 不能为空"); + StackTraceElement stackTraceElement = stackTraceElements[0]; + errorLog.setExceptionClassName(stackTraceElement.getClassName()); + errorLog.setExceptionFileName(stackTraceElement.getFileName()); + errorLog.setExceptionMethodName(stackTraceElement.getMethodName()); + errorLog.setExceptionLineNumber(stackTraceElement.getLineNumber()); + // 设置其它字段 + errorLog.setTraceId(TracerUtils.getTraceId()); + errorLog.setApplicationName(applicationName); + errorLog.setRequestUrl(request.getRequestURI()); + Map requestParams = MapUtil.builder() + .put("query", ServletUtils.getParamMap(request)) + .put("body", ServletUtils.getBody(request)).build(); + errorLog.setRequestParams(JsonUtils.toJsonString(requestParams)); + errorLog.setRequestMethod(request.getMethod()); + errorLog.setUserAgent(ServletUtils.getUserAgent(request)); + errorLog.setUserIp(ServletUtils.getClientIP(request)); + errorLog.setExceptionTime(LocalDateTime.now()); + } + + /** + * 处理 Table 不存在的异常情况 + * + * @param ex 异常 + * @return 如果是 Table 不存在的异常,则返回对应的 CommonResult + */ + private CommonResult handleTableNotExists(Throwable ex) { + String message = ExceptionUtil.getRootCauseMessage(ex); + if (!message.contains("doesn't exist")) { + return null; + } + // 1. 数据报表 + if (message.contains("report_")) { + log.error("[报表模块 aagro-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[报表模块 aagro-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); + } + // 2. 工作流 + if (message.contains("bpm_")) { + log.error("[工作流模块 aagro-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[工作流模块 aagro-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]"); + } + // 3. 微信公众号 + if (message.contains("mp_")) { + log.error("[微信公众号 aagro-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[微信公众号 aagro-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); + } + // 4. 商城系统 + if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) { + log.error("[商城系统 aagro-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[商城系统 aagro-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); + } + // 5. ERP 系统 + if (message.contains("erp_")) { + log.error("[ERP 系统 aagro-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[ERP 系统 aagro-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); + } + // 6. CRM 系统 + if (message.contains("crm_")) { + log.error("[CRM 系统 aagro-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[CRM 系统 aagro-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); + } + // 7. 支付平台 + if (message.contains("pay_")) { + log.error("[支付模块 aagro-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[支付模块 aagro-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); + } + // 8. AI 大模型 + if (message.contains("ai_")) { + log.error("[AI 大模型 aagro-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[AI 大模型 aagro-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); + } + // 9. IoT 物联网 + if (message.contains("iot_")) { + log.error("[IoT 物联网 aagro-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[IoT 物联网 aagro-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]"); + } + return null; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/handler/GlobalResponseBodyHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/handler/GlobalResponseBodyHandler.java new file mode 100644 index 0000000..eda027e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/handler/GlobalResponseBodyHandler.java @@ -0,0 +1,45 @@ +package cn.aagro.pp.framework.web.core.handler; + +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.web.core.util.WebFrameworkUtils; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * 全局响应结果(ResponseBody)处理器 + * + * 不同于在网上看到的很多文章,会选择自动将 Controller 返回结果包上 {@link CommonResult}, + * 在 onemall 中,是 Controller 在返回时,主动自己包上 {@link CommonResult}。 + * 原因是,GlobalResponseBodyHandler 本质上是 AOP,它不应该改变 Controller 返回的数据结构 + * + * 目前,GlobalResponseBodyHandler 的主要作用是,记录 Controller 的返回结果, + * 方便 {@link cn.aagro.pp.framework.apilog.core.filter.ApiAccessLogFilter} 记录访问日志 + */ +@ControllerAdvice +public class GlobalResponseBodyHandler implements ResponseBodyAdvice { + + @Override + @SuppressWarnings("NullableProblems") // 避免 IDEA 警告 + public boolean supports(MethodParameter returnType, Class converterType) { + if (returnType.getMethod() == null) { + return false; + } + // 只拦截返回结果为 CommonResult 类型 + return returnType.getMethod().getReturnType() == CommonResult.class; + } + + @Override + @SuppressWarnings("NullableProblems") // 避免 IDEA 警告 + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + // 记录 Controller 结果 + WebFrameworkUtils.setCommonResult(((ServletServerHttpRequest) request).getServletRequest(), (CommonResult) body); + return body; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/util/WebFrameworkUtils.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/util/WebFrameworkUtils.java new file mode 100644 index 0000000..78bab34 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/core/util/WebFrameworkUtils.java @@ -0,0 +1,158 @@ +package cn.aagro.pp.framework.web.core.util; + +import cn.hutool.core.util.NumberUtil; +import cn.aagro.pp.framework.common.enums.TerminalEnum; +import cn.aagro.pp.framework.common.enums.UserTypeEnum; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.web.config.WebProperties; +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; + +/** + * 专属于 web 包的工具类 + * + * @author 芋道源码 + */ +public class WebFrameworkUtils { + + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; + + private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; + + public static final String HEADER_TENANT_ID = "tenant-id"; + public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; + + /** + * 终端的 Header + * + * @see cn.aagro.pp.framework.common.enums.TerminalEnum + */ + public static final String HEADER_TERMINAL = "terminal"; + + private static WebProperties properties; + + public WebFrameworkUtils(WebProperties webProperties) { + WebFrameworkUtils.properties = webProperties; + } + + /** + * 获得租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_TENANT_ID); + return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; + } + + /** + * 获得访问的租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getVisitTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); + return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; + } + + public static void setLoginUserId(ServletRequest request, Long userId) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); + } + + /** + * 设置用户类型 + * + * @param request 请求 + * @param userType 用户类型 + */ + public static void setLoginUserType(ServletRequest request, Integer userType) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); + } + + /** + * 获得当前用户的编号,从请求中 + * 注意:该方法仅限于 framework 框架使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Long getLoginUserId(HttpServletRequest request) { + if (request == null) { + return null; + } + return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); + } + + /** + * 获得当前用户的类型 + * 注意:该方法仅限于 web 相关的 framework 组件使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Integer getLoginUserType(HttpServletRequest request) { + if (request == null) { + return null; + } + // 1. 优先,从 Attribute 中获取 + Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); + if (userType != null) { + return userType; + } + // 2. 其次,基于 URL 前缀的约定 + if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { + return UserTypeEnum.ADMIN.getValue(); + } + if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { + return UserTypeEnum.MEMBER.getValue(); + } + return null; + } + + public static Integer getLoginUserType() { + HttpServletRequest request = getRequest(); + return getLoginUserType(request); + } + + public static Long getLoginUserId() { + HttpServletRequest request = getRequest(); + return getLoginUserId(request); + } + + public static Integer getTerminal() { + HttpServletRequest request = getRequest(); + if (request == null) { + return TerminalEnum.UNKNOWN.getTerminal(); + } + String terminalValue = request.getHeader(HEADER_TERMINAL); + return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); + } + + public static void setCommonResult(ServletRequest request, CommonResult result) { + request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); + } + + public static CommonResult getCommonResult(ServletRequest request) { + return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); + } + + @SuppressWarnings("PatternVariableCanBeUsed") + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; + return servletRequestAttributes.getRequest(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/package-info.java new file mode 100644 index 0000000..72c0adf --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * 针对 SpringMVC 的基础封装 + */ +package cn.aagro.pp.framework.web; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/config/AagroXssAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/config/AagroXssAutoConfiguration.java new file mode 100644 index 0000000..6716abb --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/config/AagroXssAutoConfiguration.java @@ -0,0 +1,62 @@ +package cn.aagro.pp.framework.xss.config; + +import cn.aagro.pp.framework.common.enums.WebFilterOrderEnum; +import cn.aagro.pp.framework.xss.core.clean.JsoupXssCleaner; +import cn.aagro.pp.framework.xss.core.clean.XssCleaner; +import cn.aagro.pp.framework.xss.core.filter.XssFilter; +import cn.aagro.pp.framework.xss.core.json.XssStringJsonDeserializer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.util.PathMatcher; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import static cn.aagro.pp.framework.web.config.AagroWebAutoConfiguration.createFilterBean; + +@AutoConfiguration +@EnableConfigurationProperties(XssProperties.class) +@ConditionalOnProperty(prefix = "aagro.xss", name = "enable", havingValue = "true", matchIfMissing = true) // 设置为 false 时,禁用 +public class AagroXssAutoConfiguration implements WebMvcConfigurer { + + /** + * Xss 清理者 + * + * @return XssCleaner + */ + @Bean + @ConditionalOnMissingBean(XssCleaner.class) + public XssCleaner xssCleaner() { + return new JsoupXssCleaner(); + } + + /** + * 注册 Jackson 的序列化器,用于处理 json 类型参数的 xss 过滤 + * + * @return Jackson2ObjectMapperBuilderCustomizer + */ + @Bean + @ConditionalOnMissingBean(name = "xssJacksonCustomizer") + @ConditionalOnProperty(value = "aagro.xss.enable", havingValue = "true") + public Jackson2ObjectMapperBuilderCustomizer xssJacksonCustomizer(XssProperties properties, + PathMatcher pathMatcher, + XssCleaner xssCleaner) { + // 在反序列化时进行 xss 过滤,可以替换使用 XssStringJsonSerializer,在序列化时进行处理 + return builder -> + builder.deserializerByType(String.class, new XssStringJsonDeserializer(properties, pathMatcher, xssCleaner)); + } + + /** + * 创建 XssFilter Bean,解决 Xss 安全问题 + */ + @Bean + @ConditionalOnBean(XssCleaner.class) + public FilterRegistrationBean xssFilter(XssProperties properties, PathMatcher pathMatcher, XssCleaner xssCleaner) { + return createFilterBean(new XssFilter(properties, pathMatcher, xssCleaner), WebFilterOrderEnum.XSS_FILTER); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/config/XssProperties.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/config/XssProperties.java new file mode 100644 index 0000000..9892373 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/config/XssProperties.java @@ -0,0 +1,29 @@ +package cn.aagro.pp.framework.xss.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.util.Collections; +import java.util.List; + +/** + * Xss 配置属性 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "aagro.xss") +@Validated +@Data +public class XssProperties { + + /** + * 是否开启,默认为 true + */ + private boolean enable = true; + /** + * 需要排除的 URL,默认为空 + */ + private List excludeUrls = Collections.emptyList(); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/clean/JsoupXssCleaner.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/clean/JsoupXssCleaner.java new file mode 100644 index 0000000..7bb26f4 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/clean/JsoupXssCleaner.java @@ -0,0 +1,64 @@ +package cn.aagro.pp.framework.xss.core.clean; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; + +/** + * 基于 JSONP 实现 XSS 过滤字符串 + */ +public class JsoupXssCleaner implements XssCleaner { + + private final Safelist safelist; + + /** + * 用于在 src 属性使用相对路径时,强制转换为绝对路径。 为空时不处理,值应为绝对路径的前缀(包含协议部分) + */ + private final String baseUri; + + /** + * 无参构造,默认使用 {@link JsoupXssCleaner#buildSafelist} 方法构建一个安全列表 + */ + public JsoupXssCleaner() { + this.safelist = buildSafelist(); + this.baseUri = ""; + } + + /** + * 构建一个 Xss 清理的 Safelist 规则。 + * 基于 Safelist#relaxed() 的基础上: + * 1. 扩展支持了 style 和 class 属性 + * 2. a 标签额外支持了 target 属性 + * 3. img 标签额外支持了 data 协议,便于支持 base64 + * + * @return Safelist + */ + private Safelist buildSafelist() { + // 使用 jsoup 提供的默认的 + Safelist relaxedSafelist = Safelist.relaxed(); + // 富文本编辑时一些样式是使用 style 来进行实现的 + // 比如红色字体 style="color:red;", 所以需要给所有标签添加 style 属性 + // 注意:style 属性会有注入风险 + relaxedSafelist.addAttributes(":all", "style", "class"); + // 保留 a 标签的 target 属性 + relaxedSafelist.addAttributes("a", "target"); + // 支持img 为base64 + relaxedSafelist.addProtocols("img", "src", "data"); + + // 保留相对路径, 保留相对路径时,必须提供对应的 baseUri 属性,否则依然会被删除 + // WHITELIST.preserveRelativeLinks(false); + + // 移除 a 标签和 img 标签的一些协议限制,这会导致 xss 防注入失效,如 + // 虽然可以重写 WhiteList#isSafeAttribute 来处理,但是有隐患,所以暂时不支持相对路径 + // WHITELIST.removeProtocols("a", "href", "ftp", "http", "https", "mailto"); + // WHITELIST.removeProtocols("img", "src", "http", "https"); + return relaxedSafelist; + } + + @Override + public String clean(String html) { + return Jsoup.clean(html, baseUri, safelist, new Document.OutputSettings().prettyPrint(false)); + } + +} + diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/clean/XssCleaner.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/clean/XssCleaner.java new file mode 100644 index 0000000..dfc8529 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/clean/XssCleaner.java @@ -0,0 +1,16 @@ +package cn.aagro.pp.framework.xss.core.clean; + +/** + * 对 html 文本中的有 Xss 风险的数据进行清理 + */ +public interface XssCleaner { + + /** + * 清理有 Xss 风险的文本 + * + * @param html 原 html + * @return 清理后的 html + */ + String clean(String html); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/filter/XssFilter.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/filter/XssFilter.java new file mode 100644 index 0000000..e736959 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/filter/XssFilter.java @@ -0,0 +1,52 @@ +package cn.aagro.pp.framework.xss.core.filter; + +import cn.aagro.pp.framework.xss.config.XssProperties; +import cn.aagro.pp.framework.xss.core.clean.XssCleaner; +import lombok.AllArgsConstructor; +import org.springframework.util.PathMatcher; +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; + +/** + * Xss 过滤器 + * + * @author 芋道源码 + */ +@AllArgsConstructor +public class XssFilter extends OncePerRequestFilter { + + /** + * 属性 + */ + private final XssProperties properties; + /** + * 路径匹配器 + */ + private final PathMatcher pathMatcher; + + private final XssCleaner xssCleaner; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + filterChain.doFilter(new XssRequestWrapper(request, xssCleaner), response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 如果关闭,则不过滤 + if (!properties.isEnable()) { + return true; + } + + // 如果匹配到无需过滤,则不过滤 + String uri = request.getRequestURI(); + return properties.getExcludeUrls().stream().anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, uri)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/filter/XssRequestWrapper.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/filter/XssRequestWrapper.java new file mode 100644 index 0000000..9cea0e3 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/filter/XssRequestWrapper.java @@ -0,0 +1,92 @@ +package cn.aagro.pp.framework.xss.core.filter; + +import cn.aagro.pp.framework.xss.core.clean.XssCleaner; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Xss 请求 Wrapper + * + * @author 芋道源码 + */ +public class XssRequestWrapper extends HttpServletRequestWrapper { + + private final XssCleaner xssCleaner; + + public XssRequestWrapper(HttpServletRequest request, XssCleaner xssCleaner) { + super(request); + this.xssCleaner = xssCleaner; + } + + // ============================ parameter ============================ + @Override + public Map getParameterMap() { + Map map = new LinkedHashMap<>(); + Map parameters = super.getParameterMap(); + for (Map.Entry entry : parameters.entrySet()) { + String[] values = entry.getValue(); + for (int i = 0; i < values.length; i++) { + values[i] = xssCleaner.clean(values[i]); + } + map.put(entry.getKey(), values); + } + return map; + } + + @Override + public String[] getParameterValues(String name) { + String[] values = super.getParameterValues(name); + if (values == null) { + return null; + } + int count = values.length; + String[] encodedValues = new String[count]; + for (int i = 0; i < count; i++) { + encodedValues[i] = xssCleaner.clean(values[i]); + } + return encodedValues; + } + + @Override + public String getParameter(String name) { + String value = super.getParameter(name); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + + // ============================ attribute ============================ + @Override + public Object getAttribute(String name) { + Object value = super.getAttribute(name); + if (value instanceof String) { + return xssCleaner.clean((String) value); + } + return value; + } + + // ============================ header ============================ + @Override + public String getHeader(String name) { + String value = super.getHeader(name); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + + // ============================ queryString ============================ + @Override + public String getQueryString() { + String value = super.getQueryString(); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/json/XssStringJsonDeserializer.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/json/XssStringJsonDeserializer.java new file mode 100644 index 0000000..31ef057 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/core/json/XssStringJsonDeserializer.java @@ -0,0 +1,82 @@ +package cn.aagro.pp.framework.xss.core.json; + +import cn.aagro.pp.framework.common.util.servlet.ServletUtils; +import cn.aagro.pp.framework.xss.config.XssProperties; +import cn.aagro.pp.framework.xss.core.clean.XssCleaner; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.PathMatcher; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +/** + * XSS 过滤 jackson 反序列化器。 + * 在反序列化的过程中,会对字符串进行 XSS 过滤。 + * + * @author Hccake + */ +@Slf4j +@AllArgsConstructor +public class XssStringJsonDeserializer extends StringDeserializer { + + /** + * 属性 + */ + private final XssProperties properties; + /** + * 路径匹配器 + */ + private final PathMatcher pathMatcher; + + private final XssCleaner xssCleaner; + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // 1. 白名单 URL 的处理 + HttpServletRequest request = ServletUtils.getRequest(); + if (request != null) { + String uri = ServletUtils.getRequest().getRequestURI(); + if (properties.getExcludeUrls().stream().anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, uri))) { + return p.getText(); + } + } + + // 2. 真正使用 xssCleaner 进行过滤 + if (p.hasToken(JsonToken.VALUE_STRING)) { + return xssCleaner.clean(p.getText()); + } + JsonToken t = p.currentToken(); + // [databind#381] + if (t == JsonToken.START_ARRAY) { + return _deserializeFromArray(p, ctxt); + } + // need to gracefully handle byte[] data, as base64 + if (t == JsonToken.VALUE_EMBEDDED_OBJECT) { + Object ob = p.getEmbeddedObject(); + if (ob == null) { + return null; + } + if (ob instanceof byte[]) { + return ctxt.getBase64Variant().encode((byte[]) ob, false); + } + // otherwise, try conversion using toString()... + return ob.toString(); + } + // 29-Jun-2020, tatu: New! "Scalar from Object" (mostly for XML) + if (t == JsonToken.START_OBJECT) { + return ctxt.extractScalarFromObject(p, this, _valueClass); + } + + if (t.isScalarValue()) { + String text = p.getValueAsString(); + return xssCleaner.clean(text); + } + return (String) ctxt.handleUnexpectedToken(_valueClass, p); + } +} + diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/package-info.java b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/package-info.java new file mode 100644 index 0000000..3c3b690 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/java/cn/aagro/pp/framework/xss/package-info.java @@ -0,0 +1,6 @@ +/** + * 针对 XSS 的基础封装 + * + * XSS 说明:https://tech.meituan.com/2018/09/27/fe-security.html + */ +package cn.aagro.pp.framework.xss; diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..c887512 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,7 @@ +cn.aagro.pp.framework.apilog.config.AagroApiLogAutoConfiguration +cn.aagro.pp.framework.jackson.config.AagroJacksonAutoConfiguration +cn.aagro.pp.framework.swagger.config.AagroSwaggerAutoConfiguration +cn.aagro.pp.framework.web.config.AagroWebAutoConfiguration +cn.aagro.pp.framework.xss.config.AagroXssAutoConfiguration +cn.aagro.pp.framework.banner.config.AagroBannerAutoConfiguration +cn.aagro.pp.framework.encrypt.config.AagroApiEncryptAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/main/resources/banner.txt b/aagro-framework/aagro-spring-boot-starter-web/src/main/resources/banner.txt new file mode 100644 index 0000000..2576940 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/main/resources/banner.txt @@ -0,0 +1,17 @@ +芋道源码 http://www.iocoder.cn +Application Version: ${aagro.info.version} +Spring Boot Version: ${spring-boot.version} + +.__ __. ______ .______ __ __ _______ +| \ | | / __ \ | _ \ | | | | / _____| +| \| | | | | | | |_) | | | | | | | __ +| . ` | | | | | | _ < | | | | | | |_ | +| |\ | | `--' | | |_) | | `--' | | |__| | +|__| \__| \______/ |______/ \______/ \______| + +███╗ ██╗ ██████╗ ██████╗ ██╗ ██╗ ██████╗ +████╗ ██║██╔═══██╗ ██╔══██╗██║ ██║██╔════╝ +██╔██╗ ██║██║ ██║ ██████╔╝██║ ██║██║ ███╗ +██║╚██╗██║██║ ██║ ██╔══██╗██║ ██║██║ ██║ +██║ ╚████║╚██████╔╝ ██████╔╝╚██████╔╝╚██████╔╝ +╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ diff --git a/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/DesensitizeTest.java b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/DesensitizeTest.java new file mode 100644 index 0000000..fbcb40d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/DesensitizeTest.java @@ -0,0 +1,94 @@ +package cn.aagro.pp.framework.desensitize.core; + +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.desensitize.core.annotation.Address; +import cn.aagro.pp.framework.desensitize.core.regex.annotation.EmailDesensitize; +import cn.aagro.pp.framework.desensitize.core.regex.annotation.RegexDesensitize; +import cn.aagro.pp.framework.desensitize.core.slider.annotation.*; +import lombok.Data; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * {@link DesensitizeTest} 的单元测试 + */ +@ExtendWith(MockitoExtension.class) +public class DesensitizeTest { + + @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/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/annotation/Address.java b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/annotation/Address.java new file mode 100644 index 0000000..1403ef7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/annotation/Address.java @@ -0,0 +1,30 @@ +package cn.aagro.pp.framework.desensitize.core.annotation; + +import cn.aagro.pp.framework.desensitize.core.DesensitizeTest; +import cn.aagro.pp.framework.desensitize.core.handler.AddressHandler; +import cn.aagro.pp.framework.desensitize.core.base.annotation.DesensitizeBy; +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/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/handler/AddressHandler.java b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/handler/AddressHandler.java new file mode 100644 index 0000000..0b23ea8 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/desensitize/core/handler/AddressHandler.java @@ -0,0 +1,19 @@ +package cn.aagro.pp.framework.desensitize.core.handler; + +import cn.aagro.pp.framework.desensitize.core.DesensitizeTest; +import cn.aagro.pp.framework.desensitize.core.base.handler.DesensitizationHandler; +import cn.aagro.pp.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/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/encrypt/ApiEncryptTest.java b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/encrypt/ApiEncryptTest.java new file mode 100644 index 0000000..fcd9000 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/src/test/java/cn/aagro/pp/framework/encrypt/ApiEncryptTest.java @@ -0,0 +1,86 @@ +package cn.aagro.pp.framework.encrypt; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.asymmetric.AsymmetricAlgorithm; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.asymmetric.RSA; +import cn.hutool.crypto.symmetric.SymmetricAlgorithm; +import org.junit.jupiter.api.Test; + +import java.util.Objects; + +/** + * 各种 API 加解密的测试类:不是单测,而是方便大家生成密钥、加密、解密等操作。 + * + * @author 芋道源码 + */ +@SuppressWarnings("ConstantValue") +public class ApiEncryptTest { + + @Test + public void testGenerateAsymmetric() { + String asymmetricAlgorithm = AsymmetricAlgorithm.RSA.getValue(); +// String asymmetricAlgorithm = "SM2"; +// String asymmetricAlgorithm = SM4.ALGORITHM_NAME; +// String asymmetricAlgorithm = SymmetricAlgorithm.AES.getValue(); + String requestClientKey = null; + String requestServerKey = null; + String responseClientKey = null; + String responseServerKey = null; + if (Objects.equals(asymmetricAlgorithm, AsymmetricAlgorithm.RSA.getValue())) { + // 请求的密钥 + RSA requestRsa = SecureUtil.rsa(); + requestClientKey = requestRsa.getPublicKeyBase64(); + requestServerKey = requestRsa.getPrivateKeyBase64(); + // 响应的密钥 + RSA responseRsa = new RSA(); + responseClientKey = responseRsa.getPrivateKeyBase64(); + responseServerKey = responseRsa.getPublicKeyBase64(); + } else if (Objects.equals(asymmetricAlgorithm, SymmetricAlgorithm.AES.getValue())) { + // AES 密钥可选 32、24、16 位 + // 请求的密钥(前后端密钥一致) + requestClientKey = RandomUtil.randomNumbers(32); + requestServerKey = requestClientKey; + // 响应的密钥(前后端密钥一致) + responseClientKey = RandomUtil.randomNumbers(32); + responseServerKey = responseClientKey; + } + + // 打印结果 + System.out.println("requestClientKey = " + requestClientKey); + System.out.println("requestServerKey = " + requestServerKey); + System.out.println("responseClientKey = " + responseClientKey); + System.out.println("responseServerKey = " + responseServerKey); + } + + @Test + public void testEncrypt_aes() { + String key = "52549111389893486934626385991395"; + String body = "{\n" + + " \"username\": \"admin\",\n" + + " \"password\": \"admin123\",\n" + + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" + + " \"code\": \"1024\"\n" + + "}"; + String encrypt = SecureUtil.aes(StrUtil.utf8Bytes(key)) + .encryptBase64(body); + System.out.println("encrypt = " + encrypt); + } + + @Test + public void testEncrypt_rsa() { + String key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB"; + String body = "{\n" + + " \"username\": \"admin\",\n" + + " \"password\": \"admin123\",\n" + + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" + + " \"code\": \"1024\"\n" + + "}"; + String encrypt = SecureUtil.rsa(null, key) + .encryptBase64(body, KeyType.PublicKey); + System.out.println("encrypt = " + encrypt); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-web/《芋道 Spring Boot API 接口文档 Swagger 入门》.md b/aagro-framework/aagro-spring-boot-starter-web/《芋道 Spring Boot API 接口文档 Swagger 入门》.md new file mode 100644 index 0000000..6fc2f4e --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/《芋道 Spring Boot API 接口文档 Swagger 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-web/《芋道 Spring Boot SpringMVC 入门》.md b/aagro-framework/aagro-spring-boot-starter-web/《芋道 Spring Boot SpringMVC 入门》.md new file mode 100644 index 0000000..fb9fabc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-web/《芋道 Spring Boot SpringMVC 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/pom.xml b/aagro-framework/aagro-spring-boot-starter-websocket/pom.xml new file mode 100644 index 0000000..df860f2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/pom.xml @@ -0,0 +1,73 @@ + + + + cn.aagro.gg + aagro-framework + ${revision} + + 4.0.0 + aagro-spring-boot-starter-websocket + jar + + ${project.artifactId} + WebSocket 框架,支持多节点的广播 + https://github.com/YunaiV/ruoyi-vue-pro + + + + + cn.aagro.gg + aagro-common + + + + + + cn.aagro.gg + aagro-spring-boot-starter-security + provided + + + + org.springframework.boot + spring-boot-starter-websocket + + + + + cn.aagro.gg + aagro-spring-boot-starter-mq + + + org.springframework.kafka + spring-kafka + true + + + org.springframework.amqp + spring-rabbit + true + + + org.apache.rocketmq + rocketmq-spring-boot-starter + true + + + + + + cn.aagro.gg + aagro-spring-boot-starter-biz-tenant + provided + + + + \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/config/AagroWebSocketAutoConfiguration.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/config/AagroWebSocketAutoConfiguration.java new file mode 100644 index 0000000..2ca41ae --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/config/AagroWebSocketAutoConfiguration.java @@ -0,0 +1,183 @@ +package cn.aagro.pp.framework.websocket.config; + +import cn.aagro.pp.framework.mq.redis.config.AagroRedisMQConsumerAutoConfiguration; +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.framework.websocket.core.handler.JsonWebSocketMessageHandler; +import cn.aagro.pp.framework.websocket.core.listener.WebSocketMessageListener; +import cn.aagro.pp.framework.websocket.core.security.LoginUserHandshakeInterceptor; +import cn.aagro.pp.framework.websocket.core.security.WebSocketAuthorizeRequestsCustomizer; +import cn.aagro.pp.framework.websocket.core.sender.kafka.KafkaWebSocketMessageConsumer; +import cn.aagro.pp.framework.websocket.core.sender.kafka.KafkaWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.local.LocalWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.rabbitmq.RabbitMQWebSocketMessageConsumer; +import cn.aagro.pp.framework.websocket.core.sender.rabbitmq.RabbitMQWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.redis.RedisWebSocketMessageConsumer; +import cn.aagro.pp.framework.websocket.core.sender.redis.RedisWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.rocketmq.RocketMQWebSocketMessageConsumer; +import cn.aagro.pp.framework.websocket.core.sender.rocketmq.RocketMQWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionHandlerDecorator; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManagerImpl; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +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.context.annotation.Configuration; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.List; + +/** + * WebSocket 自动配置 + * + * @author xingyu4j + */ +@AutoConfiguration(before = AagroRedisMQConsumerAutoConfiguration.class) // before AagroRedisMQConsumerAutoConfiguration 的原因是,需要保证 RedisWebSocketMessageConsumer 先创建,才能创建 RedisMessageListenerContainer +@EnableWebSocket // 开启 websocket +@ConditionalOnProperty(prefix = "aagro.websocket", value = "enable", matchIfMissing = true) // 允许使用 aagro.websocket.enable=false 禁用 websocket +@EnableConfigurationProperties(WebSocketProperties.class) +public class AagroWebSocketAutoConfiguration { + + @Bean + public WebSocketConfigurer webSocketConfigurer(HandshakeInterceptor[] handshakeInterceptors, + WebSocketHandler webSocketHandler, + WebSocketProperties webSocketProperties) { + return registry -> registry + // 添加 WebSocketHandler + .addHandler(webSocketHandler, webSocketProperties.getPath()) + .addInterceptors(handshakeInterceptors) + // 允许跨域,否则前端连接会直接断开 + .setAllowedOriginPatterns("*"); + } + + @Bean + public HandshakeInterceptor handshakeInterceptor() { + return new LoginUserHandshakeInterceptor(); + } + + @Bean + public WebSocketHandler webSocketHandler(WebSocketSessionManager sessionManager, + List> messageListeners) { + // 1. 创建 JsonWebSocketMessageHandler 对象,处理消息 + JsonWebSocketMessageHandler messageHandler = new JsonWebSocketMessageHandler(messageListeners); + // 2. 创建 WebSocketSessionHandlerDecorator 对象,处理连接 + return new WebSocketSessionHandlerDecorator(messageHandler, sessionManager); + } + + @Bean + public WebSocketSessionManager webSocketSessionManager() { + return new WebSocketSessionManagerImpl(); + } + + @Bean + public WebSocketAuthorizeRequestsCustomizer webSocketAuthorizeRequestsCustomizer(WebSocketProperties webSocketProperties) { + return new WebSocketAuthorizeRequestsCustomizer(webSocketProperties); + } + + // ==================== Sender 相关 ==================== + + @Configuration + @ConditionalOnProperty(prefix = "aagro.websocket", name = "sender-type", havingValue = "local") + public class LocalWebSocketMessageSenderConfiguration { + + @Bean + public LocalWebSocketMessageSender localWebSocketMessageSender(WebSocketSessionManager sessionManager) { + return new LocalWebSocketMessageSender(sessionManager); + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "aagro.websocket", name = "sender-type", havingValue = "redis") + public class RedisWebSocketMessageSenderConfiguration { + + @Bean + public RedisWebSocketMessageSender redisWebSocketMessageSender(WebSocketSessionManager sessionManager, + RedisMQTemplate redisMQTemplate) { + return new RedisWebSocketMessageSender(sessionManager, redisMQTemplate); + } + + @Bean + public RedisWebSocketMessageConsumer redisWebSocketMessageConsumer( + RedisWebSocketMessageSender redisWebSocketMessageSender) { + return new RedisWebSocketMessageConsumer(redisWebSocketMessageSender); + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "aagro.websocket", name = "sender-type", havingValue = "rocketmq") + public class RocketMQWebSocketMessageSenderConfiguration { + + @Bean + public RocketMQWebSocketMessageSender rocketMQWebSocketMessageSender( + WebSocketSessionManager sessionManager, RocketMQTemplate rocketMQTemplate, + @Value("${aagro.websocket.sender-rocketmq.topic}") String topic) { + return new RocketMQWebSocketMessageSender(sessionManager, rocketMQTemplate, topic); + } + + @Bean + public RocketMQWebSocketMessageConsumer rocketMQWebSocketMessageConsumer( + RocketMQWebSocketMessageSender rocketMQWebSocketMessageSender) { + return new RocketMQWebSocketMessageConsumer(rocketMQWebSocketMessageSender); + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "aagro.websocket", name = "sender-type", havingValue = "rabbitmq") + public class RabbitMQWebSocketMessageSenderConfiguration { + + @Bean + public RabbitMQWebSocketMessageSender rabbitMQWebSocketMessageSender( + WebSocketSessionManager sessionManager, RabbitTemplate rabbitTemplate, + TopicExchange websocketTopicExchange) { + return new RabbitMQWebSocketMessageSender(sessionManager, rabbitTemplate, websocketTopicExchange); + } + + @Bean + public RabbitMQWebSocketMessageConsumer rabbitMQWebSocketMessageConsumer( + RabbitMQWebSocketMessageSender rabbitMQWebSocketMessageSender) { + return new RabbitMQWebSocketMessageConsumer(rabbitMQWebSocketMessageSender); + } + + /** + * 创建 Topic Exchange + */ + @Bean + public TopicExchange websocketTopicExchange(@Value("${aagro.websocket.sender-rabbitmq.exchange}") String exchange) { + return new TopicExchange(exchange, + true, // durable: 是否持久化 + false); // exclusive: 是否排它 + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "aagro.websocket", name = "sender-type", havingValue = "kafka") + public class KafkaWebSocketMessageSenderConfiguration { + + @Bean + public KafkaWebSocketMessageSender kafkaWebSocketMessageSender( + WebSocketSessionManager sessionManager, KafkaTemplate kafkaTemplate, + @Value("${aagro.websocket.sender-kafka.topic}") String topic) { + return new KafkaWebSocketMessageSender(sessionManager, kafkaTemplate, topic); + } + + @Bean + public KafkaWebSocketMessageConsumer kafkaWebSocketMessageConsumer( + KafkaWebSocketMessageSender kafkaWebSocketMessageSender) { + return new KafkaWebSocketMessageConsumer(kafkaWebSocketMessageSender); + } + + } + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/config/WebSocketProperties.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/config/WebSocketProperties.java new file mode 100644 index 0000000..cebf1cc --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/config/WebSocketProperties.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.framework.websocket.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * WebSocket 配置项 + * + * @author xingyu4j + */ +@ConfigurationProperties("aagro.websocket") +@Data +@Validated +public class WebSocketProperties { + + /** + * WebSocket 的连接路径 + */ + @NotEmpty(message = "WebSocket 的连接路径不能为空") + private String path = "/ws"; + + /** + * 消息发送器的类型 + * + * 可选值:local、redis、rocketmq、kafka、rabbitmq + */ + @NotNull(message = "WebSocket 的消息发送者不能为空") + private String senderType = "local"; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/handler/JsonWebSocketMessageHandler.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/handler/JsonWebSocketMessageHandler.java new file mode 100644 index 0000000..b0fc1de --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/handler/JsonWebSocketMessageHandler.java @@ -0,0 +1,83 @@ +package cn.aagro.pp.framework.websocket.core.handler; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.TypeUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.tenant.core.util.TenantUtils; +import cn.aagro.pp.framework.websocket.core.listener.WebSocketMessageListener; +import cn.aagro.pp.framework.websocket.core.message.JsonWebSocketMessage; +import cn.aagro.pp.framework.websocket.core.util.WebSocketFrameworkUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * JSON 格式 {@link WebSocketHandler} 实现类 + * + * 基于 {@link JsonWebSocketMessage#getType()} 消息类型,调度到对应的 {@link WebSocketMessageListener} 监听器。 + * + * @author 芋道源码 + */ +@Slf4j +public class JsonWebSocketMessageHandler extends TextWebSocketHandler { + + /** + * type 与 WebSocketMessageListener 的映射 + */ + private final Map> listeners = new HashMap<>(); + + @SuppressWarnings({"rawtypes", "unchecked"}) + public JsonWebSocketMessageHandler(List listenersList) { + listenersList.forEach((Consumer) + listener -> listeners.put(listener.getType(), listener)); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + // 1.1 空消息,跳过 + if (message.getPayloadLength() == 0) { + return; + } + // 1.2 ping 心跳消息,直接返回 pong 消息。 + if (message.getPayloadLength() == 4 && Objects.equals(message.getPayload(), "ping")) { + session.sendMessage(new TextMessage("pong")); + return; + } + + // 2.1 解析消息 + try { + JsonWebSocketMessage jsonMessage = JsonUtils.parseObject(message.getPayload(), JsonWebSocketMessage.class); + if (jsonMessage == null) { + log.error("[handleTextMessage][session({}) message({}) 解析为空]", session.getId(), message.getPayload()); + return; + } + if (StrUtil.isEmpty(jsonMessage.getType())) { + log.error("[handleTextMessage][session({}) message({}) 类型为空]", session.getId(), message.getPayload()); + return; + } + // 2.2 获得对应的 WebSocketMessageListener + WebSocketMessageListener messageListener = listeners.get(jsonMessage.getType()); + if (messageListener == null) { + log.error("[handleTextMessage][session({}) message({}) 监听器为空]", session.getId(), message.getPayload()); + return; + } + // 2.3 处理消息 + Type type = TypeUtil.getTypeArgument(messageListener.getClass(), 0); + Object messageObj = JsonUtils.parseObject(jsonMessage.getContent(), type); + Long tenantId = WebSocketFrameworkUtils.getTenantId(session); + TenantUtils.execute(tenantId, () -> messageListener.onMessage(session, messageObj)); + } catch (Throwable ex) { + log.error("[handleTextMessage][session({}) message({}) 处理异常]", session.getId(), message.getPayload()); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/listener/WebSocketMessageListener.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/listener/WebSocketMessageListener.java new file mode 100644 index 0000000..10a3946 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/listener/WebSocketMessageListener.java @@ -0,0 +1,31 @@ +package cn.aagro.pp.framework.websocket.core.listener; + +import cn.aagro.pp.framework.websocket.core.message.JsonWebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * WebSocket 消息监听器接口 + * + * 目的:前端发送消息给后端后,处理对应 {@link #getType()} 类型的消息 + * + * @param 泛型,消息类型 + */ +public interface WebSocketMessageListener { + + /** + * 处理消息 + * + * @param session Session + * @param message 消息 + */ + void onMessage(WebSocketSession session, T message); + + /** + * 获得消息类型 + * + * @see JsonWebSocketMessage#getType() + * @return 消息类型 + */ + String getType(); + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/message/JsonWebSocketMessage.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/message/JsonWebSocketMessage.java new file mode 100644 index 0000000..98d942c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/message/JsonWebSocketMessage.java @@ -0,0 +1,29 @@ +package cn.aagro.pp.framework.websocket.core.message; + +import cn.aagro.pp.framework.websocket.core.listener.WebSocketMessageListener; +import lombok.Data; + +import java.io.Serializable; + +/** + * JSON 格式的 WebSocket 消息帧 + * + * @author 芋道源码 + */ +@Data +public class JsonWebSocketMessage implements Serializable { + + /** + * 消息类型 + * + * 目的:用于分发到对应的 {@link WebSocketMessageListener} 实现类 + */ + private String type; + /** + * 消息内容 + * + * 要求 JSON 对象 + */ + private String content; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/security/LoginUserHandshakeInterceptor.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/security/LoginUserHandshakeInterceptor.java new file mode 100644 index 0000000..93e76a9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/security/LoginUserHandshakeInterceptor.java @@ -0,0 +1,42 @@ +package cn.aagro.pp.framework.websocket.core.security; + +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.security.core.filter.TokenAuthenticationFilter; +import cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils; +import cn.aagro.pp.framework.websocket.core.util.WebSocketFrameworkUtils; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +/** + * 登录用户的 {@link HandshakeInterceptor} 实现类 + * + * 流程如下: + * 1. 前端连接 websocket 时,会通过拼接 ?token={token} 到 ws:// 连接后,这样它可以被 {@link TokenAuthenticationFilter} 所认证通过 + * 2. {@link LoginUserHandshakeInterceptor} 负责把 {@link LoginUser} 添加到 {@link WebSocketSession} 中 + * + * @author 芋道源码 + */ +public class LoginUserHandshakeInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser != null) { + WebSocketFrameworkUtils.setLoginUser(loginUser, attributes); + } + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // do nothing + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java new file mode 100644 index 0000000..14791d7 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java @@ -0,0 +1,25 @@ +package cn.aagro.pp.framework.websocket.core.security; + +import cn.aagro.pp.framework.security.config.AuthorizeRequestsCustomizer; +import cn.aagro.pp.framework.websocket.config.WebSocketProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer; + +/** + * WebSocket 的权限自定义 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class WebSocketAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer { + + private final WebSocketProperties webSocketProperties; + + @Override + public void customize(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) { + registry.requestMatchers(webSocketProperties.getPath()).permitAll(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/AbstractWebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/AbstractWebSocketMessageSender.java new file mode 100644 index 0000000..12fb9ab --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/AbstractWebSocketMessageSender.java @@ -0,0 +1,106 @@ +package cn.aagro.pp.framework.websocket.core.sender; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import cn.aagro.pp.framework.websocket.core.message.JsonWebSocketMessage; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * WebSocketMessageSender 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractWebSocketMessageSender implements WebSocketMessageSender { + + private final WebSocketSessionManager sessionManager; + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + send(null, userType, userId, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + send(null, userType, null, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + send(sessionId, null, null, messageType, messageContent); + } + + /** + * 发送消息 + * + * @param sessionId Session 编号 + * @param userType 用户类型 + * @param userId 用户编号 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + public void send(String sessionId, Integer userType, Long userId, String messageType, String messageContent) { + // 1. 获得 Session 列表 + List sessions = Collections.emptyList(); + if (StrUtil.isNotEmpty(sessionId)) { + WebSocketSession session = sessionManager.getSession(sessionId); + if (session != null) { + sessions = Collections.singletonList(session); + } + } else if (userType != null && userId != null) { + sessions = (List) sessionManager.getSessionList(userType, userId); + } else if (userType != null) { + sessions = (List) sessionManager.getSessionList(userType); + } + if (CollUtil.isEmpty(sessions)) { + if (log.isDebugEnabled()) { + log.debug("[send][sessionId({}) userType({}) userId({}) messageType({}) messageContent({}) 未匹配到会话]", + sessionId, userType, userId, messageType, messageContent); + } + } + // 2. 执行发送 + doSend(sessions, messageType, messageContent); + } + + /** + * 发送消息的具体实现 + * + * @param sessions Session 列表 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + public void doSend(Collection sessions, String messageType, String messageContent) { + JsonWebSocketMessage message = new JsonWebSocketMessage().setType(messageType).setContent(messageContent); + String payload = JsonUtils.toJsonString(message); // 关键,使用 JSON 序列化 + sessions.forEach(session -> { + // 1. 各种校验,保证 Session 可以被发送 + if (session == null) { + log.error("[doSend][session 为空, message({})]", message); + return; + } + if (!session.isOpen()) { + log.error("[doSend][session({}) 已关闭, message({})]", session.getId(), message); + return; + } + // 2. 执行发送 + try { + session.sendMessage(new TextMessage(payload)); + log.info("[doSend][session({}) 发送消息成功,message({})]", session.getId(), message); + } catch (IOException ex) { + log.error("[doSend][session({}) 发送消息失败,message({})]", session.getId(), message, ex); + } + }); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/WebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/WebSocketMessageSender.java new file mode 100644 index 0000000..234fff2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/WebSocketMessageSender.java @@ -0,0 +1,52 @@ +package cn.aagro.pp.framework.websocket.core.sender; + +import cn.aagro.pp.framework.common.util.json.JsonUtils; + +/** + * WebSocket 消息的发送器接口 + * + * @author 芋道源码 + */ +public interface WebSocketMessageSender { + + /** + * 发送消息给指定用户 + * + * @param userType 用户类型 + * @param userId 用户编号 + * @param messageType 消息类型 + * @param messageContent 消息内容,JSON 格式 + */ + void send(Integer userType, Long userId, String messageType, String messageContent); + + /** + * 发送消息给指定用户类型 + * + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容,JSON 格式 + */ + void send(Integer userType, String messageType, String messageContent); + + /** + * 发送消息给指定 Session + * + * @param sessionId Session 编号 + * @param messageType 消息类型 + * @param messageContent 消息内容,JSON 格式 + */ + void send(String sessionId, String messageType, String messageContent); + + default void sendObject(Integer userType, Long userId, String messageType, Object messageContent) { + send(userType, userId, messageType, JsonUtils.toJsonString(messageContent)); + } + + default void sendObject(Integer userType, String messageType, Object messageContent) { + send(userType, messageType, JsonUtils.toJsonString(messageContent)); + } + + default void sendObject(String sessionId, String messageType, Object messageContent) { + send(sessionId, messageType, JsonUtils.toJsonString(messageContent)); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java new file mode 100644 index 0000000..094248d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.websocket.core.sender.kafka; + +import lombok.Data; + +/** + * Kafka 广播 WebSocket 的消息 + * + * @author 芋道源码 + */ +@Data +public class KafkaWebSocketMessage { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java new file mode 100644 index 0000000..26db053 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.framework.websocket.core.sender.kafka; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.kafka.annotation.KafkaListener; + +/** + * {@link KafkaWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class KafkaWebSocketMessageConsumer { + + private final KafkaWebSocketMessageSender kafkaWebSocketMessageSender; + + @RabbitHandler + @KafkaListener( + topics = "${aagro.websocket.sender-kafka.topic}", + // 在 Group 上,使用 UUID 生成其后缀。这样,启动的 Consumer 的 Group 不同,以达到广播消费的目的 + groupId = "${aagro.websocket.sender-kafka.consumer-group}" + "-" + "#{T(java.util.UUID).randomUUID()}") + public void onMessage(KafkaWebSocketMessage message) { + kafkaWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java new file mode 100644 index 0000000..b9ab33f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java @@ -0,0 +1,67 @@ +package cn.aagro.pp.framework.websocket.core.sender.kafka; + +import cn.aagro.pp.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.WebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; + +import java.util.concurrent.ExecutionException; + +/** + * 基于 Kafka 的 {@link WebSocketMessageSender} 实现类 + * + * @author 芋道源码 + */ +@Slf4j +public class KafkaWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final KafkaTemplate kafkaTemplate; + + private final String topic; + + public KafkaWebSocketMessageSender(WebSocketSessionManager sessionManager, + KafkaTemplate kafkaTemplate, + String topic) { + super(sessionManager); + this.kafkaTemplate = kafkaTemplate; + this.topic = topic; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendKafkaMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendKafkaMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendKafkaMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 Kafka 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendKafkaMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + KafkaWebSocketMessage mqMessage = new KafkaWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + try { + kafkaTemplate.send(topic, mqMessage).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("[sendKafkaMessage][发送消息({}) 到 Kafka 失败]", mqMessage, e); + } + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java new file mode 100644 index 0000000..a91e3fa --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java @@ -0,0 +1,20 @@ +package cn.aagro.pp.framework.websocket.core.sender.local; + +import cn.aagro.pp.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.WebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; + +/** + * 本地的 {@link WebSocketMessageSender} 实现类 + * + * 注意:仅仅适合单机场景!!! + * + * @author 芋道源码 + */ +public class LocalWebSocketMessageSender extends AbstractWebSocketMessageSender { + + public LocalWebSocketMessageSender(WebSocketSessionManager sessionManager) { + super(sessionManager); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java new file mode 100644 index 0000000..2cbf34f --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.framework.websocket.core.sender.rabbitmq; + +import lombok.Data; + +import java.io.Serializable; + +/** + * RabbitMQ 广播 WebSocket 的消息 + * + * @author 芋道源码 + */ +@Data +public class RabbitMQWebSocketMessage implements Serializable { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java new file mode 100644 index 0000000..6f18b4b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.framework.websocket.core.sender.rabbitmq; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.ExchangeTypes; +import org.springframework.amqp.rabbit.annotation.*; + +/** + * {@link RabbitMQWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author 芋道源码 + */ +@RabbitListener( + bindings = @QueueBinding( + value = @Queue( + // 在 Queue 的名字上,使用 UUID 生成其后缀。这样,启动的 Consumer 的 Queue 不同,以达到广播消费的目的 + name = "${aagro.websocket.sender-rabbitmq.queue}" + "-" + "#{T(java.util.UUID).randomUUID()}", + // Consumer 关闭时,该队列就可以被自动删除了 + autoDelete = "true" + ), + exchange = @Exchange( + name = "${aagro.websocket.sender-rabbitmq.exchange}", + type = ExchangeTypes.TOPIC, + declare = "false" + ) + ) +) +@RequiredArgsConstructor +public class RabbitMQWebSocketMessageConsumer { + + private final RabbitMQWebSocketMessageSender rabbitMQWebSocketMessageSender; + + @RabbitHandler + public void onMessage(RabbitMQWebSocketMessage message) { + rabbitMQWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java new file mode 100644 index 0000000..39bf0fa --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java @@ -0,0 +1,62 @@ +package cn.aagro.pp.framework.websocket.core.sender.rabbitmq; + +import cn.aagro.pp.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.WebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.core.RabbitTemplate; + +/** + * 基于 RabbitMQ 的 {@link WebSocketMessageSender} 实现类 + * + * @author 芋道源码 + */ +@Slf4j +public class RabbitMQWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final RabbitTemplate rabbitTemplate; + + private final TopicExchange topicExchange; + + public RabbitMQWebSocketMessageSender(WebSocketSessionManager sessionManager, + RabbitTemplate rabbitTemplate, + TopicExchange topicExchange) { + super(sessionManager); + this.rabbitTemplate = rabbitTemplate; + this.topicExchange = topicExchange; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendRabbitMQMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendRabbitMQMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendRabbitMQMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 RabbitMQ 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendRabbitMQMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + RabbitMQWebSocketMessage mqMessage = new RabbitMQWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + rabbitTemplate.convertAndSend(topicExchange.getName(), null, mqMessage); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessage.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessage.java new file mode 100644 index 0000000..a1b51e6 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessage.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.framework.websocket.core.sender.redis; + +import cn.aagro.pp.framework.mq.redis.core.pubsub.AbstractRedisChannelMessage; +import lombok.Data; + +/** + * Redis 广播 WebSocket 的消息 + */ +@Data +public class RedisWebSocketMessage extends AbstractRedisChannelMessage { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java new file mode 100644 index 0000000..5468071 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java @@ -0,0 +1,23 @@ +package cn.aagro.pp.framework.websocket.core.sender.redis; + +import cn.aagro.pp.framework.mq.redis.core.pubsub.AbstractRedisChannelMessageListener; +import lombok.RequiredArgsConstructor; + +/** + * {@link RedisWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class RedisWebSocketMessageConsumer extends AbstractRedisChannelMessageListener { + + private final RedisWebSocketMessageSender redisWebSocketMessageSender; + + @Override + public void onMessage(RedisWebSocketMessage message) { + redisWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java new file mode 100644 index 0000000..0d251a9 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.framework.websocket.core.sender.redis; + +import cn.aagro.pp.framework.mq.redis.core.RedisMQTemplate; +import cn.aagro.pp.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.WebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; + +/** + * 基于 Redis 的 {@link WebSocketMessageSender} 实现类 + * + * @author 芋道源码 + */ +@Slf4j +public class RedisWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final RedisMQTemplate redisMQTemplate; + + public RedisWebSocketMessageSender(WebSocketSessionManager sessionManager, + RedisMQTemplate redisMQTemplate) { + super(sessionManager); + this.redisMQTemplate = redisMQTemplate; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendRedisMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendRedisMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendRedisMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 Redis 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendRedisMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + RedisWebSocketMessage mqMessage = new RedisWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + redisMQTemplate.send(mqMessage); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java new file mode 100644 index 0000000..e4ae28b --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.framework.websocket.core.sender.rocketmq; + +import lombok.Data; + +/** + * RocketMQ 广播 WebSocket 的消息 + * + * @author 芋道源码 + */ +@Data +public class RocketMQWebSocketMessage { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java new file mode 100644 index 0000000..ac6965d --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java @@ -0,0 +1,30 @@ +package cn.aagro.pp.framework.websocket.core.sender.rocketmq; + +import lombok.RequiredArgsConstructor; +import org.apache.rocketmq.spring.annotation.MessageModel; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; + +/** + * {@link RocketMQWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author 芋道源码 + */ +@RocketMQMessageListener( // 重点:添加 @RocketMQMessageListener 注解,声明消费的 topic + topic = "${aagro.websocket.sender-rocketmq.topic}", + consumerGroup = "${aagro.websocket.sender-rocketmq.consumer-group}", + messageModel = MessageModel.BROADCASTING // 设置为广播模式,保证每个实例都能收到消息 +) +@RequiredArgsConstructor +public class RocketMQWebSocketMessageConsumer implements RocketMQListener { + + private final RocketMQWebSocketMessageSender rocketMQWebSocketMessageSender; + + @Override + public void onMessage(RocketMQWebSocketMessage message) { + rocketMQWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java new file mode 100644 index 0000000..2fab02c --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java @@ -0,0 +1,61 @@ +package cn.aagro.pp.framework.websocket.core.sender.rocketmq; + +import cn.aagro.pp.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.sender.WebSocketMessageSender; +import cn.aagro.pp.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.core.RocketMQTemplate; + +/** + * 基于 RocketMQ 的 {@link WebSocketMessageSender} 实现类 + * + * @author 芋道源码 + */ +@Slf4j +public class RocketMQWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final RocketMQTemplate rocketMQTemplate; + + private final String topic; + + public RocketMQWebSocketMessageSender(WebSocketSessionManager sessionManager, + RocketMQTemplate rocketMQTemplate, + String topic) { + super(sessionManager); + this.rocketMQTemplate = rocketMQTemplate; + this.topic = topic; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendRocketMQMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendRocketMQMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendRocketMQMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 RocketMQ 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendRocketMQMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + RocketMQWebSocketMessage mqMessage = new RocketMQWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + rocketMQTemplate.syncSend(topic, mqMessage); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java new file mode 100644 index 0000000..99bf708 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java @@ -0,0 +1,49 @@ +package cn.aagro.pp.framework.websocket.core.session; + +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; +import org.springframework.web.socket.handler.WebSocketHandlerDecorator; + +/** + * {@link WebSocketHandler} 的装饰类,实现了以下功能: + * + * 1. {@link WebSocketSession} 连接或关闭时,使用 {@link #sessionManager} 进行管理 + * 2. 封装 {@link WebSocketSession} 支持并发操作 + * + * @author 芋道源码 + */ +public class WebSocketSessionHandlerDecorator extends WebSocketHandlerDecorator { + + /** + * 发送时间的限制,单位:毫秒 + */ + private static final Integer SEND_TIME_LIMIT = 1000 * 5; + /** + * 发送消息缓冲上线,单位:bytes + */ + private static final Integer BUFFER_SIZE_LIMIT = 1024 * 100; + + private final WebSocketSessionManager sessionManager; + + public WebSocketSessionHandlerDecorator(WebSocketHandler delegate, + WebSocketSessionManager sessionManager) { + super(delegate); + this.sessionManager = sessionManager; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + // 实现 session 支持并发,可参考 https://blog.csdn.net/abu935009066/article/details/131218149 + session = new ConcurrentWebSocketSessionDecorator(session, SEND_TIME_LIMIT, BUFFER_SIZE_LIMIT); + // 添加到 WebSocketSessionManager 中 + sessionManager.addSession(session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) { + sessionManager.removeSession(session); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionManager.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionManager.java new file mode 100644 index 0000000..8b3b7dd --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionManager.java @@ -0,0 +1,53 @@ +package cn.aagro.pp.framework.websocket.core.session; + +import org.springframework.web.socket.WebSocketSession; + +import java.util.Collection; + +/** + * {@link WebSocketSession} 管理器的接口 + * + * @author 芋道源码 + */ +public interface WebSocketSessionManager { + + /** + * 添加 Session + * + * @param session Session + */ + void addSession(WebSocketSession session); + + /** + * 移除 Session + * + * @param session Session + */ + void removeSession(WebSocketSession session); + + /** + * 获得指定编号的 Session + * + * @param id Session 编号 + * @return Session + */ + WebSocketSession getSession(String id); + + /** + * 获得指定用户类型的 Session 列表 + * + * @param userType 用户类型 + * @return Session 列表 + */ + Collection getSessionList(Integer userType); + + /** + * 获得指定用户编号的 Session 列表 + * + * @param userType 用户类型 + * @param userId 用户编号 + * @return Session 列表 + */ + Collection getSessionList(Integer userType, Long userId); + +} \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionManagerImpl.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionManagerImpl.java new file mode 100644 index 0000000..5ceb8ab --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/session/WebSocketSessionManagerImpl.java @@ -0,0 +1,125 @@ +package cn.aagro.pp.framework.websocket.core.session; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.security.core.LoginUser; +import cn.aagro.pp.framework.tenant.core.context.TenantContextHolder; +import cn.aagro.pp.framework.websocket.core.util.WebSocketFrameworkUtils; +import org.springframework.web.socket.WebSocketSession; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 默认的 {@link WebSocketSessionManager} 实现类 + * + * @author 芋道源码 + */ +public class WebSocketSessionManagerImpl implements WebSocketSessionManager { + + /** + * id 与 WebSocketSession 映射 + * + * key:Session 编号 + */ + private final ConcurrentMap idSessions = new ConcurrentHashMap<>(); + + /** + * user 与 WebSocketSession 映射 + * + * key1:用户类型 + * key2:用户编号 + */ + private final ConcurrentMap>> userSessions + = new ConcurrentHashMap<>(); + + @Override + public void addSession(WebSocketSession session) { + // 添加到 idSessions 中 + idSessions.put(session.getId(), session); + // 添加到 userSessions 中 + LoginUser user = WebSocketFrameworkUtils.getLoginUser(session); + if (user == null) { + return; + } + ConcurrentMap> userSessionsMap = userSessions.get(user.getUserType()); + if (userSessionsMap == null) { + userSessionsMap = new ConcurrentHashMap<>(); + if (userSessions.putIfAbsent(user.getUserType(), userSessionsMap) != null) { + userSessionsMap = userSessions.get(user.getUserType()); + } + } + CopyOnWriteArrayList sessions = userSessionsMap.get(user.getId()); + if (sessions == null) { + sessions = new CopyOnWriteArrayList<>(); + if (userSessionsMap.putIfAbsent(user.getId(), sessions) != null) { + sessions = userSessionsMap.get(user.getId()); + } + } + sessions.add(session); + } + + @Override + public void removeSession(WebSocketSession session) { + // 移除从 idSessions 中 + idSessions.remove(session.getId()); + // 移除从 idSessions 中 + LoginUser user = WebSocketFrameworkUtils.getLoginUser(session); + if (user == null) { + return; + } + ConcurrentMap> userSessionsMap = userSessions.get(user.getUserType()); + if (userSessionsMap == null) { + return; + } + CopyOnWriteArrayList sessions = userSessionsMap.get(user.getId()); + sessions.removeIf(session0 -> session0.getId().equals(session.getId())); + if (CollUtil.isEmpty(sessions)) { + userSessionsMap.remove(user.getId(), sessions); + } + } + + @Override + public WebSocketSession getSession(String id) { + return idSessions.get(id); + } + + @Override + public Collection getSessionList(Integer userType) { + ConcurrentMap> userSessionsMap = userSessions.get(userType); + if (CollUtil.isEmpty(userSessionsMap)) { + return new ArrayList<>(); + } + LinkedList result = new LinkedList<>(); // 避免扩容 + Long contextTenantId = TenantContextHolder.getTenantId(); + for (List sessions : userSessionsMap.values()) { + if (CollUtil.isEmpty(sessions)) { + continue; + } + // 特殊:如果租户不匹配,则直接排除 + if (contextTenantId != null) { + Long userTenantId = WebSocketFrameworkUtils.getTenantId(sessions.get(0)); + if (!contextTenantId.equals(userTenantId)) { + continue; + } + } + result.addAll(sessions); + } + return result; + } + + @Override + public Collection getSessionList(Integer userType, Long userId) { + ConcurrentMap> userSessionsMap = userSessions.get(userType); + if (CollUtil.isEmpty(userSessionsMap)) { + return new ArrayList<>(); + } + CopyOnWriteArrayList sessions = userSessionsMap.get(userId); + return CollUtil.isNotEmpty(sessions) ? new ArrayList<>(sessions) : new ArrayList<>(); + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/util/WebSocketFrameworkUtils.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/util/WebSocketFrameworkUtils.java new file mode 100644 index 0000000..ad43dfb --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/core/util/WebSocketFrameworkUtils.java @@ -0,0 +1,67 @@ +package cn.aagro.pp.framework.websocket.core.util; + +import cn.aagro.pp.framework.security.core.LoginUser; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; + +/** + * 专属于 web 包的工具类 + * + * @author 芋道源码 + */ +public class WebSocketFrameworkUtils { + + public static final String ATTRIBUTE_LOGIN_USER = "LOGIN_USER"; + + /** + * 设置当前用户 + * + * @param loginUser 登录用户 + * @param attributes Session + */ + public static void setLoginUser(LoginUser loginUser, Map attributes) { + attributes.put(ATTRIBUTE_LOGIN_USER, loginUser); + } + + /** + * 获取当前用户 + * + * @return 当前用户 + */ + public static LoginUser getLoginUser(WebSocketSession session) { + return (LoginUser) session.getAttributes().get(ATTRIBUTE_LOGIN_USER); + } + + /** + * 获得当前用户的编号 + * + * @return 用户编号 + */ + public static Long getLoginUserId(WebSocketSession session) { + LoginUser loginUser = getLoginUser(session); + return loginUser != null ? loginUser.getId() : null; + } + + /** + * 获得当前用户的类型 + * + * @return 用户编号 + */ + public static Integer getLoginUserType(WebSocketSession session) { + LoginUser loginUser = getLoginUser(session); + return loginUser != null ? loginUser.getUserType() : null; + } + + /** + * 获得当前用户的租户编号 + * + * @param session Session + * @return 租户编号 + */ + public static Long getTenantId(WebSocketSession session) { + LoginUser loginUser = getLoginUser(session); + return loginUser != null ? loginUser.getTenantId() : null; + } + +} diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/package-info.java b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/package-info.java new file mode 100644 index 0000000..05978b2 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/java/cn/aagro/pp/framework/websocket/package-info.java @@ -0,0 +1,4 @@ +/** + * WebSocket 框架,支持多节点的广播 + */ +package cn.aagro.pp.framework.websocket; diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..11c8d31 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.aagro.pp.framework.websocket.config.AagroWebSocketAutoConfiguration \ No newline at end of file diff --git a/aagro-framework/aagro-spring-boot-starter-websocket/《芋道 Spring Boot WebSocket 入门》.md b/aagro-framework/aagro-spring-boot-starter-websocket/《芋道 Spring Boot WebSocket 入门》.md new file mode 100644 index 0000000..c753981 --- /dev/null +++ b/aagro-framework/aagro-spring-boot-starter-websocket/《芋道 Spring Boot WebSocket 入门》.md @@ -0,0 +1 @@ + diff --git a/aagro-framework/pom.xml b/aagro-framework/pom.xml new file mode 100644 index 0000000..ec97699 --- /dev/null +++ b/aagro-framework/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + aiot + cn.aagro.gg + ${revision} + + pom + + aagro-common + aagro-spring-boot-starter-mybatis + aagro-spring-boot-starter-redis + aagro-spring-boot-starter-web + aagro-spring-boot-starter-security + aagro-spring-boot-starter-websocket + + aagro-spring-boot-starter-monitor + aagro-spring-boot-starter-protection + aagro-spring-boot-starter-job + aagro-spring-boot-starter-mq + + aagro-spring-boot-starter-excel + aagro-spring-boot-starter-test + + aagro-spring-boot-starter-biz-tenant + aagro-spring-boot-starter-biz-data-permission + aagro-spring-boot-starter-biz-ip + + + aagro-framework + + 该包是技术组件,每个子包,代表一个组件。每个组件包括两部分: + 1. core 包:是该组件的核心封装 + 2. config 包:是该组件基于 Spring 的配置 + + 技术组件,也分成两类: + 1. 框架组件:和我们熟悉的 MyBatis、Redis 等等的拓展 + 2. 业务组件:和业务相关的组件的封装,例如说数据字典、操作日志等等。 + 如果是业务组件,Maven 名字会包含 biz + + https://github.com/YunaiV/ruoyi-vue-pro + + diff --git a/aagro-module-ai/pom.xml b/aagro-module-ai/pom.xml new file mode 100644 index 0000000..fc29e0d --- /dev/null +++ b/aagro-module-ai/pom.xml @@ -0,0 +1,251 @@ + + + + cn.aagro.gg + aagro + ${revision} + + 4.0.0 + jar + aagro-module-ai + + ${project.artifactId} + + ai 模块下,接入 LLM 大模型,支持聊天、绘图、音乐、写作、思维导图等功能。 + 目前已接入各种模型,不限于: + 国内:通义千问、文心一言、讯飞星火、智谱 GLM、DeepSeek + 国外:OpenAI、Ollama、Midjourney、StableDiffusion、Suno + + + 1.0.1 + 1.0.0.3 + 1.0.2 + + + + + cn.aagro.gg + aagro-module-system + ${revision} + + + cn.aagro.gg + aagro-module-infra + ${revision} + + + + + + cn.aagro.gg + aagro-spring-boot-starter-biz-tenant + + + + + cn.aagro.gg + aagro-spring-boot-starter-security + + + + + cn.aagro.gg + aagro-spring-boot-starter-mybatis + + + + + cn.aagro.gg + aagro-spring-boot-starter-job + + + + + cn.aagro.gg + aagro-spring-boot-starter-test + + + + + cn.aagro.gg + aagro-spring-boot-starter-excel + + + + + org.springframework.ai + spring-ai-starter-model-openai + ${spring-ai.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + + + + + org.springframework.ai + spring-ai-starter-model-azure-openai + ${spring-ai.version} + + + org.springframework.ai + spring-ai-starter-model-anthropic + ${spring-ai.version} + + + org.springframework.ai + spring-ai-starter-model-deepseek + ${spring-ai.version} + + + org.springframework.ai + spring-ai-starter-model-ollama + ${spring-ai.version} + + + org.springframework.ai + spring-ai-starter-model-stability-ai + ${spring-ai.version} + + + + org.springframework.ai + spring-ai-starter-model-zhipuai + ${spring-ai.version} + + + org.springframework.ai + spring-ai-starter-model-minimax + ${spring-ai.version} + + + + + com.alibaba.cloud.ai + spring-ai-alibaba-starter-dashscope + ${alibaba-ai.version} + + + + + org.springaicommunity + qianfan-spring-boot-starter + 1.0.0 + + + + org.springaicommunity + moonshot-spring-boot-starter + 1.0.0 + + + + + + org.springframework.ai + spring-ai-starter-vector-store-qdrant + ${spring-ai.version} + + + + + org.springframework.ai + spring-ai-starter-vector-store-redis + ${spring-ai.version} + + + cn.aagro.gg + aagro-spring-boot-starter-redis + + + + + org.springframework.ai + spring-ai-starter-vector-store-milvus + ${spring-ai.version} + + + + org.slf4j + slf4j-reload4j + + + + + + + org.springframework.ai + spring-ai-tika-document-reader + ${spring-ai.version} + + + + spring-cloud-function-context + org.springframework.cloud + + + spring-cloud-function-core + org.springframework.cloud + + + + + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + ${spring-ai.version} + + + + org.springframework.ai + spring-ai-starter-mcp-client + ${spring-ai.version} + + + + + dev.tinyflow + tinyflow-java-core + ${tinyflow.version} + + + com.jfinal + enjoy + + + + com.agentsflex + agents-flex-store-elasticsearch + + + + org.codehaus.groovy + groovy-all + + + + org.slf4j + slf4j-simple + + + org.apache.logging.log4j + log4j-slf4j-impl + + + org.slf4j + slf4j-reload4j + + + + + + \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatConversationController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatConversationController.java new file mode 100644 index 0000000..a1a8c35 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatConversationController.java @@ -0,0 +1,118 @@ +package cn.aagro.pp.module.ai.controller.admin.chat; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation.AiChatConversationCreateMyReqVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation.AiChatConversationPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation.AiChatConversationRespVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation.AiChatConversationUpdateMyReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.chat.AiChatConversationDO; +import cn.aagro.pp.module.ai.service.chat.AiChatConversationService; +import cn.aagro.pp.module.ai.service.chat.AiChatMessageService; +import com.fhs.core.trans.anno.TransMethodResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI 聊天对话") +@RestController +@RequestMapping("/ai/chat/conversation") +@Validated +public class AiChatConversationController { + + @Resource + private AiChatConversationService chatConversationService; + @Resource + private AiChatMessageService chatMessageService; + + @PostMapping("/create-my") + @Operation(summary = "创建【我的】聊天对话") + public CommonResult createChatConversationMy(@RequestBody @Valid AiChatConversationCreateMyReqVO createReqVO) { + return success(chatConversationService.createChatConversationMy(createReqVO, getLoginUserId())); + } + + @PutMapping("/update-my") + @Operation(summary = "更新【我的】聊天对话") + public CommonResult updateChatConversationMy(@RequestBody @Valid AiChatConversationUpdateMyReqVO updateReqVO) { + chatConversationService.updateChatConversationMy(updateReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/my-list") + @Operation(summary = "获得【我的】聊天对话列表") + @TransMethodResult + public CommonResult> getChatConversationMyList() { + List list = chatConversationService.getChatConversationListByUserId(getLoginUserId()); + return success(BeanUtils.toBean(list, AiChatConversationRespVO.class)); + } + + @GetMapping("/get-my") + @Operation(summary = "获得【我的】聊天对话") + @Parameter(name = "id", required = true, description = "对话编号", example = "1024") + @TransMethodResult + public CommonResult getChatConversationMy(@RequestParam("id") Long id) { + AiChatConversationDO conversation = chatConversationService.getChatConversation(id); + if (conversation != null && ObjUtil.notEqual(conversation.getUserId(), getLoginUserId())) { + conversation = null; + } + return success(BeanUtils.toBean(conversation, AiChatConversationRespVO.class)); + } + + @DeleteMapping("/delete-my") + @Operation(summary = "删除聊天对话") + @Parameter(name = "id", required = true, description = "对话编号", example = "1024") + public CommonResult deleteChatConversationMy(@RequestParam("id") Long id) { + chatConversationService.deleteChatConversationMy(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete-by-unpinned") + @Operation(summary = "删除未置顶的聊天对话") + public CommonResult deleteChatConversationMyByUnpinned() { + chatConversationService.deleteChatConversationMyByUnpinned(getLoginUserId()); + return success(true); + } + + // ========== 对话管理 ========== + + @GetMapping("/page") + @Operation(summary = "获得对话分页", description = "用于【对话管理】菜单") + @PreAuthorize("@ss.hasPermission('ai:chat-conversation:query')") + @TransMethodResult + public CommonResult> getChatConversationPage(AiChatConversationPageReqVO pageReqVO) { + PageResult pageResult = chatConversationService.getChatConversationPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty()); + } + // 拼接关联数据 + Map messageCountMap = chatMessageService.getChatMessageCountMap( + convertList(pageResult.getList(), AiChatConversationDO::getId)); + return success(BeanUtils.toBean(pageResult, AiChatConversationRespVO.class, + conversation -> conversation.setMessageCount(messageCountMap.getOrDefault(conversation.getId(), 0)))); + } + + @Operation(summary = "管理员删除对话") + @DeleteMapping("/delete-by-admin") + @Parameter(name = "id", required = true, description = "对话编号", example = "1024") + @PreAuthorize("@ss.hasPermission('ai:chat-conversation:delete')") + public CommonResult deleteChatConversationByAdmin(@RequestParam("id") Long id) { + chatConversationService.deleteChatConversationByAdmin(id); + return success(true); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatMessageController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatMessageController.http new file mode 100644 index 0000000..41bef35 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatMessageController.http @@ -0,0 +1,66 @@ +### 发送消息(段式) +POST {{baseUrl}}/ai/chat/message/send +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581724", + "content": "你是 OpenAI 么?" +} + +### 发送消息(流式) +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581724", + "content": "1+1=?" +} + +### 发送消息(流式)【带文件】 +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581797", + "content": "图片里有什么?", + "attachmentUrls": ["http://test.aagro.iocoder.cn/1755531278.jpeg"] +} + +### 发送消息(流式)【追问带文件】 +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581799", + "content": "说下图片里,有哪些字?", + "useContext": true +} + +### 发送消息(流式)【联网搜索】 +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581799", + "content": "今天是周几?", + "useSearch": true +} + +### 获得指定对话的消息列表 +GET {{baseUrl}}/ai/chat/message/list-by-conversation-id?conversationId=1781604279872581799 +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +### 删除消息 +DELETE {{baseUrl}}/ai/chat/message/delete?id=50 +Authorization: {{token}} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatMessageController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatMessageController.java new file mode 100644 index 0000000..23eec3e --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/AiChatMessageController.java @@ -0,0 +1,157 @@ +package cn.aagro.pp.module.ai.controller.admin.chat; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.collection.MapUtils; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.message.AiChatMessagePageReqVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.message.AiChatMessageRespVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.message.AiChatMessageSendReqVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.message.AiChatMessageSendRespVO; +import cn.aagro.pp.module.ai.dal.dataobject.chat.AiChatConversationDO; +import cn.aagro.pp.module.ai.dal.dataobject.chat.AiChatMessageDO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiChatRoleDO; +import cn.aagro.pp.module.ai.service.chat.AiChatConversationService; +import cn.aagro.pp.module.ai.service.chat.AiChatMessageService; +import cn.aagro.pp.module.ai.service.knowledge.AiKnowledgeDocumentService; +import cn.aagro.pp.module.ai.service.knowledge.AiKnowledgeSegmentService; +import cn.aagro.pp.module.ai.service.model.AiChatRoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.*; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 聊天消息") +@RestController +@RequestMapping("/ai/chat/message") +@Slf4j +public class AiChatMessageController { + + @Resource + private AiChatMessageService chatMessageService; + @Resource + private AiChatConversationService chatConversationService; + @Resource + private AiChatRoleService chatRoleService; + @Resource + private AiKnowledgeSegmentService knowledgeSegmentService; + @Resource + private AiKnowledgeDocumentService knowledgeDocumentService; + + @Operation(summary = "发送消息(段式)", description = "一次性返回,响应较慢") + @PostMapping("/send") + public CommonResult sendMessage(@Valid @RequestBody AiChatMessageSendReqVO sendReqVO) { + return success(chatMessageService.sendMessage(sendReqVO, getLoginUserId())); + } + + @Operation(summary = "发送消息(流式)", description = "流式返回,响应较快") + @PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> sendChatMessageStream(@Valid @RequestBody AiChatMessageSendReqVO sendReqVO) { + return chatMessageService.sendChatMessageStream(sendReqVO, getLoginUserId()); + } + + @Operation(summary = "获得指定对话的消息列表") + @GetMapping("/list-by-conversation-id") + @Parameter(name = "conversationId", required = true, description = "对话编号", example = "1024") + public CommonResult> getChatMessageListByConversationId( + @RequestParam("conversationId") Long conversationId) { + AiChatConversationDO conversation = chatConversationService.getChatConversation(conversationId); + if (conversation == null || ObjUtil.notEqual(conversation.getUserId(), getLoginUserId())) { + return success(Collections.emptyList()); + } + // 1. 获取消息列表 + List messageList = chatMessageService.getChatMessageListByConversationId(conversationId); + if (CollUtil.isEmpty(messageList)) { + return success(Collections.emptyList()); + } + + // 2. 拼接数据,主要是知识库段落信息 + Map segmentMap = knowledgeSegmentService.getKnowledgeSegmentMap(convertListByFlatMap(messageList, + message -> CollUtil.isEmpty(message.getSegmentIds()) ? null : message.getSegmentIds().stream())); + Map documentMap = knowledgeDocumentService.getKnowledgeDocumentMap( + convertList(segmentMap.values(), AiKnowledgeSegmentDO::getDocumentId)); + List messageVOList = BeanUtils.toBean(messageList, AiChatMessageRespVO.class); + for (int i = 0; i < messageList.size(); i++) { + AiChatMessageDO message = messageList.get(i); + if (CollUtil.isEmpty(message.getSegmentIds())) { + continue; + } + // 设置知识库段落信息 + messageVOList.get(i).setSegments(convertList(message.getSegmentIds(), segmentId -> { + AiKnowledgeSegmentDO segment = segmentMap.get(segmentId); + if (segment == null) { + return null; + } + AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId()); + if (document == null) { + return null; + } + return new AiChatMessageRespVO.KnowledgeSegment().setId(segment.getId()).setContent(segment.getContent()) + .setDocumentId(segment.getDocumentId()).setDocumentName(document.getName()); + })); + } + return success(messageVOList); + } + + @Operation(summary = "删除消息") + @DeleteMapping("/delete") + @Parameter(name = "id", required = true, description = "消息编号", example = "1024") + public CommonResult deleteChatMessage(@RequestParam("id") Long id) { + chatMessageService.deleteChatMessage(id, getLoginUserId()); + return success(true); + } + + @Operation(summary = "删除指定对话的消息") + @DeleteMapping("/delete-by-conversation-id") + @Parameter(name = "conversationId", required = true, description = "对话编号", example = "1024") + public CommonResult deleteChatMessageByConversationId(@RequestParam("conversationId") Long conversationId) { + chatMessageService.deleteChatMessageByConversationId(conversationId, getLoginUserId()); + return success(true); + } + + // ========== 对话管理 ========== + + @GetMapping("/page") + @Operation(summary = "获得消息分页", description = "用于【对话管理】菜单") + @PreAuthorize("@ss.hasPermission('ai:chat-conversation:query')") + public CommonResult> getChatMessagePage(AiChatMessagePageReqVO pageReqVO) { + PageResult pageResult = chatMessageService.getChatMessagePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty()); + } + // 拼接数据 + Map roleMap = chatRoleService.getChatRoleMap( + convertSet(pageResult.getList(), AiChatMessageDO::getRoleId)); + return success(BeanUtils.toBean(pageResult, AiChatMessageRespVO.class, + respVO -> MapUtils.findAndThen(roleMap, respVO.getRoleId(), + role -> respVO.setRoleName(role.getName())))); + } + + @Operation(summary = "管理员删除消息") + @DeleteMapping("/delete-by-admin") + @Parameter(name = "id", required = true, description = "消息编号", example = "1024") + @PreAuthorize("@ss.hasPermission('ai:chat-message:delete')") + public CommonResult deleteChatMessageByAdmin(@RequestParam("id") Long id) { + chatMessageService.deleteChatMessageByAdmin(id); + return success(true); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationCreateMyReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationCreateMyReqVO.java new file mode 100644 index 0000000..4796e53 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationCreateMyReqVO.java @@ -0,0 +1,16 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 聊天对话创建【我的】 Request VO") +@Data +public class AiChatConversationCreateMyReqVO { + + @Schema(description = "聊天角色编号", example = "666") + private Long roleId; + + @Schema(description = "知识库编号", example = "1204") + private Long knowledgeId; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationPageReqVO.java new file mode 100644 index 0000000..3972b18 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationPageReqVO.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 聊天对话的分页 Request VO") +@Data +public class AiChatConversationPageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "1024") + private Long userId; + + @Schema(description = "对话标题", example = "你好") + private String title; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationRespVO.java new file mode 100644 index 0000000..4d6ab8e --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationRespVO.java @@ -0,0 +1,71 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation; + +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiChatRoleDO; +import com.fhs.core.trans.anno.Trans; +import com.fhs.core.trans.constant.TransType; +import com.fhs.core.trans.vo.VO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 聊天对话 Response VO") +@Data +public class AiChatConversationRespVO implements VO { + + @Schema(description = "对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long userId; + + @Schema(description = "对话标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "我是一个标题") + private String title; + + @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean pinned; + + @Schema(description = "角色编号", example = "1") + @Trans(type = TransType.SIMPLE, target = AiChatRoleDO.class, fields = {"name", "avatar"}, refs = {"roleName", "roleAvatar"}) + private Long roleId; + + @Schema(description = "模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @Trans(type = TransType.SIMPLE, target = AiModelDO.class, fields = "name", ref = "modelName") + private Long modelId; + + @Schema(description = "模型标志", requiredMode = Schema.RequiredMode.REQUIRED, example = "ERNIE-Bot-turbo-0922") + private String model; + + @Schema(description = "模型名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + private String modelName; + + @Schema(description = "角色设定", example = "一个快乐的程序员") + private String systemMessage; + + @Schema(description = "温度参数", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.8") + private Double temperature; + + @Schema(description = "单条回复的最大 Token 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096") + private Integer maxTokens; + + @Schema(description = "上下文的最大 Message 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer maxContexts; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + // ========== 关联 role 信息 ========== + + @Schema(description = "角色头像", example = "https://www.iocoder.cn/1.png") + private String roleAvatar; + + @Schema(description = "角色名字", example = "小黄") + private String roleName; + + // ========== 仅在【对话管理】时加载 ========== + + @Schema(description = "消息数量", example = "20") + private Integer messageCount; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationUpdateMyReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationUpdateMyReqVO.java new file mode 100644 index 0000000..41d9a30 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/conversation/AiChatConversationUpdateMyReqVO.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 聊天对话更新【我的】 Request VO") +@Data +public class AiChatConversationUpdateMyReqVO { + + @Schema(description = "对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "对话编号不能为空") + private Long id; + + @Schema(description = "对话标题", example = "我是一个标题") + private String title; + + @Schema(description = "是否置顶", example = "true") + private Boolean pinned; + + @Schema(description = "模型编号", example = "1") + private Long modelId; + + @Schema(description = "知识库编号", example = "1") + private Long knowledgeId; + + @Schema(description = "角色设定", example = "一个快乐的程序员") + private String systemMessage; + + @Schema(description = "温度参数", example = "0.8") + private Double temperature; + + @Schema(description = "单条回复的最大 Token 数量", example = "4096") + private Integer maxTokens; + + @Schema(description = "上下文的最大 Message 数量", example = "10") + private Integer maxContexts; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessagePageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessagePageReqVO.java new file mode 100644 index 0000000..0c1c1f8 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessagePageReqVO.java @@ -0,0 +1,29 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.message; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 聊天消息的分页 Request VO") +@Data +public class AiChatMessagePageReqVO extends PageParam { + + @Schema(description = "对话编号", example = "2048") + private Long conversationId; + + @Schema(description = "用户编号", example = "1024") + private Long userId; + + @Schema(description = "消息内容", example = "你好") + private String content; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java new file mode 100644 index 0000000..a3f8f58 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java @@ -0,0 +1,85 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.message; + +import cn.aagro.pp.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - AI 聊天消息 Response VO") +@Data +public class AiChatMessageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long conversationId; + + @Schema(description = "回复消息编号", example = "1024") + private Long replyId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "role") + private String type; // 参见 MessageType 枚举类 + + @Schema(description = "用户编号", example = "4096") + private Long userId; + + @Schema(description = "角色编号", example = "888") + private Long roleId; + + @Schema(description = "模型标志", requiredMode = Schema.RequiredMode.REQUIRED, example = "gpt-3.5-turbo") + private String model; + + @Schema(description = "模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "123") + private Long modelId; + + @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好,你好啊") + private String content; + + @Schema(description = "推理内容", example = "要达到这个目标,你需要...") + private String reasoningContent; + + @Schema(description = "是否携带上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean useContext; + + @Schema(description = "知识库段落编号数组", example = "[1,2,3]") + private List segmentIds; + + @Schema(description = "知识库段落数组") + private List segments; + + @Schema(description = "联网搜索的网页内容数组") + private List webSearchPages; + + @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png") + private List attachmentUrls; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-05-12 12:51") + private LocalDateTime createTime; + + // ========== 仅在【对话管理】时加载 ========== + + @Schema(description = "角色名字", example = "小黄") + private String roleName; + + @Schema(description = "知识库段落", example = "Java 开发手册") + @Data + public static class KnowledgeSegment { + + @Schema(description = "段落编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "切片内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发手册") + private String content; + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long documentId; + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品使用手册") + private String documentName; + + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java new file mode 100644 index 0000000..12d195e --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java @@ -0,0 +1,31 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI 聊天消息发送 Request VO") +@Data +public class AiChatMessageSendReqVO { + + @Schema(description = "聊天对话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "聊天对话编号不能为空") + private Long conversationId; + + @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "帮我写个 Java 算法") + @NotEmpty(message = "聊天内容不能为空") + private String content; + + @Schema(description = "是否携带上下文", example = "true") + private Boolean useContext; + + @Schema(description = "是否联网搜索", example = "true") + private Boolean useSearch; + + @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png") + private List attachmentUrls; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java new file mode 100644 index 0000000..f0e112d --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java @@ -0,0 +1,50 @@ +package cn.aagro.pp.module.ai.controller.admin.chat.vo.message; + +import cn.aagro.pp.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - AI 聊天消息发送 Response VO") +@Data +public class AiChatMessageSendRespVO { + + @Schema(description = "发送消息", requiredMode = Schema.RequiredMode.REQUIRED) + private Message send; + + @Schema(description = "接收消息", requiredMode = Schema.RequiredMode.REQUIRED) + private Message receive; + + @Schema(description = "消息") + @Data + public static class Message { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "role") + private String type; // 参见 MessageType 枚举类 + + @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好,你好啊") + private String content; + + @Schema(description = "推理内容", example = "要达到这个目标,你需要...") + private String reasoningContent; + + @Schema(description = "知识库段落编号数组", example = "[1,2,3]") + private List segmentIds; + + @Schema(description = "知识库段落数组") + private List segments; + + @Schema(description = "联网搜索的网页内容数组") + private List webSearchPages; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/AiImageController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/AiImageController.http new file mode 100644 index 0000000..9047610 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/AiImageController.http @@ -0,0 +1,42 @@ +### 生成图片:OpenAI(DALL) +POST {{baseUrl}}/ai/image/draw +Content-Type: application/json +Authorization: {{token}} + +{ + "platform": "OpenAI", + "prompt": "可爱的小喵星人", + "model": "dall-e-3", + "height": "1024", + "width": "1024", + "options": { + "style": "vivid" + } +} + +### 生成图片:StableDiffusion +POST {{baseUrl}}/ai/image/draw +Content-Type: application/json +Authorization: {{token}} + +{ + "platform": "StableDiffusion", + "prompt": "中国长城", + "model": "stable-diffusion-v1-6", + "height": "1024", + "width": "1024", + "style": "vivid" +} + +### 生成图片:生成图片(Midjourney) +POST {{baseUrl}}/ai/image/midjourney/imagine +Content-Type: application/json +Authorization: {{token}} + +{ + "prompt": "中国旗袍", + "model": "midjourney", + "width": "1", + "height": "1", + "version": "6.0" +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/AiImageController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/AiImageController.java new file mode 100644 index 0000000..376de99 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/AiImageController.java @@ -0,0 +1,139 @@ +package cn.aagro.pp.module.ai.controller.admin.image; + +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.framework.tenant.core.aop.TenantIgnore; +import cn.aagro.pp.module.ai.controller.admin.image.vo.*; +import cn.aagro.pp.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyActionReqVO; +import cn.aagro.pp.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyImagineReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.image.AiImageDO; +import cn.aagro.pp.module.ai.service.image.AiImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI 绘画") +@RestController +@RequestMapping("/ai/image") +@Slf4j +public class AiImageController { + + @Resource + private AiImageService imageService; + + @GetMapping("/my-page") + @Operation(summary = "获取【我的】绘图分页") + public CommonResult> getImagePageMy(@Validated AiImagePageReqVO pageReqVO) { + PageResult pageResult = imageService.getImagePageMy(getLoginUserId(), pageReqVO); + return success(BeanUtils.toBean(pageResult, AiImageRespVO.class)); + } + + @GetMapping("/public-page") + @Operation(summary = "获取公开的绘图分页") + public CommonResult> getImagePagePublic(AiImagePublicPageReqVO pageReqVO) { + PageResult pageResult = imageService.getImagePagePublic(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiImageRespVO.class)); + } + + @GetMapping("/get-my") + @Operation(summary = "获取【我的】绘图记录") + @Parameter(name = "id", required = true, description = "绘画编号", example = "1024") + public CommonResult getImageMy(@RequestParam("id") Long id) { + AiImageDO image = imageService.getImage(id); + if (image == null || ObjUtil.notEqual(getLoginUserId(), image.getUserId())) { + return success(null); + } + return success(BeanUtils.toBean(image, AiImageRespVO.class)); + } + + @GetMapping("/my-list-by-ids") + @Operation(summary = "获取【我的】绘图记录列表") + @Parameter(name = "ids", required = true, description = "绘画编号数组", example = "1024,2048") + public CommonResult> getImageListMyByIds(@RequestParam("ids") List ids) { + List imageList = imageService.getImageList(ids); + imageList.removeIf(item -> !ObjUtil.equal(getLoginUserId(), item.getUserId())); + return success(BeanUtils.toBean(imageList, AiImageRespVO.class)); + } + + @Operation(summary = "生成图片") + @PostMapping("/draw") + public CommonResult drawImage(@Valid @RequestBody AiImageDrawReqVO drawReqVO) { + return success(imageService.drawImage(getLoginUserId(), drawReqVO)); + } + + @Operation(summary = "删除【我的】绘画记录") + @DeleteMapping("/delete-my") + @Parameter(name = "id", required = true, description = "绘画编号", example = "1024") + public CommonResult deleteImageMy(@RequestParam("id") Long id) { + imageService.deleteImageMy(id, getLoginUserId()); + return success(true); + } + + // ================ midjourney 专属 ================ + + @Operation(summary = "【Midjourney】生成图片") + @PostMapping("/midjourney/imagine") + public CommonResult midjourneyImagine(@Valid @RequestBody AiMidjourneyImagineReqVO reqVO) { + Long imageId = imageService.midjourneyImagine(getLoginUserId(), reqVO); + return success(imageId); + } + + @Operation(summary = "【Midjourney】通知图片进展", description = "由 Midjourney Proxy 回调") + @PostMapping("/midjourney/notify") // 必须是 POST 方法,否则会报错 + @PermitAll + @TenantIgnore + public CommonResult midjourneyNotify(@Valid @RequestBody MidjourneyApi.Notify notify) { + imageService.midjourneyNotify(notify); + return success(true); + } + + @Operation(summary = "【Midjourney】Action 操作(二次生成图片)", description = "例如说:放大、缩小、U1、U2 等") + @PostMapping("/midjourney/action") + public CommonResult midjourneyAction(@Valid @RequestBody AiMidjourneyActionReqVO reqVO) { + Long imageId = imageService.midjourneyAction(getLoginUserId(), reqVO); + return success(imageId); + } + + // ================ 绘图管理 ================ + + @GetMapping("/page") + @Operation(summary = "获得绘画分页") + @PreAuthorize("@ss.hasPermission('ai:image:query')") + public CommonResult> getImagePage(@Valid AiImagePageReqVO pageReqVO) { + PageResult pageResult = imageService.getImagePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiImageRespVO.class)); + } + + @PutMapping("/update") + @Operation(summary = "更新绘画") + @PreAuthorize("@ss.hasPermission('ai:image:update')") + public CommonResult updateImage(@Valid @RequestBody AiImageUpdateReqVO updateReqVO) { + imageService.updateImage(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除绘画") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:image:delete')") + public CommonResult deleteImage(@RequestParam("id") Long id) { + imageService.deleteImage(id); + return success(true); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageDrawReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageDrawReqVO.java new file mode 100644 index 0000000..9bffdf1 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageDrawReqVO.java @@ -0,0 +1,49 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.ai.stabilityai.api.StabilityAiImageOptions; + +import java.util.Map; + +@Schema(description = "管理后台 - AI 绘画 Request VO") +@Data +public class AiImageDrawReqVO { + + @Schema(description = "模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "模型编号不能为空") + private Long modelId; + + @Schema(description = "提示词", requiredMode = Schema.RequiredMode.REQUIRED, example = "画一个长城") + @NotEmpty(message = "提示词不能为空") + @Size(max = 1200, message = "提示词最大 1200") + private String prompt; + + /** + * 1. dall-e-2 模型:256x256、512x512、1024x1024 + * 2. dall-e-3 模型:1024x1024, 1792x1024, 或 1024x1792 + */ + @Schema(description = "图片高度") + @NotNull(message = "图片高度不能为空") + private Integer height; + + @Schema(description = "图片宽度") + @NotNull(message = "图片宽度不能为空") + private Integer width; + + // ========== 各平台绘画的拓展参数 ========== + + /** + * 绘制参数,不同 platform 的不同参数 + * + * 1. {@link OpenAiImageOptions} + * 2. {@link StabilityAiImageOptions} + */ + @Schema(description = "绘制参数") + private Map options; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImagePageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImagePageReqVO.java new file mode 100644 index 0000000..7c761d6 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImagePageReqVO.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 绘画分页 Request VO") +@Data +public class AiImagePageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "28987") + private Long userId; + + @Schema(description = "平台", example = "OpenAI") + private String platform; + + @Schema(description = "提示词", example = "1") + private String prompt; + + @Schema(description = "绘画状态", example = "1") + private Integer status; + + @Schema(description = "是否发布", example = "1") + private Boolean publicStatus; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImagePublicPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImagePublicPageReqVO.java new file mode 100644 index 0000000..73da0e5 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImagePublicPageReqVO.java @@ -0,0 +1,14 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 绘画公开的分页 Request VO") +@Data +public class AiImagePublicPageReqVO extends PageParam { + + @Schema(description = "提示词") + private String prompt; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageRespVO.java new file mode 100644 index 0000000..7815e34 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageRespVO.java @@ -0,0 +1,60 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo; + +import cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - AI 绘画 Response VO") +@Data +public class AiImageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "OpenAI") + private String platform; // 参见 AiPlatformEnum 枚举 + + @Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "stable-diffusion-v1-6") + private String model; + + @Schema(description = "提示词", requiredMode = Schema.RequiredMode.REQUIRED, example = "南极的小企鹅") + private String prompt; + + @Schema(description = "图片宽度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer width; + + @Schema(description = "图片高度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer height; + + @Schema(description = "绘画状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer status; + + @Schema(description = "是否发布", requiredMode = Schema.RequiredMode.REQUIRED, example = "public") + private Boolean publicStatus; + + @Schema(description = "图片地址", example = "https://www.iocoder.cn/1.png") + private String picUrl; + + @Schema(description = "绘画错误信息", example = "图片错误信息") + private String errorMessage; + + @Schema(description = "绘制参数") + private Map options; + + @Schema(description = "mj buttons 按钮") + private List buttons; + + @Schema(description = "完成时间") + private LocalDateTime finishTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageUpdateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageUpdateReqVO.java new file mode 100644 index 0000000..ba35a8c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/AiImageUpdateReqVO.java @@ -0,0 +1,18 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 绘画修改 Request VO") +@Data +public class AiImageUpdateReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15583") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "是否发布", example = "true") + private Boolean publicStatus; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/midjourney/AiMidjourneyActionReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/midjourney/AiMidjourneyActionReqVO.java new file mode 100644 index 0000000..fd43ba3 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/midjourney/AiMidjourneyActionReqVO.java @@ -0,0 +1,20 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo.midjourney; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 绘图操作(Midjourney) Request VO") +@Data +public class AiMidjourneyActionReqVO { + + @Schema(description = "图片编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "图片编号不能为空") + private Long id; + + @Schema(description = "操作按钮编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "MJ::JOB::variation::4::06aa3e66-0e97-49cc-8201-e0295d883de4") + @NotEmpty(message = "操作按钮编号不能为空") + private String customId; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/midjourney/AiMidjourneyImagineReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/midjourney/AiMidjourneyImagineReqVO.java new file mode 100644 index 0000000..fbe2b6f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/image/vo/midjourney/AiMidjourneyImagineReqVO.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.module.ai.controller.admin.image.vo.midjourney; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 绘画生成(Midjourney) Request VO") +@Data +public class AiMidjourneyImagineReqVO { + + @Schema(description = "提示词", requiredMode = Schema.RequiredMode.REQUIRED, example = "中国神龙") + @NotEmpty(message = "提示词不能为空!") + private String prompt; + + @Schema(description = "模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "模型编号不能为空") + private Long modelId; + + @Schema(description = "图片宽度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "图片宽度不能为空") + private Integer width; + + @Schema(description = "图片高度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "图片高度不能为空") + private Integer height; + + @Schema(description = "版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "6.0") + @NotEmpty(message = "版本号不能为空") + private String version; + + @Schema(description = "参考图", example = "https://www.iocoder.cn/x.png") + private String referImageUrl; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeController.http new file mode 100644 index 0000000..a0f1278 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeController.http @@ -0,0 +1,35 @@ +### 创建知识库 +POST {{baseUrl}}/ai/knowledge/create +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "name": "测试标题", + "description": "测试描述", + "embeddingModelId": 30, + "topK": 3, + "similarityThreshold": 0.5, + "status": 0 +} + +### 更新知识库 +PUT {{baseUrl}}/ai/knowledge/update +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "id": 1, + "name": "测试标题(更新)", + "description": "测试描述", + "embeddingModelId": 30, + "topK": 5, + "similarityThreshold": 0.6, + "status": 0 +} + +### 获取知识库分页 +GET {{baseUrl}}/ai/knowledge/page?pageNo=1&pageSize=10 +Authorization: {{token}} +tenant-id: {{adminTenantId}} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeController.java new file mode 100644 index 0000000..d9d6c32 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeController.java @@ -0,0 +1,84 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge.AiKnowledgePageReqVO; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge.AiKnowledgeRespVO; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge.AiKnowledgeSaveReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; +import cn.aagro.pp.module.ai.service.knowledge.AiKnowledgeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI 知识库") +@RestController +@RequestMapping("/ai/knowledge") +@Validated +public class AiKnowledgeController { + + @Resource + private AiKnowledgeService knowledgeService; + + @GetMapping("/page") + @Operation(summary = "获取知识库分页") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult> getKnowledgePage(@Valid AiKnowledgePageReqVO pageReqVO) { + PageResult pageResult = knowledgeService.getKnowledgePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiKnowledgeRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得知识库") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult getKnowledge(@RequestParam("id") Long id) { + AiKnowledgeDO knowledge = knowledgeService.getKnowledge(id); + return success(BeanUtils.toBean(knowledge, AiKnowledgeRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "创建知识库") + @PreAuthorize("@ss.hasPermission('ai:knowledge:create')") + public CommonResult createKnowledge(@RequestBody @Valid AiKnowledgeSaveReqVO createReqVO) { + return success(knowledgeService.createKnowledge(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新知识库") + @PreAuthorize("@ss.hasPermission('ai:knowledge:update')") + public CommonResult updateKnowledge(@RequestBody @Valid AiKnowledgeSaveReqVO updateReqVO) { + knowledgeService.updateKnowledge(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:knowledge:delete')") + public CommonResult deleteKnowledge(@RequestParam("id") Long id) { + knowledgeService.deleteKnowledge(id); + return success(true); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得知识库的精简列表") + public CommonResult> getKnowledgeSimpleList() { + List list = knowledgeService.getKnowledgeSimpleListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, knowledge -> new AiKnowledgeRespVO() + .setId(knowledge.getId()).setName(knowledge.getName()))); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeDocumentController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeDocumentController.http new file mode 100644 index 0000000..688d9f2 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeDocumentController.http @@ -0,0 +1,35 @@ +### 创建知识文档 +POST {{baseUrl}}/ai/knowledge/document/create +Content-Type: application/json +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} + +{ + "knowledgeId": 2, + "name": "测试文档", + "url": "https://static.iocoder.cn/README.md", + "segmentMaxTokens": 800 +} + +### 批量创建知识文档 +POST {{baseUrl}}/ai/knowledge/document/create-list +Content-Type: application/json +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} + +{ + "knowledgeId": 1, + "list": [ + { + "name": "测试文档1", + "url": "https://static.iocoder.cn/README.md", + "segmentMaxTokens": 800 + }, + { + "name": "测试文档2", + "url": "https://static.iocoder.cn/README_aagro.md", + "segmentMaxTokens": 400 + } + ] +} + diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeDocumentController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeDocumentController.java new file mode 100644 index 0000000..bfc2979 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeDocumentController.java @@ -0,0 +1,90 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge; + +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document.*; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge.AiKnowledgeDocumentCreateReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; +import cn.aagro.pp.module.ai.service.knowledge.AiKnowledgeDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - AI 知识库文档") +@RestController +@RequestMapping("/ai/knowledge/document") +@Validated +public class AiKnowledgeDocumentController { + + @Resource + private AiKnowledgeDocumentService documentService; + + @GetMapping("/page") + @Operation(summary = "获取文档分页") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult> getKnowledgeDocumentPage( + @Valid AiKnowledgeDocumentPageReqVO pageReqVO) { + PageResult pageResult = documentService.getKnowledgeDocumentPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiKnowledgeDocumentRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获取文档详情") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult getKnowledgeDocument(@RequestParam("id") Long id) { + AiKnowledgeDocumentDO document = documentService.getKnowledgeDocument(id); + return success(BeanUtils.toBean(document, AiKnowledgeDocumentRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "新建文档(单个)") + @PreAuthorize("@ss.hasPermission('ai:knowledge:create')") + public CommonResult createKnowledgeDocument(@RequestBody @Valid AiKnowledgeDocumentCreateReqVO reqVO) { + Long id = documentService.createKnowledgeDocument(reqVO); + return success(id); + } + + @PostMapping("/create-list") + @Operation(summary = "新建文档(多个)") + @PreAuthorize("@ss.hasPermission('ai:knowledge:create')") + public CommonResult> createKnowledgeDocumentList( + @RequestBody @Valid AiKnowledgeDocumentCreateListReqVO reqVO) { + List ids = documentService.createKnowledgeDocumentList(reqVO); + return success(ids); + } + + @PutMapping("/update") + @Operation(summary = "更新文档") + @PreAuthorize("@ss.hasPermission('ai:knowledge:update')") + public CommonResult updateKnowledgeDocument(@Valid @RequestBody AiKnowledgeDocumentUpdateReqVO reqVO) { + documentService.updateKnowledgeDocument(reqVO); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "更新文档状态") + @PreAuthorize("@ss.hasPermission('ai:knowledge:update')") + public CommonResult updateKnowledgeDocumentStatus( + @Valid @RequestBody AiKnowledgeDocumentUpdateStatusReqVO reqVO) { + documentService.updateKnowledgeDocumentStatus(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除文档") + @PreAuthorize("@ss.hasPermission('ai:knowledge:delete')") + public CommonResult deleteKnowledgeDocument(@RequestParam("id") Long id) { + documentService.deleteKnowledgeDocument(id); + return success(true); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeSegmentController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeSegmentController.http new file mode 100644 index 0000000..84e0284 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeSegmentController.http @@ -0,0 +1,17 @@ +### 切片内容 +GET {{baseUrl}}/ai/knowledge/segment/split?url=https://static.iocoder.cn/README_aagro.md&segmentMaxTokens=800 +Content-Type: application/json +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} + +### 搜索段落内容 +GET {{baseUrl}}/ai/knowledge/segment/search?knowledgeId=2&content=如何使用这个产品&topK=5&similarityThreshold=0.1 +Content-Type: application/json +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} + +### 获取文档处理列表 +GET {{baseUrl}}/ai/knowledge/segment/get-process-list?documentIds=1,2,3 +Content-Type: application/json +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeSegmentController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeSegmentController.java new file mode 100644 index 0000000..62f386c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/AiKnowledgeSegmentController.java @@ -0,0 +1,130 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.collection.MapUtils; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment.*; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; +import cn.aagro.pp.module.ai.service.knowledge.AiKnowledgeDocumentService; +import cn.aagro.pp.module.ai.service.knowledge.AiKnowledgeSegmentService; +import cn.aagro.pp.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO; +import cn.aagro.pp.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchRespBO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.hibernate.validator.constraints.URL; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI 知识库段落") +@RestController +@RequestMapping("/ai/knowledge/segment") +@Validated +public class AiKnowledgeSegmentController { + + @Resource + private AiKnowledgeSegmentService segmentService; + @Resource + private AiKnowledgeDocumentService documentService; + + @GetMapping("/get") + @Operation(summary = "获取段落详情") + @Parameter(name = "id", description = "段落编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult getKnowledgeSegment(@RequestParam("id") Long id) { + AiKnowledgeSegmentDO segment = segmentService.getKnowledgeSegment(id); + return success(BeanUtils.toBean(segment, AiKnowledgeSegmentRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获取段落分页") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult> getKnowledgeSegmentPage( + @Valid AiKnowledgeSegmentPageReqVO pageReqVO) { + PageResult pageResult = segmentService.getKnowledgeSegmentPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiKnowledgeSegmentRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "创建段落") + @PreAuthorize("@ss.hasPermission('ai:knowledge:create')") + public CommonResult createKnowledgeSegment(@Valid @RequestBody AiKnowledgeSegmentSaveReqVO createReqVO) { + return success(segmentService.createKnowledgeSegment(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新段落内容") + @PreAuthorize("@ss.hasPermission('ai:knowledge:update')") + public CommonResult updateKnowledgeSegment(@Valid @RequestBody AiKnowledgeSegmentSaveReqVO reqVO) { + segmentService.updateKnowledgeSegment(reqVO); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "启禁用段落内容") + @PreAuthorize("@ss.hasPermission('ai:knowledge:update')") + public CommonResult updateKnowledgeSegmentStatus( + @Valid @RequestBody AiKnowledgeSegmentUpdateStatusReqVO reqVO) { + segmentService.updateKnowledgeSegmentStatus(reqVO); + return success(true); + } + + @GetMapping("/split") + @Operation(summary = "切片内容") + @Parameters({ + @Parameter(name = "url", description = "文档 URL", required = true), + @Parameter(name = "segmentMaxTokens", description = "分段的最大 Token 数", required = true) + }) + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult> splitContent( + @RequestParam("url") @URL String url, + @RequestParam(value = "segmentMaxTokens") Integer segmentMaxTokens) { + List segments = segmentService.splitContent(url, segmentMaxTokens); + return success(BeanUtils.toBean(segments, AiKnowledgeSegmentRespVO.class)); + } + + @GetMapping("/get-process-list") + @Operation(summary = "获取文档处理列表") + @Parameter(name = "documentIds", description = "文档编号列表", required = true, example = "1,2,3") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult> getKnowledgeSegmentProcessList( + @RequestParam("documentIds") List documentIds) { + List list = segmentService.getKnowledgeSegmentProcessList(documentIds); + return success(list); + } + + @GetMapping("/search") + @Operation(summary = "搜索段落内容") + @PreAuthorize("@ss.hasPermission('ai:knowledge:query')") + public CommonResult> searchKnowledgeSegment( + @Valid AiKnowledgeSegmentSearchReqVO reqVO) { + // 1. 搜索段落 + List segments = segmentService + .searchKnowledgeSegment(BeanUtils.toBean(reqVO, AiKnowledgeSegmentSearchReqBO.class)); + if (CollUtil.isEmpty(segments)) { + return success(Collections.emptyList()); + } + + // 2. 拼接 VO + Map documentMap = documentService.getKnowledgeDocumentMap(convertSet( + segments, AiKnowledgeSegmentSearchRespBO::getDocumentId)); + return success(BeanUtils.toBean(segments, AiKnowledgeSegmentSearchRespVO.class, + segment -> MapUtils.findAndThen(documentMap, segment.getDocumentId(), + document -> segment.setDocumentName(document.getName())))); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentCreateListReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentCreateListReqVO.java new file mode 100644 index 0000000..d9dca5a --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentCreateListReqVO.java @@ -0,0 +1,42 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import java.util.List; + +@Schema(description = "管理后台 - AI 知识库文档批量创建 Request VO") +@Data +public class AiKnowledgeDocumentCreateListReqVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1204") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeId; + + @Schema(description = "分段的最大 Token 数", requiredMode = Schema.RequiredMode.REQUIRED, example = "800") + @NotNull(message = "分段的最大 Token 数不能为空") + private Integer segmentMaxTokens; + + @Schema(description = "文档列表", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "文档列表不能为空") + private List list; + + @Schema(description = "文档") + @Data + public static class Document { + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "三方登陆") + @NotBlank(message = "文档名称不能为空") + private String name; + + @Schema(description = "文档 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://doc.iocoder.cn") + @URL(message = "文档 URL 格式不正确") + private String url; + + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentPageReqVO.java new file mode 100644 index 0000000..a330b6a --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentPageReqVO.java @@ -0,0 +1,17 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库文档的分页 Request VO") +@Data +public class AiKnowledgeDocumentPageReqVO extends PageParam { + + @Schema(description = "知识库编号", example = "1") + private Long knowledgeId; + + @Schema(description = "文档名称", example = "Java 开发手册") + private String name; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentRespVO.java new file mode 100644 index 0000000..f64e173 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentRespVO.java @@ -0,0 +1,45 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 知识库文档 Response VO") +@Data +public class AiKnowledgeDocumentRespVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long knowledgeId; + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发手册") + private String name; + + @Schema(description = "文档 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://doc.iocoder.cn") + private String url; + + @Schema(description = "文档内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 是一门面向对象的语言.....") + private String content; + + @Schema(description = "文档内容长度", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Integer contentLength; + + @Schema(description = "文档 Token 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer tokens; + + @Schema(description = "分片最大 Token 数", requiredMode = Schema.RequiredMode.REQUIRED, example = "512") + private Integer segmentMaxTokens; + + @Schema(description = "召回次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer retrievalCount; + + @Schema(description = "文档状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentUpdateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentUpdateReqVO.java new file mode 100644 index 0000000..7b09920 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentUpdateReqVO.java @@ -0,0 +1,21 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库文档更新 Request VO") +@Data +public class AiKnowledgeDocumentUpdateReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15583") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "名称", example = "Java 开发手册") + private String name; + + @Schema(description = "分片最大 Token 数", example = "1000") + private Integer segmentMaxTokens; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentUpdateStatusReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentUpdateStatusReqVO.java new file mode 100644 index 0000000..125be95 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/document/AiKnowledgeDocumentUpdateStatusReqVO.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库文档更新状态 Request VO") +@Data +public class AiKnowledgeDocumentUpdateStatusReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15583") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeDocumentCreateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeDocumentCreateReqVO.java new file mode 100644 index 0000000..8e70ab4 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeDocumentCreateReqVO.java @@ -0,0 +1,30 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + + +@Schema(description = "管理后台 - AI 知识库文档的创建 Request VO") +@Data +public class AiKnowledgeDocumentCreateReqVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1204") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeId; + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "三方登陆") + @NotBlank(message = "文档名称不能为空") + private String name; + + @Schema(description = "文档 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://doc.iocoder.cn") + @URL(message = "文档 URL 格式不正确") + private String url; + + @Schema(description = "分段的最大 Token 数", requiredMode = Schema.RequiredMode.REQUIRED, example = "800") + @NotNull(message = "分段的最大 Token 数不能为空") + private Integer segmentMaxTokens; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgePageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgePageReqVO.java new file mode 100644 index 0000000..ec1309e --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgePageReqVO.java @@ -0,0 +1,29 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 知识库的分页 Request VO") +@Data +public class AiKnowledgePageReqVO extends PageParam { + + @Schema(description = "知识库名称", example = "芋艿") + private String name; + + @Schema(description = "是否启用", example = "1") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeRespVO.java new file mode 100644 index 0000000..8e94068 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeRespVO.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 知识库 Response VO") +@Data +public class AiKnowledgeRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long id; + + @Schema(description = "知识库名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ruoyi-vue-pro 用户指南") + private String name; + + @Schema(description = "知识库描述", example = "帮助你快速构建系统") + private String description; + + @Schema(description = "向量模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "14") + private Long embeddingModelId; + + @Schema(description = "向量模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "qwen-72b-chat") + private String embeddingModel; + + @Schema(description = "topK", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer topK; + + @Schema(description = "相似度阈值", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.7") + private Double similarityThreshold; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeSaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeSaveReqVO.java new file mode 100644 index 0000000..dac5905 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/knowledge/AiKnowledgeSaveReqVO.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库新增/修改 Request VO") +@Data +public class AiKnowledgeSaveReqVO { + + @Schema(description = "对话编号", example = "1204") + private Long id; + + @Schema(description = "知识库名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ruoyi-vue-pro 用户指南") + @NotBlank(message = "知识库名称不能为空") + private String name; + + @Schema(description = "知识库描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "存储 ruoyi-vue-pro 操作文档") + private String description; + + @Schema(description = "向量模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "向量模型不能为空") + private Long embeddingModelId; + + @Schema(description = "topK", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "topK 不能为空") + private Integer topK; + + @Schema(description = "相似性阈值", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.5") + @NotNull(message = "相似性阈值不能为空") + private Double similarityThreshold; + + @Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "是否启用不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentPageReqVO.java new file mode 100644 index 0000000..999eafa --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentPageReqVO.java @@ -0,0 +1,23 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库分段的分页 Request VO") +@Data +public class AiKnowledgeSegmentPageReqVO extends PageParam { + + @Schema(description = "文档编号", example = "1") + private Integer documentId; + + @Schema(description = "分段内容关键字", example = "Java 开发") + private String content; + + @Schema(description = "分段状态", example = "1") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentProcessRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentProcessRespVO.java new file mode 100644 index 0000000..11e7a75 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentProcessRespVO.java @@ -0,0 +1,19 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库段落向量进度 Response VO") +@Data +public class AiKnowledgeSegmentProcessRespVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long documentId; + + @Schema(description = "总段落数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Long count; + + @Schema(description = "已向量化段落数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Long embeddingCount; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentRespVO.java new file mode 100644 index 0000000..d5049af --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentRespVO.java @@ -0,0 +1,40 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库文档分片 Response VO") +@Data +public class AiKnowledgeSegmentRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long id; + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long documentId; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long knowledgeId; + + @Schema(description = "向量库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1858496a-1dde-4edf-a43e-0aed08f37f8c") + private String vectorId; + + @Schema(description = "切片内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发手册") + private String content; + + @Schema(description = "切片内容长度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer contentLength; + + @Schema(description = "token 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer tokens; + + @Schema(description = "召回次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer retrievalCount; + + @Schema(description = "文档状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private Long createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSaveReqVO.java new file mode 100644 index 0000000..06fa09f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSaveReqVO.java @@ -0,0 +1,21 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Schema(description = "管理后台 - AI 新增/修改知识库段落 request VO") +@Data +public class AiKnowledgeSegmentSaveReqVO { + + @Schema(description = "编号", example = "24790") + private Long id; + + @Schema(description = "知识库文档编号", example = "1024") + private Long documentId; + + @Schema(description = "切片内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 开发手册") + @NotEmpty(message = "切片内容不能为空") + private String content; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSearchReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSearchReqVO.java new file mode 100644 index 0000000..02a98f3 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSearchReqVO.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "管理后台 - AI 知识库段落搜索 Request VO") +@Data +public class AiKnowledgeSegmentSearchReqVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeId; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "如何使用这个产品") + @NotEmpty(message = "内容不能为空") + private String content; + + @Schema(description = "最大返回数量", example = "5") + private Integer topK; + + @Schema(description = "相似度阈值", example = "0.7") + private Double similarityThreshold; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSearchRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSearchRespVO.java new file mode 100644 index 0000000..0db98eb --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentSearchRespVO.java @@ -0,0 +1,16 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 知识库段落搜索 Response VO") +@Data +public class AiKnowledgeSegmentSearchRespVO extends AiKnowledgeSegmentRespVO { + + @Schema(description = "文档名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品使用手册") + private String documentName; + + @Schema(description = "相似度分数", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.95") + private Double score; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentUpdateStatusReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentUpdateStatusReqVO.java new file mode 100644 index 0000000..93bbd69 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/knowledge/vo/segment/AiKnowledgeSegmentUpdateStatusReqVO.java @@ -0,0 +1,22 @@ +package cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + + +@Schema(description = "管理后台 - AI 知识库段落的更新状态 Request VO") +@Data +public class AiKnowledgeSegmentUpdateStatusReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long id; + + @Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "是否启用不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/AiMindMapController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/AiMindMapController.java new file mode 100644 index 0000000..49e4350 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/AiMindMapController.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.module.ai.controller.admin.mindmap; + +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.mindmap.vo.AiMindMapGenerateReqVO; +import cn.aagro.pp.module.ai.controller.admin.mindmap.vo.AiMindMapPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.mindmap.vo.AiMindMapRespVO; +import cn.aagro.pp.module.ai.dal.dataobject.mindmap.AiMindMapDO; +import cn.aagro.pp.module.ai.service.mindmap.AiMindMapService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI 思维导图") +@RestController +@RequestMapping("/ai/mind-map") +public class AiMindMapController { + + @Resource + private AiMindMapService mindMapService; + + @PostMapping(value = "/generate-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "导图生成(流式)", description = "流式返回,响应较快") + public Flux> generateMindMap(@RequestBody @Valid AiMindMapGenerateReqVO generateReqVO) { + return mindMapService.generateMindMap(generateReqVO, getLoginUserId()); + } + + // ================ 导图管理 ================ + + @DeleteMapping("/delete") + @Operation(summary = "删除思维导图") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:mind-map:delete')") + public CommonResult deleteMindMap(@RequestParam("id") Long id) { + mindMapService.deleteMindMap(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得思维导图分页") + @PreAuthorize("@ss.hasPermission('ai:mind-map:query')") + public CommonResult> getMindMapPage(@Valid AiMindMapPageReqVO pageReqVO) { + PageResult pageResult = mindMapService.getMindMapPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiMindMapRespVO.class)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapGenerateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapGenerateReqVO.java new file mode 100644 index 0000000..49f719c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapGenerateReqVO.java @@ -0,0 +1,15 @@ +package cn.aagro.pp.module.ai.controller.admin.mindmap.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Schema(description = "管理后台 - AI 思维导图生成 Request VO") +@Data +public class AiMindMapGenerateReqVO { + + @Schema(description = "思维导图内容提示", example = "Java 学习路线") + @NotBlank(message = "思维导图内容提示不能为空") + private String prompt; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapPageReqVO.java new file mode 100644 index 0000000..c6d3c75 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapPageReqVO.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.module.ai.controller.admin.mindmap.vo; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 思维导图分页 Request VO") +@Data +public class AiMindMapPageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "4325") + private Long userId; + + @Schema(description = "生成内容提示", example = "Java 学习路线") + private String prompt; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapRespVO.java new file mode 100644 index 0000000..fb06e6d --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/mindmap/vo/AiMindMapRespVO.java @@ -0,0 +1,36 @@ +package cn.aagro.pp.module.ai.controller.admin.mindmap.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 思维导图 Response VO") +@Data +public class AiMindMapRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3373") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4325") + private Long userId; + + @Schema(description = "生成内容提示", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 学习路线") + private String prompt; + + @Schema(description = "生成的思维导图内容") + private String generatedContent; + + @Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "OpenAI") + private String platform; + + @Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "gpt-3.5-turbo-0125") + private String model; + + @Schema(description = "错误信息") + private String errorMessage; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiApiKeyController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiApiKeyController.java new file mode 100644 index 0000000..49e0ef4 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiApiKeyController.java @@ -0,0 +1,83 @@ +package cn.aagro.pp.module.ai.controller.admin.model; + +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.model.vo.apikey.AiApiKeyPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.apikey.AiApiKeyRespVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.apikey.AiApiKeySaveReqVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.model.AiModelRespVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiApiKeyDO; +import cn.aagro.pp.module.ai.service.model.AiApiKeyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI API 密钥") +@RestController +@RequestMapping("/ai/api-key") +@Validated +public class AiApiKeyController { + + @Resource + private AiApiKeyService apiKeyService; + + @PostMapping("/create") + @Operation(summary = "创建 API 密钥") + @PreAuthorize("@ss.hasPermission('ai:api-key:create')") + public CommonResult createApiKey(@Valid @RequestBody AiApiKeySaveReqVO createReqVO) { + return success(apiKeyService.createApiKey(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 API 密钥") + @PreAuthorize("@ss.hasPermission('ai:api-key:update')") + public CommonResult updateApiKey(@Valid @RequestBody AiApiKeySaveReqVO updateReqVO) { + apiKeyService.updateApiKey(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 API 密钥") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:api-key:delete')") + public CommonResult deleteApiKey(@RequestParam("id") Long id) { + apiKeyService.deleteApiKey(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 API 密钥") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:api-key:query')") + public CommonResult getApiKey(@RequestParam("id") Long id) { + AiApiKeyDO apiKey = apiKeyService.getApiKey(id); + return success(BeanUtils.toBean(apiKey, AiApiKeyRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得 API 密钥分页") + @PreAuthorize("@ss.hasPermission('ai:api-key:query')") + public CommonResult> getApiKeyPage(@Valid AiApiKeyPageReqVO pageReqVO) { + PageResult pageResult = apiKeyService.getApiKeyPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiApiKeyRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得 API 密钥分页列表") + public CommonResult> getApiKeySimpleList() { + List list = apiKeyService.getApiKeyList(); + return success(convertList(list, key -> new AiModelRespVO().setId(key.getId()).setName(key.getName()))); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiChatRoleController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiChatRoleController.java new file mode 100644 index 0000000..b5c6d27 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiChatRoleController.java @@ -0,0 +1,128 @@ +package cn.aagro.pp.module.ai.controller.admin.model; + +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole.AiChatRolePageReqVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole.AiChatRoleRespVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole.AiChatRoleSaveMyReqVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole.AiChatRoleSaveReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiChatRoleDO; +import cn.aagro.pp.module.ai.service.model.AiChatRoleService; +import com.fhs.core.trans.anno.TransMethodResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI 聊天角色") +@RestController +@RequestMapping("/ai/chat-role") +@Validated +public class AiChatRoleController { + + @Resource + private AiChatRoleService chatRoleService; + + @GetMapping("/my-page") + @Operation(summary = "获得【我的】聊天角色分页") + @TransMethodResult + public CommonResult> getChatRoleMyPage(@Valid AiChatRolePageReqVO pageReqVO) { + PageResult pageResult = chatRoleService.getChatRoleMyPage(pageReqVO, getLoginUserId()); + return success(BeanUtils.toBean(pageResult, AiChatRoleRespVO.class)); + } + + @GetMapping("/get-my") + @Operation(summary = "获得【我的】聊天角色") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @TransMethodResult + public CommonResult getChatRoleMy(@RequestParam("id") Long id) { + AiChatRoleDO chatRole = chatRoleService.getChatRole(id); + if (ObjUtil.notEqual(chatRole.getUserId(), getLoginUserId())) { + return success(null); + } + return success(BeanUtils.toBean(chatRole, AiChatRoleRespVO.class)); + } + + @PostMapping("/create-my") + @Operation(summary = "创建【我的】聊天角色") + public CommonResult createChatRoleMy(@Valid @RequestBody AiChatRoleSaveMyReqVO createReqVO) { + return success(chatRoleService.createChatRoleMy(createReqVO, getLoginUserId())); + } + + @PutMapping("/update-my") + @Operation(summary = "更新【我的】聊天角色") + public CommonResult updateChatRoleMy(@Valid @RequestBody AiChatRoleSaveMyReqVO updateReqVO) { + chatRoleService.updateChatRoleMy(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete-my") + @Operation(summary = "删除【我的】聊天角色") + @Parameter(name = "id", description = "编号", required = true) + public CommonResult deleteChatRoleMy(@RequestParam("id") Long id) { + chatRoleService.deleteChatRoleMy(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/category-list") + @Operation(summary = "获得聊天角色的分类列表") + public CommonResult> getChatRoleCategoryList() { + return success(chatRoleService.getChatRoleCategoryList()); + } + + // ========== 角色管理 ========== + + @PostMapping("/create") + @Operation(summary = "创建聊天角色") + @PreAuthorize("@ss.hasPermission('ai:chat-role:create')") + public CommonResult createChatRole(@Valid @RequestBody AiChatRoleSaveReqVO createReqVO) { + return success(chatRoleService.createChatRole(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新聊天角色") + @PreAuthorize("@ss.hasPermission('ai:chat-role:update')") + public CommonResult updateChatRole(@Valid @RequestBody AiChatRoleSaveReqVO updateReqVO) { + chatRoleService.updateChatRole(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除聊天角色") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:chat-role:delete')") + public CommonResult deleteChatRole(@RequestParam("id") Long id) { + chatRoleService.deleteChatRole(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得聊天角色") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:chat-role:query')") + @TransMethodResult + public CommonResult getChatRole(@RequestParam("id") Long id) { + AiChatRoleDO chatRole = chatRoleService.getChatRole(id); + return success(BeanUtils.toBean(chatRole, AiChatRoleRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得聊天角色分页") + @PreAuthorize("@ss.hasPermission('ai:chat-role:query')") + public CommonResult> getChatRolePage(@Valid AiChatRolePageReqVO pageReqVO) { + PageResult pageResult = chatRoleService.getChatRolePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiChatRoleRespVO.class)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiModelController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiModelController.java new file mode 100644 index 0000000..2a0aa3f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiModelController.java @@ -0,0 +1,89 @@ +package cn.aagro.pp.module.ai.controller.admin.model; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.model.vo.model.AiModelPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.model.AiModelRespVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.model.AiModelSaveReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import cn.aagro.pp.module.ai.service.model.AiModelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI 模型") +@RestController +@RequestMapping("/ai/model") +@Validated +public class AiModelController { + + @Resource + private AiModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建模型") + @PreAuthorize("@ss.hasPermission('ai:model:create')") + public CommonResult createModel(@Valid @RequestBody AiModelSaveReqVO createReqVO) { + return success(modelService.createModel(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新模型") + @PreAuthorize("@ss.hasPermission('ai:model:update')") + public CommonResult updateModel(@Valid @RequestBody AiModelSaveReqVO updateReqVO) { + modelService.updateModel(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除模型") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:model:delete')") + public CommonResult deleteModel(@RequestParam("id") Long id) { + modelService.deleteModel(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得模型") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:model:query')") + public CommonResult getModel(@RequestParam("id") Long id) { + AiModelDO model = modelService.getModel(id); + return success(BeanUtils.toBean(model, AiModelRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得模型分页") + @PreAuthorize("@ss.hasPermission('ai:model:query')") + public CommonResult> getModelPage(@Valid AiModelPageReqVO pageReqVO) { + PageResult pageResult = modelService.getModelPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiModelRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得模型列表") + @Parameter(name = "type", description = "类型", required = true, example = "1") + @Parameter(name = "platform", description = "平台", example = "midjourney") + public CommonResult> getModelSimpleList( + @RequestParam("type") Integer type, + @RequestParam(value = "platform", required = false) String platform) { + List list = modelService.getModelListByStatusAndType( + CommonStatusEnum.ENABLE.getStatus(), type, platform); + return success(convertList(list, model -> new AiModelRespVO().setId(model.getId()) + .setName(model.getName()).setModel(model.getModel()).setPlatform(model.getPlatform()))); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiToolController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiToolController.java new file mode 100644 index 0000000..e2accf9 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/AiToolController.java @@ -0,0 +1,84 @@ +package cn.aagro.pp.module.ai.controller.admin.model; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.model.vo.tool.AiToolPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.tool.AiToolRespVO; +import cn.aagro.pp.module.ai.controller.admin.model.vo.tool.AiToolSaveReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiToolDO; +import cn.aagro.pp.module.ai.service.model.AiToolService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI 工具") +@RestController +@RequestMapping("/ai/tool") +@Validated +public class AiToolController { + + @Resource + private AiToolService toolService; + + @PostMapping("/create") + @Operation(summary = "创建工具") + @PreAuthorize("@ss.hasPermission('ai:tool:create')") + public CommonResult createTool(@Valid @RequestBody AiToolSaveReqVO createReqVO) { + return success(toolService.createTool(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新工具") + @PreAuthorize("@ss.hasPermission('ai:tool:update')") + public CommonResult updateTool(@Valid @RequestBody AiToolSaveReqVO updateReqVO) { + toolService.updateTool(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除工具") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:tool:delete')") + public CommonResult deleteTool(@RequestParam("id") Long id) { + toolService.deleteTool(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得工具") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:tool:query')") + public CommonResult getTool(@RequestParam("id") Long id) { + AiToolDO tool = toolService.getTool(id); + return success(BeanUtils.toBean(tool, AiToolRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得工具分页") + @PreAuthorize("@ss.hasPermission('ai:tool:query')") + public CommonResult> getToolPage(@Valid AiToolPageReqVO pageReqVO) { + PageResult pageResult = toolService.getToolPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiToolRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得工具列表") + public CommonResult> getToolSimpleList() { + List list = toolService.getToolListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, tool -> new AiToolRespVO() + .setId(tool.getId()).setName(tool.getName()))); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeyPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeyPageReqVO.java new file mode 100644 index 0000000..632d80a --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeyPageReqVO.java @@ -0,0 +1,25 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.apikey; + +import lombok.*; +import java.util.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.aagro.pp.framework.common.pojo.PageParam; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI API 密钥分页 Request VO") +@Data +public class AiApiKeyPageReqVO extends PageParam { + + @Schema(description = "名称", example = "文心一言") + private String name; + + @Schema(description = "平台", example = "OpenAI") + private String platform; + + @Schema(description = "状态", example = "1") + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeyRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeyRespVO.java new file mode 100644 index 0000000..a51b989 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeyRespVO.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.apikey; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Schema(description = "管理后台 - AI API 密钥 Response VO") +@Data +public class AiApiKeyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23538") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "文心一言") + private String name; + + @Schema(description = "密钥", requiredMode = Schema.RequiredMode.REQUIRED, example = "ABC") + private String apiKey; + + @Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "OpenAI") + private String platform; + + @Schema(description = "自定义 API 地址", example = "https://aip.baidubce.com") + private String url; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeySaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeySaveReqVO.java new file mode 100644 index 0000000..9f80c7c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/apikey/AiApiKeySaveReqVO.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.apikey; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import java.util.*; +import jakarta.validation.constraints.*; + +@Schema(description = "管理后台 - AI API 密钥新增/修改 Request VO") +@Data +public class AiApiKeySaveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23538") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "文心一言") + @NotEmpty(message = "名称不能为空") + private String name; + + @Schema(description = "密钥", requiredMode = Schema.RequiredMode.REQUIRED, example = "ABC") + @NotEmpty(message = "密钥不能为空") + private String apiKey; + + @Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "OpenAI") + @NotEmpty(message = "平台不能为空") + private String platform; + + @Schema(description = "自定义 API 地址", example = "https://aip.baidubce.com") + private String url; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "状态不能为空") + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRolePageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRolePageReqVO.java new file mode 100644 index 0000000..2003bc1 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRolePageReqVO.java @@ -0,0 +1,20 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole; + +import lombok.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.aagro.pp.framework.common.pojo.PageParam; + +@Schema(description = "管理后台 - AI 聊天角色分页 Request VO") +@Data +public class AiChatRolePageReqVO extends PageParam { + + @Schema(description = "角色名称", example = "李四") + private String name; + + @Schema(description = "角色类别", example = "创作") + private String category; + + @Schema(description = "是否公开", example = "1") + private Boolean publicStatus; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java new file mode 100644 index 0000000..dc4da42 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java @@ -0,0 +1,67 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole; + +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import com.fhs.core.trans.anno.Trans; +import com.fhs.core.trans.constant.TransType; +import com.fhs.core.trans.vo.VO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - AI 聊天角色 Response VO") +@Data +public class AiChatRoleRespVO implements VO { + + @Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "32746") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9442") + private Long userId; + + @Schema(description = "模型编号", example = "17640") + @Trans(type = TransType.SIMPLE, target = AiModelDO.class, fields = { "name", "model" }, refs = { "modelName", "model" }) + private Long modelId; + @Schema(description = "模型名字", example = "张三") + private String modelName; + @Schema(description = "模型标识", example = "gpt-3.5-turbo-0125") + private String model; + + @Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + private String name; + + @Schema(description = "角色头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.png") + private String avatar; + + @Schema(description = "角色类别", requiredMode = Schema.RequiredMode.REQUIRED, example = "创作") + private String category; + + @Schema(description = "角色排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "角色描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "你说的对") + private String description; + + @Schema(description = "角色设定", requiredMode = Schema.RequiredMode.REQUIRED) + private String systemMessage; + + @Schema(description = "引用的知识库编号列表", example = "1,2,3") + private List knowledgeIds; + + @Schema(description = "引用的工具编号列表", example = "1,2,3") + private List toolIds; + + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") + private List mcpClientNames; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Boolean publicStatus; + + @Schema(description = "状态", example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java new file mode 100644 index 0000000..bc40b1d --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import org.hibernate.validator.constraints.URL; + +import java.util.List; + +@Schema(description = "管理后台 - AI 聊天角色新增/修改【我的】 Request VO") +@Data +public class AiChatRoleSaveMyReqVO { + + @Schema(description = "角色编号", example = "32746") + private Long id; + + @Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @NotEmpty(message = "角色名称不能为空") + private String name; + + @Schema(description = "角色头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.png") + @NotEmpty(message = "角色头像不能为空") + @URL(message = "角色头像必须是 URL 格式") + private String avatar; + + @Schema(description = "角色描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "你说的对") + @NotEmpty(message = "角色描述不能为空") + private String description; + + @Schema(description = "角色设定", requiredMode = Schema.RequiredMode.REQUIRED, example = "现在开始你扮演一位程序员,你是一名优秀的程序员,具有很强的逻辑思维能力,总能高效的解决问题") + @NotEmpty(message = "角色设定不能为空") + private String systemMessage; + + @Schema(description = "引用的知识库编号列表", example = "1,2,3") + private List knowledgeIds; + + @Schema(description = "引用的工具编号列表", example = "1,2,3") + private List toolIds; + + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") + private List mcpClientNames; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java new file mode 100644 index 0000000..263c469 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java @@ -0,0 +1,65 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import jakarta.validation.constraints.*; +import org.hibernate.validator.constraints.URL; + +import java.util.List; + +@Schema(description = "管理后台 - AI 聊天角色新增/修改 Request VO") +@Data +public class AiChatRoleSaveReqVO { + + @Schema(description = "角色编号", example = "32746") + private Long id; + + @Schema(description = "模型编号", example = "17640") + private Long modelId; + + @Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四") + @NotEmpty(message = "角色名称不能为空") + private String name; + + @Schema(description = "角色头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.png") + @NotEmpty(message = "角色头像不能为空") + @URL(message = "角色头像必须是 URL 格式") + private String avatar; + + @Schema(description = "角色类别", requiredMode = Schema.RequiredMode.REQUIRED, example = "创作") + @NotEmpty(message = "角色类别不能为空") + private String category; + + @Schema(description = "角色排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "角色排序不能为空") + private Integer sort; + + @Schema(description = "角色描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "你说的对") + @NotEmpty(message = "角色描述不能为空") + private String description; + + @Schema(description = "角色设定", requiredMode = Schema.RequiredMode.REQUIRED, example = "现在开始你扮演一位程序员,你是一名优秀的程序员,具有很强的逻辑思维能力,总能高效的解决问题") + @NotEmpty(message = "角色设定不能为空") + private String systemMessage; + + @Schema(description = "引用的知识库编号列表", example = "1,2,3") + private List knowledgeIds; + + @Schema(description = "引用的工具编号列表", example = "1,2,3") + private List toolIds; + + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") + private List mcpClientNames; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "是否公开不能为空") + private Boolean publicStatus; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelPageReqVO.java new file mode 100644 index 0000000..bae6212 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelPageReqVO.java @@ -0,0 +1,20 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.model; + +import lombok.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.aagro.pp.framework.common.pojo.PageParam; + +@Schema(description = "管理后台 - API 模型分页 Request VO") +@Data +public class AiModelPageReqVO extends PageParam { + + @Schema(description = "模型名字", example = "张三") + private String name; + + @Schema(description = "模型标识", example = "gpt-3.5-turbo-0125") + private String model; + + @Schema(description = "模型平台", example = "OpenAI") + private String platform; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelRespVO.java new file mode 100644 index 0000000..d544a45 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelRespVO.java @@ -0,0 +1,48 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 模型 Response VO") +@Data +public class AiModelRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2630") + private Long id; + + @Schema(description = "API 秘钥编号", example = "22042") + private Long keyId; + + @Schema(description = "模型名字", example = "张三") + private String name; + + @Schema(description = "模型标识", example = "gpt-3.5-turbo-0125") + private String model; + + @Schema(description = "模型平台", example = "OpenAI") + private String platform; + + @Schema(description = "模型类型", example = "1") + private Integer type; + + @Schema(description = "排序", example = "1") + private Integer sort; + + @Schema(description = "状态", example = "2") + private Integer status; + + @Schema(description = "温度参数", example = "1") + private Double temperature; + + @Schema(description = "单条回复的最大 Token 数量", example = "4096") + private Integer maxTokens; + + @Schema(description = "上下文的最大 Message 数量", example = "8192") + private Integer maxContexts; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelSaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelSaveReqVO.java new file mode 100644 index 0000000..03f8f55 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/model/AiModelSaveReqVO.java @@ -0,0 +1,59 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.model; + +import cn.aagro.pp.module.ai.enums.model.AiModelTypeEnum; +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - API 模型新增/修改 Request VO") +@Data +public class AiModelSaveReqVO { + + @Schema(description = "编号", example = "2630") + private Long id; + + @Schema(description = "API 秘钥编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "22042") + @NotNull(message = "API 秘钥编号不能为空") + private Long keyId; + + @Schema(description = "模型名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + @NotEmpty(message = "模型名字不能为空") + private String name; + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "gpt-3.5-turbo-0125") + @NotEmpty(message = "模型标识不能为空") + private String model; + + @Schema(description = "模型平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "OpenAI") + @NotEmpty(message = "模型平台不能为空") + @InEnum(AiPlatformEnum.class) + private String platform; + + @Schema(description = "模型类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "模型类型不能为空") + @InEnum(AiModelTypeEnum.class) + private Integer type; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "排序不能为空") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @InEnum(CommonStatusEnum.class) + @NotNull(message = "状态不能为空") + private Integer status; + + @Schema(description = "温度参数", example = "1") + private Double temperature; + + @Schema(description = "单条回复的最大 Token 数量", example = "4096") + private Integer maxTokens; + + @Schema(description = "上下文的最大 Message 数量", example = "8192") + private Integer maxContexts; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolPageReqVO.java new file mode 100644 index 0000000..5671b57 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolPageReqVO.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.tool; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 工具分页 Request VO") +@Data +public class AiToolPageReqVO extends PageParam { + + @Schema(description = "工具名称", example = "王五") + private String name; + + @Schema(description = "工具描述", example = "你猜") + private String description; + + @Schema(description = "状态", example = "1") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolRespVO.java new file mode 100644 index 0000000..a2da847 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolRespVO.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.tool; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 工具 Response VO") +@Data +public class AiToolRespVO { + + @Schema(description = "工具编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "19661") + private Long id; + + @Schema(description = "工具名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "王五") + private String name; + + @Schema(description = "工具描述", example = "你猜") + private String description; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolSaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolSaveReqVO.java new file mode 100644 index 0000000..3f4e4d0 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/model/vo/tool/AiToolSaveReqVO.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.module.ai.controller.admin.model.vo.tool; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +@Schema(description = "管理后台 - AI 工具新增/修改 Request VO") +@Data +public class AiToolSaveReqVO { + + @Schema(description = "工具编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "19661") + private Long id; + + @Schema(description = "工具名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "王五") + @NotEmpty(message = "工具名称不能为空") + private String name; + + @Schema(description = "工具描述", example = "你猜") + private String description; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/AiMusicController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/AiMusicController.http new file mode 100644 index 0000000..ae68c82 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/AiMusicController.http @@ -0,0 +1,26 @@ +### 生成音乐:Suno + 歌词模式 +POST {{baseUrl}}/ai/music/generate +Content-Type: application/json +Authorization: {{token}} + +{ + "platform": "Suno", + "generateMode": 2, + "prompt": "创作一首带有轻松吉他旋律的流行歌曲,[verse] 描述夏日海滩的宁静,[chorus] 节奏加快,表达对自由的向往。", + "model": "chirp-v3.5", + "tags": ["Happy"], + "title": "Happy Song" +} + +### 生成音乐:Suno + 描述模式 +POST {{baseUrl}}/ai/music/generate +Content-Type: application/json +Authorization: {{token}} + +{ + "platform": "Suno", + "generateMode": 1, + "model": "chirp-v3.5", + "prompt": "happy music", + "makeInstrumental": false +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/AiMusicController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/AiMusicController.java new file mode 100644 index 0000000..d5959ee --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/AiMusicController.java @@ -0,0 +1,98 @@ +package cn.aagro.pp.module.ai.controller.admin.music; + +import cn.hutool.core.util.ObjUtil; +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.music.vo.*; +import cn.aagro.pp.module.ai.dal.dataobject.music.AiMusicDO; +import cn.aagro.pp.module.ai.service.music.AiMusicService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI 音乐") +@RestController +@RequestMapping("/ai/music") +public class AiMusicController { + + @Resource + private AiMusicService musicService; + + @GetMapping("/my-page") + @Operation(summary = "获得【我的】音乐分页") + public CommonResult> getMusicMyPage(@Valid AiMusicPageReqVO pageReqVO) { + PageResult pageResult = musicService.getMusicMyPage(pageReqVO, getLoginUserId()); + return success(BeanUtils.toBean(pageResult, AiMusicRespVO.class)); + } + + @PostMapping("/generate") + @Operation(summary = "音乐生成") + public CommonResult> generateMusic(@RequestBody @Valid AiSunoGenerateReqVO reqVO) { + return success(musicService.generateMusic(getLoginUserId(), reqVO)); + } + + @Operation(summary = "删除【我的】音乐记录") + @DeleteMapping("/delete-my") + @Parameter(name = "id", required = true, description = "音乐编号", example = "1024") + public CommonResult deleteMusicMy(@RequestParam("id") Long id) { + musicService.deleteMusicMy(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get-my") + @Operation(summary = "获取【我的】音乐") + @Parameter(name = "id", required = true, description = "音乐编号", example = "1024") + public CommonResult getMusicMy(@RequestParam("id") Long id) { + AiMusicDO music = musicService.getMusic(id); + if (music == null || ObjUtil.notEqual(getLoginUserId(), music.getUserId())) { + return success(null); + } + return success(BeanUtils.toBean(music, AiMusicRespVO.class)); + } + + @PostMapping("/update-my") + @Operation(summary = "修改【我的】音乐 目前只支持修改标题") + @Parameter(name = "title", required = true, description = "音乐名称", example = "夜空中最亮的星") + public CommonResult updateMy(AiMusicUpdateMyReqVO updateReqVO) { + musicService.updateMyMusic(updateReqVO, getLoginUserId()); + return success(true); + } + + // ================ 音乐管理 ================ + + @GetMapping("/page") + @Operation(summary = "获得音乐分页") + @PreAuthorize("@ss.hasPermission('ai:music:query')") + public CommonResult> getMusicPage(@Valid AiMusicPageReqVO pageReqVO) { + PageResult pageResult = musicService.getMusicPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiMusicRespVO.class)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除音乐") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:music:delete')") + public CommonResult deleteMusic(@RequestParam("id") Long id) { + musicService.deleteMusic(id); + return success(true); + } + + @PutMapping("/update") + @Operation(summary = "更新音乐") + @PreAuthorize("@ss.hasPermission('ai:music:update')") + public CommonResult updateMusic(@Valid @RequestBody AiMusicUpdateReqVO updateReqVO) { + musicService.updateMusic(updateReqVO); + return success(true); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicPageReqVO.java new file mode 100644 index 0000000..6472e9c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicPageReqVO.java @@ -0,0 +1,40 @@ +package cn.aagro.pp.module.ai.controller.admin.music.vo; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.validation.InEnum; +import cn.aagro.pp.module.ai.enums.music.AiMusicGenerateModeEnum; +import cn.aagro.pp.module.ai.enums.music.AiMusicStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 音乐分页 Request VO") +@Data +public class AiMusicPageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "12212") + private Long userId; + + @Schema(description = "音乐名称", example = "夜空中最亮的星") + private String title; + + @Schema(description = "音乐状态", example = "20") + @InEnum(AiMusicStatusEnum.class) + private Integer status; + + @Schema(description = "生成模式", example = "1") + @InEnum(AiMusicGenerateModeEnum.class) + private Integer generateMode; + + @Schema(description = "是否发布", example = "true") + private Boolean publicStatus; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicRespVO.java new file mode 100644 index 0000000..01f5aa0 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicRespVO.java @@ -0,0 +1,70 @@ +package cn.aagro.pp.module.ai.controller.admin.music.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - AI 音乐 Response VO") +@Data +public class AiMusicRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24790") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12212") + private Long userId; + + @Schema(description = "音乐名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "夜空中最亮的星") + private String title; + + @Schema(description = "歌词", example = "oh~卖糕的") + private String lyric; + + @Schema(description = "图片地址", example = "https://www.iocoder.cn") + private String imageUrl; + + @Schema(description = "音频地址", example = "https://www.iocoder.cn") + private String audioUrl; + + @Schema(description = "视频地址", example = "https://www.iocoder.cn") + private String videoUrl; + + @Schema(description = "音乐状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Integer status; + + @Schema(description = "描述词", example = "一首轻快的歌曲") + private String gptDescriptionPrompt; + + @Schema(description = "提示词", example = "创作一首带有轻松吉他旋律的流行歌曲,[verse] 描述夏日海滩的宁静,[chorus] 节奏加快,表达对自由的向往。") + private String prompt; + + @Schema(description = "模型平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "Suno") + private String platform; + + @Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "chirp-v3.5") + private String model; + + @Schema(description = "生成模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer generateMode; + + @Schema(description = "音乐风格标签") + private List tags; + + @Schema(description = "音乐时长", example = "[\"pop\",\"jazz\",\"punk\"]") + private Double duration; + + @Schema(description = "是否发布", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean publicStatus; + + @Schema(description = "任务编号", example = "11369") + private String taskId; + + @Schema(description = "错误信息") + private String errorMessage; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicUpdateMyReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicUpdateMyReqVO.java new file mode 100644 index 0000000..d45cbef --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicUpdateMyReqVO.java @@ -0,0 +1,18 @@ +package cn.aagro.pp.module.ai.controller.admin.music.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 修改我的音乐 Request VO") +@Data +public class AiMusicUpdateMyReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15583") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "音乐名称", example = "夜空中最亮的星") + private String title; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicUpdateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicUpdateReqVO.java new file mode 100644 index 0000000..2a53134 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiMusicUpdateReqVO.java @@ -0,0 +1,18 @@ +package cn.aagro.pp.module.ai.controller.admin.music.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 音乐修改 Request VO") +@Data +public class AiMusicUpdateReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15583") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "是否发布", example = "true") + private Boolean publicStatus; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiSunoGenerateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiSunoGenerateReqVO.java new file mode 100644 index 0000000..181d9fe --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/music/vo/AiSunoGenerateReqVO.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.module.ai.controller.admin.music.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI 音乐生成 Request VO") +@Data +public class AiSunoGenerateReqVO { + + @Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "Suno") + @NotBlank(message = "平台不能为空") + private String platform; // 参见 AiPlatformEnum 枚举 + + /** + * 1. 描述模式:描述词 + 是否纯音乐 + 模型 + * 2. 歌词模式:歌词 + 音乐风格 + 标题 + 模型 + */ + @Schema(description = "生成模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "生成模式不能为空") + private Integer generateMode; // 参见 AiMusicGenerateModeEnum 枚举 + + @Schema(description = "用于生成音乐音频的歌词提示", + example = """ + 1.描述模式:创作一首带有轻松吉他旋律的流行歌曲,[verse] 描述夏日海滩的宁静,[chorus] 节奏加快,表达对自由的向往。 + 2.歌词模式: + [Verse] + 阳光下奔跑 多么欢快 + 假期就要来 心都飞起来 + 朋友在一旁 笑声又灿烂 + 无忧无虑的 每一天甜蜜 + [Chorus] + 马上放假了 快来庆祝 + 一起去旅行 快去冒险 + 日子太短暂 别再等待 + 马上放假了 梦想起飞 + """) + private String prompt; + + @Schema(description = "是否纯音乐", example = "true") + private Boolean makeInstrumental; + + @Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "chirp-v3.5") + @NotEmpty(message = "模型不能为空") + private String model; + + @Schema(description = "音乐风格", example = "[\"pop\",\"jazz\",\"punk\"]") + private List tags; + + @Schema(description = "音乐/歌曲名称", example = "夜空中最亮的星") + private String title; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/AiWorkflowController.http b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/AiWorkflowController.http new file mode 100644 index 0000000..8dc1b0c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/AiWorkflowController.http @@ -0,0 +1,12 @@ +### 测试 AI 工作流 +POST {{baseUrl}}/ai/workflow/test +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "id": 4, + "params": { + "message": "1 + 1 = ?" + } +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/AiWorkflowController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/AiWorkflowController.java new file mode 100644 index 0000000..b92ed9a --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/AiWorkflowController.java @@ -0,0 +1,77 @@ +package cn.aagro.pp.module.ai.controller.admin.workflow; + +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.workflow.vo.*; +import cn.aagro.pp.module.ai.dal.dataobject.workflow.AiWorkflowDO; +import cn.aagro.pp.module.ai.service.workflow.AiWorkflowService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - AI 工作流") +@RestController +@RequestMapping("/ai/workflow") +@Slf4j +public class AiWorkflowController { + + @Resource + private AiWorkflowService workflowService; + + @PostMapping("/create") + @Operation(summary = "创建 AI 工作流") + @PreAuthorize("@ss.hasPermission('ai:workflow:create')") + public CommonResult createWorkflow(@Valid @RequestBody AiWorkflowSaveReqVO createReqVO) { + return success(workflowService.createWorkflow(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 AI 工作流") + @PreAuthorize("@ss.hasPermission('ai:workflow:update')") + public CommonResult updateWorkflow(@Valid @RequestBody AiWorkflowSaveReqVO updateReqVO) { + workflowService.updateWorkflow(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 AI 工作流") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:workflow:delete')") + public CommonResult deleteWorkflow(@RequestParam("id") Long id) { + workflowService.deleteWorkflow(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 AI 工作流") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai:workflow:query')") + public CommonResult getWorkflow(@RequestParam("id") Long id) { + AiWorkflowDO workflow = workflowService.getWorkflow(id); + return success(BeanUtils.toBean(workflow, AiWorkflowRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得 AI 工作流分页") + @PreAuthorize("@ss.hasPermission('ai:workflow:query')") + public CommonResult> getWorkflowPage(@Valid AiWorkflowPageReqVO pageReqVO) { + PageResult pageResult = workflowService.getWorkflowPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiWorkflowRespVO.class)); + } + + @PostMapping("/test") + @Operation(summary = "测试 AI 工作流") + @PreAuthorize("@ss.hasPermission('ai:workflow:test')") + public CommonResult testWorkflow(@Valid @RequestBody AiWorkflowTestReqVO testReqVO) { + return success(workflowService.testWorkflow(testReqVO)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowPageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowPageReqVO.java new file mode 100644 index 0000000..9fe0b54 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowPageReqVO.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.module.ai.controller.admin.workflow.vo; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.PageParam; +import cn.aagro.pp.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 工作流分页 Request VO") +@Data +public class AiWorkflowPageReqVO extends PageParam { + + @Schema(description = "名称", example = "工作流") + private String name; + + @Schema(description = "标识", example = "FLOW") + private String code; + + @Schema(description = "状态", example = "1") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowRespVO.java new file mode 100644 index 0000000..3724b30 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowRespVO.java @@ -0,0 +1,33 @@ +package cn.aagro.pp.module.ai.controller.admin.workflow.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 工作流 Response VO") +@Data +public class AiWorkflowRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "工作流标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "FLOW") + private String code; + + @Schema(description = "工作流名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "工作流") + private String name; + + @Schema(description = "备注", requiredMode = Schema.RequiredMode.REQUIRED, example = "工作流") + private String remark; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "工作流模型 JSON", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") + private String graph; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式") + private LocalDateTime createTime; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowSaveReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowSaveReqVO.java new file mode 100644 index 0000000..cb88e9f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowSaveReqVO.java @@ -0,0 +1,34 @@ +package cn.aagro.pp.module.ai.controller.admin.workflow.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 工作流新增/修改 Request VO") +@Data +public class AiWorkflowSaveReqVO { + + @Schema(description = "编号", example = "1") + private Long id; + + @Schema(description = "工作流标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "FLOW") + @NotEmpty(message = "工作流标识不能为空") + private String code; + + @Schema(description = "工作流名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "工作流") + @NotEmpty(message = "工作流名称不能为空") + private String name; + + @Schema(description = "备注", example = "FLOW") + private String remark; + + @Schema(description = "工作流模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") + @NotEmpty(message = "工作流模型不能为空") + private String graph; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "FLOW") + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowTestReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowTestReqVO.java new file mode 100644 index 0000000..b68b0ef --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/workflow/vo/AiWorkflowTestReqVO.java @@ -0,0 +1,28 @@ +package cn.aagro.pp.module.ai.controller.admin.workflow.vo; + +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import lombok.Data; + +import java.util.Map; + +@Schema(description = "管理后台 - AI 工作流测试 Request VO") +@Data +public class AiWorkflowTestReqVO { + + @Schema(description = "工作流编号", example = "1024") + private Long id; + + @Schema(description = "工作流模型", example = "{}") + private String graph; + + @Schema(description = "参数", requiredMode = Schema.RequiredMode.REQUIRED, example = "{}") + private Map params; + + @AssertTrue(message = "工作流或模型,必须传递一个") + public boolean isGraphValid() { + return id != null || StrUtil.isNotEmpty(graph); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/AiWriteController.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/AiWriteController.java new file mode 100644 index 0000000..b6f992a --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/AiWriteController.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.module.ai.controller.admin.write; + +import cn.aagro.pp.framework.common.pojo.CommonResult; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.object.BeanUtils; +import cn.aagro.pp.module.ai.controller.admin.write.vo.AiWriteGenerateReqVO; +import cn.aagro.pp.module.ai.controller.admin.write.vo.AiWritePageReqVO; +import cn.aagro.pp.module.ai.controller.admin.write.vo.AiWriteRespVO; +import cn.aagro.pp.module.ai.dal.dataobject.write.AiWriteDO; +import cn.aagro.pp.module.ai.service.write.AiWriteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import static cn.aagro.pp.framework.common.pojo.CommonResult.success; +import static cn.aagro.pp.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI 写作") +@RestController +@RequestMapping("/ai/write") +public class AiWriteController { + + @Resource + private AiWriteService writeService; + + @PostMapping(value = "/generate-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "写作生成(流式)", description = "流式返回,响应较快") + public Flux> generateWriteContent(@RequestBody @Valid AiWriteGenerateReqVO generateReqVO) { + return writeService.generateWriteContent(generateReqVO, getLoginUserId()); + } + + // ================ 写作管理 ================ + + @DeleteMapping("/delete") + @Operation(summary = "删除写作") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai:write:delete')") + public CommonResult deleteWrite(@RequestParam("id") Long id) { + writeService.deleteWrite(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得写作分页") + @PreAuthorize("@ss.hasPermission('ai:write:query')") + public CommonResult> getWritePage(@Valid AiWritePageReqVO pageReqVO) { + PageResult pageResult = writeService.getWritePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, AiWriteRespVO.class)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWriteGenerateReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWriteGenerateReqVO.java new file mode 100644 index 0000000..841afd7 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWriteGenerateReqVO.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.module.ai.controller.admin.write.vo; + +import cn.aagro.pp.framework.common.validation.InEnum; +import cn.aagro.pp.module.ai.enums.write.AiWriteTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI 写作生成 Request VO") +@Data +public class AiWriteGenerateReqVO { + + @Schema(description = "写作类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @InEnum(value = AiWriteTypeEnum.class, message = "写作类型必须是 {value}") + private Integer type; + + @Schema(description = "写作内容提示", example = "1.撰写:田忌赛马;2.回复:不批") + private String prompt; + + @Schema(description = "原文", example = "领导我要辞职") + private String originalContent; + + @Schema(description = "长度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "长度不能为空") + private Integer length; + + @Schema(description = "格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "格式不能为空") + private Integer format; + + @Schema(description = "语气", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "语气不能为空") + private Integer tone; + + @Schema(description = "语言", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "语言不能为空") + private Integer language; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWritePageReqVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWritePageReqVO.java new file mode 100644 index 0000000..9ebc8f1 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWritePageReqVO.java @@ -0,0 +1,31 @@ +package cn.aagro.pp.module.ai.controller.admin.write.vo; + +import cn.aagro.pp.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.aagro.pp.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - AI 写作分页 Request VO") +@Data +public class AiWritePageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "28404") + private Long userId; + + @Schema(description = "写作类型", example = "1") + private Integer type; + + @Schema(description = "平台", example = "TongYi") + private String platform; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWriteRespVO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWriteRespVO.java new file mode 100644 index 0000000..7779fee --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/admin/write/vo/AiWriteRespVO.java @@ -0,0 +1,54 @@ +package cn.aagro.pp.module.ai.controller.admin.write.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI 写作 Response VO") +@Data +public class AiWriteRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5311") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "28404") + private Long userId; + + @Schema(description = "写作类型", example = "1") + private Integer type; + + @Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "TongYi") + private String platform; + + @Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "qwen") + private String model; + + @Schema(description = "生成内容提示", requiredMode = Schema.RequiredMode.REQUIRED, example = "撰写:田忌赛马") + private String prompt; + + @Schema(description = "生成的内容", example = "你非常不错") + private String generatedContent; + + @Schema(description = "原文", example = "真的么?") + private String originalContent; + + @Schema(description = "长度提示词", example = "1") + private Integer length; + + @Schema(description = "格式提示词", example = "2") + private Integer format; + + @Schema(description = "语气提示词", example = "3") + private Integer tone; + + @Schema(description = "语言提示词", example = "4") + private Integer language; + + @Schema(description = "错误信息") + private String errorMessage; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/app/package-info.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/app/package-info.java new file mode 100644 index 0000000..73d7a30 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/app/package-info.java @@ -0,0 +1,4 @@ +/** + * TODO 芋艿:站位,无特殊作用 + */ +package cn.aagro.pp.module.ai.controller.app; \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/package-info.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/package-info.java new file mode 100644 index 0000000..858a29f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/controller/package-info.java @@ -0,0 +1,6 @@ +/** + * 提供 RESTful API 给前端: + * 1. admin 包:提供给管理后台 aagro-ui-admin 前端项目 + * 2. app 包:提供给用户 APP aagro-ui-app 前端项目,它的 Controller 和 VO 都要添加 App 前缀,用于和管理后台进行区分 + */ +package cn.aagro.pp.module.ai.controller; diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/chat/AiChatConversationDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/chat/AiChatConversationDO.java new file mode 100644 index 0000000..08bf49f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/chat/AiChatConversationDO.java @@ -0,0 +1,100 @@ +package cn.aagro.pp.module.ai.dal.dataobject.chat; + +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiChatRoleDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * AI Chat 对话 DO + * + * 用户每次发起 Chat 聊天时,会创建一个 {@link AiChatConversationDO} 对象,将它的消息关联在一起 + * + * @author fansili + * @since 2024/4/14 17:35 + */ +@TableName("ai_chat_conversation") +@KeySequence("ai_chat_conversation_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiChatConversationDO extends BaseDO { + + public static final String TITLE_DEFAULT = "新对话"; + + /** + * ID 编号,自增 + */ + @TableId + private Long id; + + /** + * 用户编号 + * + * 关联 AdminUserDO 的 userId 字段 + */ + private Long userId; + + /** + * 对话标题 + * + * 默认由系统自动生成,可用户手动修改 + */ + private String title; + /** + * 是否置顶 + */ + private Boolean pinned; + /** + * 置顶时间 + */ + private LocalDateTime pinnedTime; + + /** + * 角色编号 + * + * 关联 {@link AiChatRoleDO#getId()} + */ + private Long roleId; + + /** + * 模型编号 + * + * 关联 {@link AiModelDO#getId()} 字段 + */ + private Long modelId; + /** + * 模型标志 + * + * 冗余 {@link AiModelDO#getModel()} 字段 + */ + private String model; + + // ========== 对话配置 ========== + + /** + * 角色设定 + */ + private String systemMessage; + /** + * 温度参数 + * + * 用于调整生成回复的随机性和多样性程度:较低的温度值会使输出更收敛于高频词汇,较高的则增加多样性 + */ + private Double temperature; + /** + * 单条回复的最大 Token 数量 + */ + private Integer maxTokens; + /** + * 上下文的最大 Message 数量 + */ + private Integer maxContexts; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/chat/AiChatMessageDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/chat/AiChatMessageDO.java new file mode 100644 index 0000000..2193b00 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/chat/AiChatMessageDO.java @@ -0,0 +1,126 @@ +package cn.aagro.pp.module.ai.dal.dataobject.chat; + +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.framework.mybatis.core.type.LongListTypeHandler; +import cn.aagro.pp.framework.mybatis.core.type.StringListTypeHandler; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiChatRoleDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import cn.aagro.pp.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.ai.chat.messages.MessageType; + +import java.util.List; + +/** + * AI Chat 消息 DO + * + * @since 2024/4/14 17:35 + * @since 2024/4/14 17:35 + */ +@TableName(value = "ai_chat_message", autoResultMap = true) +@KeySequence("ai_chat_conversation_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiChatMessageDO extends BaseDO { + + /** + * 编号,作为每条聊天记录的唯一标识符 + */ + @TableId + private Long id; + + /** + * 对话编号 + * + * 关联 {@link AiChatConversationDO#getId()} 字段 + */ + private Long conversationId; + /** + * 回复消息编号 + * + * 关联 {@link #id} 字段 + * + * 大模型回复的消息编号,用于“问答”的关联 + */ + private Long replyId; + + /** + * 消息类型 + * + * 也等价于 OpenAPI 的 role 字段 + * + * 枚举 {@link MessageType} + */ + private String type; + /** + * 用户编号 + * + * 关联 AdminUserDO 的 userId 字段 + */ + private Long userId; + /** + * 角色编号 + * + * 关联 {@link AiChatRoleDO#getId()} 字段 + */ + private Long roleId; + + /** + * 模型标志 + * + * 冗余 {@link AiModelDO#getModel()} + */ + private String model; + /** + * 模型编号 + * + * 关联 {@link AiModelDO#getId()} 字段 + */ + private Long modelId; + + /** + * 聊天内容 + */ + private String content; + /** + * 推理内容 + */ + private String reasoningContent; + + /** + * 是否携带上下文 + */ + private Boolean useContext; + + /** + * 知识库段落编号数组 + * + * 关联 {@link AiKnowledgeSegmentDO#getId()} 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List segmentIds; + + /** + * 联网搜索的网页内容数组 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List webSearchPages; + + /** + * 附件 URL 数组 + */ + @TableField(typeHandler = StringListTypeHandler.class) + private List attachmentUrls; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/image/AiImageDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/image/AiImageDO.java new file mode 100644 index 0000000..140d193 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/image/AiImageDO.java @@ -0,0 +1,127 @@ +package cn.aagro.pp.module.ai.dal.dataobject.image; + +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import cn.aagro.pp.module.ai.enums.image.AiImageStatusEnum; +import cn.aagro.pp.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.ai.stabilityai.api.StabilityAiImageOptions; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * AI 绘画 DO + * + * @author fansili + */ +@TableName(value = "ai_image", autoResultMap = true) +@KeySequence("ai_image_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiImageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + + /** + * 用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + + /** + * 提示词 + */ + private String prompt; + + /** + * 平台 + * + * 枚举 {@link AiPlatformEnum} + */ + private String platform; + /** + * 模型编号 + * + * 关联 {@link AiModelDO#getId()} + */ + private Long modelId; + /** + * 模型标识 + * + * 冗余 {@link AiModelDO#getModel()} + */ + private String model; + + /** + * 图片宽度 + */ + private Integer width; + /** + * 图片高度 + */ + private Integer height; + + /** + * 生成状态 + * + * 枚举 {@link AiImageStatusEnum} + */ + private Integer status; + + /** + * 完成时间 + */ + private LocalDateTime finishTime; + + /** + * 绘画错误信息 + */ + private String errorMessage; + + /** + * 图片地址 + */ + private String picUrl; + /** + * 是否公开 + */ + private Boolean publicStatus; + + /** + * 绘制参数,不同 platform 的不同参数 + * + * 1. {@link OpenAiImageOptions} + * 2. {@link StabilityAiImageOptions} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map options; + + /** + * mj buttons 按钮 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List buttons; + + /** + * 任务编号 + * + * 1. midjourney proxy:关联的 task id + */ + private String taskId; + +} + diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeDO.java new file mode 100644 index 0000000..81037f8 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeDO.java @@ -0,0 +1,64 @@ +package cn.aagro.pp.module.ai.dal.dataobject.knowledge; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * AI 知识库 DO + * + * @author xiaoxin + */ +@TableName(value = "ai_knowledge", autoResultMap = true) +@KeySequence("ai_knowledge_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiKnowledgeDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 知识库名称 + */ + private String name; + /** + * 知识库描述 + */ + private String description; + + /** + * 向量模型编号 + * + * 关联 {@link AiModelDO#getId()} + */ + private Long embeddingModelId; + /** + * 模型标识 + * + * 冗余 {@link AiModelDO#getModel()} + */ + private String embeddingModel; + + /** + * topK + */ + private Integer topK; + /** + * 相似度阈值 + */ + private Double similarityThreshold; + + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeDocumentDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeDocumentDO.java new file mode 100644 index 0000000..61e4b63 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeDocumentDO.java @@ -0,0 +1,69 @@ +package cn.aagro.pp.module.ai.dal.dataobject.knowledge; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * AI 知识库-文档 DO + * + * @author xiaoxin + */ +@TableName(value = "ai_knowledge_document") +@KeySequence("ai_knowledge_document_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiKnowledgeDocumentDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + *

+ * 关联 {@link AiKnowledgeDO#getId()} + */ + private Long knowledgeId; + /** + * 文档名称 + */ + private String name; + /** + * 文件 URL + */ + private String url; + /** + * 内容 + */ + private String content; + /** + * 文档长度 + */ + private Integer contentLength; + + /** + * 文档 token 数量 + */ + private Integer tokens; + /** + * 分片最大 Token 数 + */ + private Integer segmentMaxTokens; + + /** + * 召回次数 + */ + private Integer retrievalCount; + + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeSegmentDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeSegmentDO.java new file mode 100644 index 0000000..4abd2a0 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/knowledge/AiKnowledgeSegmentDO.java @@ -0,0 +1,72 @@ +package cn.aagro.pp.module.ai.dal.dataobject.knowledge; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * AI 知识库-文档分段 DO + * + * @author xiaoxin + */ +@TableName(value = "ai_knowledge_segment") +@KeySequence("ai_knowledge_segment_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiKnowledgeSegmentDO extends BaseDO { + + /** + * 向量库的编号 - 空值 + */ + public static final String VECTOR_ID_EMPTY = ""; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + *

+ * 关联 {@link AiKnowledgeDO#getId()} + */ + private Long knowledgeId; + /** + * 文档编号 + *

+ * 关联 {@link AiKnowledgeDocumentDO#getId()} + */ + private Long documentId; + /** + * 切片内容 + */ + private String content; + /** + * 切片内容长度 + */ + private Integer contentLength; + + /** + * 向量库的编号 + */ + private String vectorId; + /** + * token 数量 + */ + private Integer tokens; + + /** + * 召回次数 + */ + private Integer retrievalCount; + + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/mindmap/AiMindMapDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/mindmap/AiMindMapDO.java new file mode 100644 index 0000000..cc7453d --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/mindmap/AiMindMapDO.java @@ -0,0 +1,66 @@ +package cn.aagro.pp.module.ai.dal.dataobject.mindmap; + +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * AI 思维导图 DO + * + * @author xiaoxin + */ +@TableName(value = "ai_mind_map") +@KeySequence("ai_mind_map_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiMindMapDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + + /** + * 用户编号 + *

+ * 关联 AdminUserDO 的 userId 字段 + */ + private Long userId; + + /** + * 平台 + *

+ * 枚举 {@link AiPlatformEnum} + */ + private String platform; + /** + * 模型编号 + * + * 关联 {@link AiModelDO#getId()} + */ + private Long modelId; + /** + * 模型 + */ + private String model; + + /** + * 生成内容提示 + */ + private String prompt; + + /** + * 生成的内容 + */ + private String generatedContent; + + /** + * 错误信息 + */ + private String errorMessage; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiApiKeyDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiApiKeyDO.java new file mode 100644 index 0000000..7423867 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiApiKeyDO.java @@ -0,0 +1,54 @@ +package cn.aagro.pp.module.ai.dal.dataobject.model; + +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI API 秘钥 DO + * + * @author 芋道源码 + */ +@TableName("ai_api_key") +@KeySequence("ai_chat_conversation_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiApiKeyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 密钥 + */ + private String apiKey; + /** + * 平台 + * + * 枚举 {@link AiPlatformEnum} + */ + private String platform; + /** + * API 地址 + */ + private String url; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiChatRoleDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiChatRoleDO.java new file mode 100644 index 0000000..2da12a5 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiChatRoleDO.java @@ -0,0 +1,111 @@ +package cn.aagro.pp.module.ai.dal.dataobject.model; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.framework.mybatis.core.type.LongListTypeHandler; +import cn.aagro.pp.framework.mybatis.core.type.StringListTypeHandler; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.util.List; + +/** + * AI 聊天角色 DO + * + * @author fansili + * @since 2024/4/24 19:39 + */ +@TableName(value = "ai_chat_role", autoResultMap = true) +@KeySequence("ai_chat_role_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiChatRoleDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 角色名称 + */ + private String name; + /** + * 角色头像 + */ + private String avatar; + /** + * 角色分类 + */ + private String category; + /** + * 角色描述 + */ + private String description; + /** + * 角色设定 + */ + private String systemMessage; + + /** + * 用户编号 + * + * 关联 AdminUserDO 的 userId 字段 + */ + private Long userId; + + /** + * 模型编号 + * + * 关联 {@link AiModelDO#getId()} 字段 + */ + private Long modelId; + + /** + * 引用的知识库编号列表 + * + * 关联 {@link AiKnowledgeDO#getId()} 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List knowledgeIds; + /** + * 引用的工具编号列表 + * + * 关联 {@link AiToolDO#getId()} 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List toolIds; + /** + * 引用的 MCP Client 名字列表 + * + * 关联 spring.ai.mcp.client 下的名字 + */ + @TableField(typeHandler = StringListTypeHandler.class) + private List mcpClientNames; + + /** + * 是否公开 + * + * 1. true - 公开;由管理员在【角色管理】所创建 + * 2. false - 私有;由个人在【我的角色】所创建 + */ + private Boolean publicStatus; + + /** + * 排序值 + */ + private Integer sort; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiModelDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiModelDO.java new file mode 100644 index 0000000..1785e48 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiModelDO.java @@ -0,0 +1,88 @@ +package cn.aagro.pp.module.ai.dal.dataobject.model; + +import cn.aagro.pp.module.ai.enums.model.AiModelTypeEnum; +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI 模型 DO + * + * 默认模型:{@link #status} 为开启,并且 {@link #sort} 排序第一 + * + * @author fansili + * @since 2024/4/24 19:39 + */ +@TableName("ai_model") +@KeySequence("ai_model_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiModelDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * API 秘钥编号 + * + * 关联 {@link AiApiKeyDO#getId()} + */ + private Long keyId; + /** + * 模型名称 + */ + private String name; + /** + * 模型标志 + */ + private String model; + /** + * 平台 + * + * 枚举 {@link AiPlatformEnum} + */ + private String platform; + /** + * 类型 + * + * 枚举 {@link AiModelTypeEnum} + */ + private Integer type; + + /** + * 排序值 + */ + private Integer sort; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + + // ========== 对话配置 ========== + + /** + * 温度参数 + * + * 用于调整生成回复的随机性和多样性程度:较低的温度值会使输出更收敛于高频词汇,较高的则增加多样性 + */ + private Double temperature; + /** + * 单条回复的最大 Token 数量 + */ + private Integer maxTokens; + /** + * 上下文的最大 Message 数量 + */ + private Integer maxContexts; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiToolDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiToolDO.java new file mode 100644 index 0000000..07be16b --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/model/AiToolDO.java @@ -0,0 +1,48 @@ +package cn.aagro.pp.module.ai.dal.dataobject.model; + +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.tool.function.DirectoryListToolFunction; +import cn.aagro.pp.module.ai.tool.function.WeatherQueryToolFunction; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI 工具 DO + * + * @author 芋道源码 + */ +@TableName("ai_tool") +@KeySequence("ai_tool_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiToolDO extends BaseDO { + + /** + * 工具编号 + */ + @TableId + private Long id; + /** + * 工具名称 + * + * 对应 Bean 的名字,例如说: + * 1. {@link DirectoryListToolFunction} 的 Bean 名字是 directory_list + * 2. {@link WeatherQueryToolFunction} 的 Bean 名字是 weather_query + */ + private String name; + /** + * 工具描述 + */ + private String description; + /** + * 状态 + * + * 枚举 {@link cn.aagro.pp.framework.common.enums.CommonStatusEnum} + */ + private Integer status; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/music/AiMusicDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/music/AiMusicDO.java new file mode 100644 index 0000000..2d10f2c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/music/AiMusicDO.java @@ -0,0 +1,119 @@ +package cn.aagro.pp.module.ai.dal.dataobject.music; + +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.enums.music.AiMusicGenerateModeEnum; +import cn.aagro.pp.module.ai.enums.music.AiMusicStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; + +import java.util.List; + +/** + * AI 音乐 DO + * + * @author xiaoxin + */ +@TableName(value = "ai_music", autoResultMap = true) +@KeySequence("ai_music_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiMusicDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + + /** + * 用户编号 + *

+ * 关联 AdminUserDO 的 userId 字段 + */ + private Long userId; + + /** + * 音乐名称 + */ + private String title; + + /** + * 歌词 + */ + private String lyric; + + /** + * 图片地址 + */ + private String imageUrl; + /** + * 音频地址 + */ + private String audioUrl; + /** + * 视频地址 + */ + private String videoUrl; + + /** + * 音乐状态 + *

+ * 枚举 {@link AiMusicStatusEnum} + */ + private Integer status; + + /** + * 生成模式 + *

+ * 枚举 {@link AiMusicGenerateModeEnum} + */ + private Integer generateMode; + + /** + * 描述词 + */ + private String description; + + /** + * 平台 + *

+ * 枚举 {@link AiPlatformEnum} + */ + private String platform; + // TODO @芋艿:modelId? + /** + * 模型 + */ + private String model; + + /** + * 音乐风格标签 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List tags; + + /** + * 音乐时长 + */ + private Double duration; + + /** + * 是否公开 + */ + private Boolean publicStatus; + + /** + * 任务编号 + */ + private String taskId; + + /** + * 错误信息 + */ + private String errorMessage; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/workflow/AiWorkflowDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/workflow/AiWorkflowDO.java new file mode 100644 index 0000000..d989609 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/workflow/AiWorkflowDO.java @@ -0,0 +1,51 @@ +package cn.aagro.pp.module.ai.dal.dataobject.workflow; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * AI 工作流 DO + * + * @author lesan + */ +@TableName(value = "ai_workflow", autoResultMap = true) +@KeySequence("ai_workflow") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiWorkflowDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 工作流名称 + */ + private String name; + /** + * 工作流标识 + */ + private String code; + + /** + * 工作流模型 JSON 数据 + */ + private String graph; + + /** + * 备注 + */ + private String remark; + + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/write/AiWriteDO.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/write/AiWriteDO.java new file mode 100644 index 0000000..1bc4e6b --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/dataobject/write/AiWriteDO.java @@ -0,0 +1,104 @@ +package cn.aagro.pp.module.ai.dal.dataobject.write; + +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.framework.mybatis.core.dataobject.BaseDO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import cn.aagro.pp.module.ai.enums.DictTypeConstants; +import cn.aagro.pp.module.ai.enums.write.AiWriteTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +/** + * AI 写作 DO + * + * @author xiaoxin + */ +@TableName("ai_write") +@KeySequence("ai_write_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +public class AiWriteDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + + /** + * 用户编号 + * + * 关联 AdminUserDO 的 userId 字段 + */ + private Long userId; + + /** + * 写作类型 + *

+ * 枚举 {@link AiWriteTypeEnum} + */ + private Integer type; + + /** + * 平台 + * + * 枚举 {@link AiPlatformEnum} + */ + private String platform; + /** + * 模型编号 + * + * 关联 {@link AiModelDO#getId()} + */ + private Long modelId; + /** + * 模型 + */ + private String model; + + /** + * 生成内容提示 + */ + private String prompt; + + /** + * 生成的内容 + */ + private String generatedContent; + /** + * 原文 + */ + private String originalContent; + + /** + * 长度提示词 + * + * 字典:{@link DictTypeConstants#AI_WRITE_LENGTH} + */ + private Integer length; + /** + * 格式提示词 + * + * 字典:{@link DictTypeConstants#AI_WRITE_FORMAT} + */ + private Integer format; + /** + * 语气提示词 + * + * 字典:{@link DictTypeConstants#AI_WRITE_TONE} + */ + private Integer tone; + /** + * 语言提示词 + * + * 字典:{@link DictTypeConstants#AI_WRITE_LANGUAGE} + */ + private Integer language; + + /** + * 错误信息 + */ + private String errorMessage; + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/chat/AiChatConversationMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/chat/AiChatConversationMapper.java new file mode 100644 index 0000000..cdfee29 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/chat/AiChatConversationMapper.java @@ -0,0 +1,38 @@ +package cn.aagro.pp.module.ai.dal.mysql.chat; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation.AiChatConversationPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.chat.AiChatConversationDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 聊天对话 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface AiChatConversationMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(AiChatConversationDO::getUserId, userId); + } + + default List selectListByUserIdAndPinned(Long userId, boolean pinned) { + return selectList(new LambdaQueryWrapperX() + .eq(AiChatConversationDO::getUserId, userId) + .eq(AiChatConversationDO::getPinned, pinned)); + } + + default PageResult selectChatConversationPage(AiChatConversationPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiChatConversationDO::getUserId, pageReqVO.getUserId()) + .likeIfPresent(AiChatConversationDO::getTitle, pageReqVO.getTitle()) + .betweenIfPresent(AiChatConversationDO::getCreateTime, pageReqVO.getCreateTime()) + .orderByDesc(AiChatConversationDO::getId)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/chat/AiChatMessageMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/chat/AiChatMessageMapper.java new file mode 100644 index 0000000..0e780f7 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/chat/AiChatMessageMapper.java @@ -0,0 +1,59 @@ +package cn.aagro.pp.module.ai.dal.mysql.chat; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.common.util.collection.CollectionUtils; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.conversation.AiChatConversationPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.chat.vo.message.AiChatMessagePageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.chat.AiChatConversationDO; +import cn.aagro.pp.module.ai.dal.dataobject.chat.AiChatMessageDO; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * AI 聊天对话 Mapper + * + * @author fansili + */ +@Mapper +public interface AiChatMessageMapper extends BaseMapperX { + + default List selectListByConversationId(Long conversationId) { + return selectList(new LambdaQueryWrapperX() + .eq(AiChatMessageDO::getConversationId, conversationId) + .orderByAsc(AiChatMessageDO::getId)); + } + + default Map selectCountMapByConversationId(Collection conversationIds) { + // SQL count 查询 + List> result = selectMaps(new QueryWrapper() + .select("COUNT(id) AS count, conversation_id AS conversationId") + .in("conversation_id", conversationIds) + .groupBy("conversation_id")); + if (CollUtil.isEmpty(result)) { + return Collections.emptyMap(); + } + // 转换数据 + return CollectionUtils.convertMap(result, + record -> MapUtil.getLong(record, "conversationId"), + record -> MapUtil.getInt(record, "count" )); + } + + default PageResult selectPage(AiChatMessagePageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiChatMessageDO::getConversationId, pageReqVO.getConversationId()) + .eqIfPresent(AiChatMessageDO::getUserId, pageReqVO.getUserId()) + .likeIfPresent(AiChatMessageDO::getContent, pageReqVO.getContent()) + .betweenIfPresent(AiChatMessageDO::getCreateTime, pageReqVO.getCreateTime()) + .orderByDesc(AiChatMessageDO::getId)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/image/AiImageMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/image/AiImageMapper.java new file mode 100644 index 0000000..ce7d2d7 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/image/AiImageMapper.java @@ -0,0 +1,57 @@ +package cn.aagro.pp.module.ai.dal.mysql.image; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.image.vo.AiImagePageReqVO; +import cn.aagro.pp.module.ai.controller.admin.image.vo.AiImagePublicPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.image.AiImageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 绘图 Mapper + * + * @author fansili + */ +@Mapper +public interface AiImageMapper extends BaseMapperX { + + default AiImageDO selectByTaskId(String taskId) { + return selectOne(AiImageDO::getTaskId, taskId); + } + + default PageResult selectPage(AiImagePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiImageDO::getUserId, reqVO.getUserId()) + .eqIfPresent(AiImageDO::getPlatform, reqVO.getPlatform()) + .eqIfPresent(AiImageDO::getStatus, reqVO.getStatus()) + .eqIfPresent(AiImageDO::getPublicStatus, reqVO.getPublicStatus()) + .betweenIfPresent(AiImageDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(AiImageDO::getId)); + } + + default PageResult selectPageMy(Long userId, AiImagePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiImageDO::getPrompt, reqVO.getPrompt()) + // 情况一:公开 + .eq(Boolean.TRUE.equals(reqVO.getPublicStatus()), AiImageDO::getPublicStatus, reqVO.getPublicStatus()) + // 情况二:私有 + .eq(Boolean.FALSE.equals(reqVO.getPublicStatus()), AiImageDO::getUserId, userId) + .orderByDesc(AiImageDO::getId)); + } + + default PageResult selectPage(AiImagePublicPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiImageDO::getPublicStatus, Boolean.TRUE) + .likeIfPresent(AiImageDO::getPrompt, pageReqVO.getPrompt()) + .orderByDesc(AiImageDO::getId)); + } + + default List selectListByStatusAndPlatform(Integer status, String platform) { + return selectList(AiImageDO::getStatus, status, + AiImageDO::getPlatform, platform); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeDocumentMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeDocumentMapper.java new file mode 100644 index 0000000..af8984d --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeDocumentMapper.java @@ -0,0 +1,43 @@ +package cn.aagro.pp.module.ai.dal.mysql.knowledge; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.document.AiKnowledgeDocumentPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI 知识库文档 Mapper + * + * @author xiaoxin + */ +@Mapper +public interface AiKnowledgeDocumentMapper extends BaseMapperX { + + default PageResult selectPage(AiKnowledgeDocumentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiKnowledgeDocumentDO::getKnowledgeId, reqVO.getKnowledgeId()) + .likeIfPresent(AiKnowledgeDocumentDO::getName, reqVO.getName()) + .orderByDesc(AiKnowledgeDocumentDO::getId)); + } + + default void updateRetrievalCountIncr(Collection ids) { + update(new LambdaUpdateWrapper() + .setSql(" retrieval_count = retrieval_count + 1") + .in(AiKnowledgeDocumentDO::getId, ids)); + } + + default List selectListByStatus(Integer status) { + return selectList(AiKnowledgeDocumentDO::getStatus, status); + } + + default List selectListByKnowledgeId(Long knowledgeId) { + return selectList(AiKnowledgeDocumentDO::getKnowledgeId, knowledgeId); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeMapper.java new file mode 100644 index 0000000..b8b763c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeMapper.java @@ -0,0 +1,32 @@ +package cn.aagro.pp.module.ai.dal.mysql.knowledge; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.knowledge.AiKnowledgePageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 知识库 Mapper + * + * @author xiaoxin + */ +@Mapper +public interface AiKnowledgeMapper extends BaseMapperX { + + default PageResult selectPage(AiKnowledgePageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiKnowledgeDO::getName, pageReqVO.getName()) + .eqIfPresent(AiKnowledgeDO::getStatus, pageReqVO.getStatus()) + .betweenIfPresent(AiKnowledgeDO::getCreateTime, pageReqVO.getCreateTime()) + .orderByDesc(AiKnowledgeDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(AiKnowledgeDO::getStatus, status); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeSegmentMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeSegmentMapper.java new file mode 100644 index 0000000..489e1fa --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/knowledge/AiKnowledgeSegmentMapper.java @@ -0,0 +1,67 @@ +package cn.aagro.pp.module.ai.dal.mysql.knowledge; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment.AiKnowledgeSegmentPageReqVO; +import cn.aagro.pp.module.ai.controller.admin.knowledge.vo.segment.AiKnowledgeSegmentProcessRespVO; +import cn.aagro.pp.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI 知识库分片 Mapper + * + * @author xiaoxin + */ +@Mapper +public interface AiKnowledgeSegmentMapper extends BaseMapperX { + + default PageResult selectPage(AiKnowledgeSegmentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(AiKnowledgeSegmentDO::getDocumentId, reqVO.getDocumentId()) + .likeIfPresent(AiKnowledgeSegmentDO::getContent, reqVO.getContent()) + .eqIfPresent(AiKnowledgeSegmentDO::getStatus, reqVO.getStatus()) + .orderByDesc(AiKnowledgeSegmentDO::getId)); + } + + default List selectListByVectorIds(List vectorIds) { + return selectList(new LambdaQueryWrapperX() + .in(AiKnowledgeSegmentDO::getVectorId, vectorIds) + .orderByDesc(AiKnowledgeSegmentDO::getId)); + } + + default List selectListByDocumentId(Long documentId) { + return selectList(new LambdaQueryWrapperX() + .eq(AiKnowledgeSegmentDO::getDocumentId, documentId) + .orderByDesc(AiKnowledgeSegmentDO::getId)); + } + + default List selectListByKnowledgeIdAndStatus(Long knowledgeId, Integer status) { + return selectList(AiKnowledgeSegmentDO::getKnowledgeId, knowledgeId, + AiKnowledgeSegmentDO::getStatus, status); + } + + default List selectProcessList(Collection documentIds) { + MPJLambdaWrapper wrapper = new MPJLambdaWrapperX() + .selectAs(AiKnowledgeSegmentDO::getDocumentId, AiKnowledgeSegmentProcessRespVO::getDocumentId) + .selectCount(AiKnowledgeSegmentDO::getId, "count") + .select("COUNT(CASE WHEN vector_id > '" + AiKnowledgeSegmentDO.VECTOR_ID_EMPTY + + "' THEN 1 ELSE NULL END) AS embeddingCount") + .in(AiKnowledgeSegmentDO::getDocumentId, documentIds) + .groupBy(AiKnowledgeSegmentDO::getDocumentId); + return selectJoinList(AiKnowledgeSegmentProcessRespVO.class, wrapper); + } + + default void updateRetrievalCountIncrByIds(List ids) { + update(new LambdaUpdateWrapper() + .setSql(" retrieval_count = retrieval_count + 1") + .in(AiKnowledgeSegmentDO::getId, ids)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/mindmap/AiMindMapMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/mindmap/AiMindMapMapper.java new file mode 100644 index 0000000..c218679 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/mindmap/AiMindMapMapper.java @@ -0,0 +1,26 @@ +package cn.aagro.pp.module.ai.dal.mysql.mindmap; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.mindmap.vo.AiMindMapPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.mindmap.AiMindMapDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * AI 思维导图 Mapper + * + * @author xiaoxin + */ +@Mapper +public interface AiMindMapMapper extends BaseMapperX { + + default PageResult selectPage(AiMindMapPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiMindMapDO::getUserId, reqVO.getUserId()) + .eqIfPresent(AiMindMapDO::getPrompt, reqVO.getPrompt()) + .betweenIfPresent(AiMindMapDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(AiMindMapDO::getId)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiApiKeyMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiApiKeyMapper.java new file mode 100644 index 0000000..2452dbb --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiApiKeyMapper.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.module.ai.dal.mysql.model; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.framework.mybatis.core.query.QueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.model.vo.apikey.AiApiKeyPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiApiKeyDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * AI API 密钥 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface AiApiKeyMapper extends BaseMapperX { + + default PageResult selectPage(AiApiKeyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiApiKeyDO::getName, reqVO.getName()) + .eqIfPresent(AiApiKeyDO::getPlatform, reqVO.getPlatform()) + .eqIfPresent(AiApiKeyDO::getStatus, reqVO.getStatus()) + .orderByDesc(AiApiKeyDO::getId)); + } + + default AiApiKeyDO selectFirstByPlatformAndStatus(String platform, Integer status) { + return selectOne(new QueryWrapperX() + .eq("platform", platform) + .eq("status", status) + .limitN(1) + .orderByAsc("id")); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiChatMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiChatMapper.java new file mode 100644 index 0000000..1e2bd54 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiChatMapper.java @@ -0,0 +1,47 @@ +package cn.aagro.pp.module.ai.dal.mysql.model; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.framework.mybatis.core.query.QueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.model.vo.model.AiModelPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiModelDO; +import org.apache.ibatis.annotations.Mapper; + +import javax.annotation.Nullable; +import java.util.List; + +/** + * API 模型 Mapper + * + * @author fansili + */ +@Mapper +public interface AiChatMapper extends BaseMapperX { + + default AiModelDO selectFirstByStatus(Integer type, Integer status) { + return selectOne(new QueryWrapperX() + .eq("type", type) + .eq("status", status) + .limitN(1) + .orderByAsc("sort")); + } + + default PageResult selectPage(AiModelPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiModelDO::getName, reqVO.getName()) + .eqIfPresent(AiModelDO::getModel, reqVO.getModel()) + .eqIfPresent(AiModelDO::getPlatform, reqVO.getPlatform()) + .orderByAsc(AiModelDO::getSort)); + } + + default List selectListByStatusAndType(Integer status, Integer type, + @Nullable String platform) { + return selectList(new LambdaQueryWrapperX() + .eq(AiModelDO::getStatus, status) + .eq(AiModelDO::getType, type) + .eqIfPresent(AiModelDO::getPlatform, platform) + .orderByAsc(AiModelDO::getSort)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiChatRoleMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiChatRoleMapper.java new file mode 100644 index 0000000..6916623 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiChatRoleMapper.java @@ -0,0 +1,54 @@ +package cn.aagro.pp.module.ai.dal.mysql.model; + +import cn.aagro.pp.framework.common.enums.CommonStatusEnum; +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.model.vo.chatRole.AiChatRolePageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiChatRoleDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 聊天角色 Mapper + * + * @author fansili + */ +@Mapper +public interface AiChatRoleMapper extends BaseMapperX { + + default PageResult selectPage(AiChatRolePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiChatRoleDO::getName, reqVO.getName()) + .eqIfPresent(AiChatRoleDO::getCategory, reqVO.getCategory()) + .eqIfPresent(AiChatRoleDO::getPublicStatus, reqVO.getPublicStatus()) + .orderByAsc(AiChatRoleDO::getSort)); + } + + default PageResult selectPageByMy(AiChatRolePageReqVO reqVO, Long userId) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiChatRoleDO::getName, reqVO.getName()) + .eqIfPresent(AiChatRoleDO::getCategory, reqVO.getCategory()) + // 情况一:公开 + .eq(Boolean.TRUE.equals(reqVO.getPublicStatus()), AiChatRoleDO::getPublicStatus, reqVO.getPublicStatus()) + // 情况二:私有 + .eq(Boolean.FALSE.equals(reqVO.getPublicStatus()), AiChatRoleDO::getUserId, userId) + .eq(Boolean.FALSE.equals(reqVO.getPublicStatus()), AiChatRoleDO::getStatus, CommonStatusEnum.ENABLE.getStatus()) + .orderByAsc(AiChatRoleDO::getSort)); + } + + default List selectListGroupByCategory(Integer status) { + return selectList(new LambdaQueryWrapperX() + .select(AiChatRoleDO::getCategory) + .eq(AiChatRoleDO::getStatus, status) + .groupBy(AiChatRoleDO::getCategory)); + } + + default List selectListByName(String name) { + return selectList(new LambdaQueryWrapperX() + .likeIfPresent(AiChatRoleDO::getName, name) + .orderByAsc(AiChatRoleDO::getSort)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiToolMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiToolMapper.java new file mode 100644 index 0000000..6f6afb1 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/model/AiToolMapper.java @@ -0,0 +1,35 @@ +package cn.aagro.pp.module.ai.dal.mysql.model; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.model.vo.tool.AiToolPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.model.AiToolDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 工具 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface AiToolMapper extends BaseMapperX { + + default PageResult selectPage(AiToolPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(AiToolDO::getName, reqVO.getName()) + .eqIfPresent(AiToolDO::getDescription, reqVO.getDescription()) + .eqIfPresent(AiToolDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(AiToolDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(AiToolDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(AiToolDO::getStatus, status) + .orderByDesc(AiToolDO::getId)); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/music/AiMusicMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/music/AiMusicMapper.java new file mode 100644 index 0000000..e753042 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/music/AiMusicMapper.java @@ -0,0 +1,44 @@ +package cn.aagro.pp.module.ai.dal.mysql.music; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.music.vo.AiMusicPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.music.AiMusicDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 音乐 Mapper + * + * @author xiaoxin + */ +@Mapper +public interface AiMusicMapper extends BaseMapperX { + + default List selectListByStatus(Integer status) { + return selectList(AiMusicDO::getStatus, status); + } + + default PageResult selectPage(AiMusicPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiMusicDO::getUserId, reqVO.getUserId()) + .eqIfPresent(AiMusicDO::getTitle, reqVO.getTitle()) + .eqIfPresent(AiMusicDO::getStatus, reqVO.getStatus()) + .eqIfPresent(AiMusicDO::getGenerateMode, reqVO.getGenerateMode()) + .betweenIfPresent(AiMusicDO::getCreateTime, reqVO.getCreateTime()) + .eqIfPresent(AiMusicDO::getPublicStatus, reqVO.getPublicStatus()) + .orderByDesc(AiMusicDO::getId)); + } + + default PageResult selectPageByMy(AiMusicPageReqVO reqVO, Long userId) { + return selectPage(reqVO, new LambdaQueryWrapperX() + // 情况一:公开 + .eq(Boolean.TRUE.equals(reqVO.getPublicStatus()), AiMusicDO::getPublicStatus, reqVO.getPublicStatus()) + // 情况二:私有 + .eq(Boolean.FALSE.equals(reqVO.getPublicStatus()), AiMusicDO::getUserId, userId) + .orderByAsc(AiMusicDO::getId)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/workflow/AiWorkflowMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/workflow/AiWorkflowMapper.java new file mode 100644 index 0000000..97d5862 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/workflow/AiWorkflowMapper.java @@ -0,0 +1,30 @@ +package cn.aagro.pp.module.ai.dal.mysql.workflow; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.workflow.vo.AiWorkflowPageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.workflow.AiWorkflowDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * AI 工作流 Mapper + * + * @author lesan + */ +@Mapper +public interface AiWorkflowMapper extends BaseMapperX { + + default AiWorkflowDO selectByCode(String code) { + return selectOne(AiWorkflowDO::getCode, code); + } + + default PageResult selectPage(AiWorkflowPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiWorkflowDO::getStatus, pageReqVO.getStatus()) + .likeIfPresent(AiWorkflowDO::getName, pageReqVO.getName()) + .likeIfPresent(AiWorkflowDO::getCode, pageReqVO.getCode()) + .betweenIfPresent(AiWorkflowDO::getCreateTime, pageReqVO.getCreateTime())); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/write/AiWriteMapper.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/write/AiWriteMapper.java new file mode 100644 index 0000000..85bf98d --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/dal/mysql/write/AiWriteMapper.java @@ -0,0 +1,27 @@ +package cn.aagro.pp.module.ai.dal.mysql.write; + +import cn.aagro.pp.framework.common.pojo.PageResult; +import cn.aagro.pp.framework.mybatis.core.mapper.BaseMapperX; +import cn.aagro.pp.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.aagro.pp.module.ai.controller.admin.write.vo.AiWritePageReqVO; +import cn.aagro.pp.module.ai.dal.dataobject.write.AiWriteDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * AI 写作 Mapper + * + * @author xiaoxin + */ +@Mapper +public interface AiWriteMapper extends BaseMapperX { + + default PageResult selectPage(AiWritePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiWriteDO::getUserId, reqVO.getUserId()) + .eqIfPresent(AiWriteDO::getType, reqVO.getType()) + .eqIfPresent(AiWriteDO::getPlatform, reqVO.getPlatform()) + .betweenIfPresent(AiWriteDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(AiWriteDO::getId)); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/AiChatRoleEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/AiChatRoleEnum.java new file mode 100644 index 0000000..036fb1f --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/AiChatRoleEnum.java @@ -0,0 +1,50 @@ +package cn.aagro.pp.module.ai.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * AI 内置聊天角色的枚举 + * + * @author xiaoxin + */ +@AllArgsConstructor +@Getter +public enum AiChatRoleEnum { + + AI_WRITE_ROLE("写作助手", """ + 你是一位出色的写作助手,能够帮助用户生成创意和灵感,并在用户提供场景和提示词时生成对应的回复。你的任务包括: + 1. 撰写建议:根据用户提供的主题或问题,提供详细的写作建议、情节发展方向、角色设定以及背景描写,确保内容结构清晰、有逻辑。 + 2. 回复生成:根据用户提供的场景和提示词,生成合适的对话或文字回复,确保语气和风格符合场景需求。 + 除此之外不需要除了正文内容外的其他回复,如标题、开头、任何解释性语句或道歉。 + """), + + AI_MIND_MAP_ROLE("导图助手", """ + 你是一位非常优秀的思维导图助手,你会把用户的所有提问都总结成思维导图,然后以 Markdown 格式输出。markdown 只需要输出一级标题,二级标题,三级标题,四级标题,最多输出四级,除此之外不要输出任何其他 markdown 标记。下面是一个合格的例子: + # Geek-AI 助手 + ## 完整的开源系统 + ### 前端开源 + ### 后端开源 + ## 支持各种大模型 + ### OpenAI + ### Azure + ### 文心一言 + ### 通义千问 + ## 集成多种收费方式 + ### 支付宝 + ### 微信 + 除此之外不要任何解释性语句。 + """), + ; + + /** + * 角色名 + */ + private final String name; + + /** + * 角色设定 + */ + private final String systemMessage; + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/DictTypeConstants.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/DictTypeConstants.java new file mode 100644 index 0000000..7688da6 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/DictTypeConstants.java @@ -0,0 +1,16 @@ +package cn.aagro.pp.module.ai.enums; + +/** + * AI 字典类型的枚举类 + * + * @author xiaoxin + */ +public interface DictTypeConstants { + + // ========== AI Write ========== + String AI_WRITE_FORMAT = "ai_write_format"; // 写作格式 + String AI_WRITE_LENGTH = "ai_write_length"; // 写作长度 + String AI_WRITE_LANGUAGE = "ai_write_language"; // 写作语言 + String AI_WRITE_TONE = "ai_write_tone"; // 写作语气 + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/ErrorCodeConstants.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/ErrorCodeConstants.java new file mode 100644 index 0000000..0aa2aac --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/ErrorCodeConstants.java @@ -0,0 +1,68 @@ +package cn.aagro.pp.module.ai.enums; + +import cn.aagro.pp.framework.common.exception.ErrorCode; + +/** + * AI 错误码枚举类 + *

+ * ai 系统,使用 1-040-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== API 密钥 1-040-000-000 ========== + ErrorCode API_KEY_NOT_EXISTS = new ErrorCode(1_040_000_000, "API 密钥不存在"); + ErrorCode API_KEY_DISABLE = new ErrorCode(1_040_000_001, "API 密钥已禁用!"); + + // ========== API 模型 1-040-001-000 ========== + ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_040_001_000, "模型不存在!"); + ErrorCode MODEL_DISABLE = new ErrorCode(1_040_001_001, "模型({})已禁用!"); + ErrorCode MODEL_DEFAULT_NOT_EXISTS = new ErrorCode(1_040_001_002, "操作失败,找不到默认模型"); + ErrorCode MODEL_USE_TYPE_ERROR = new ErrorCode(1_040_001_003, "操作失败,该模型的模型类型不正确"); + + // ========== API 聊天角色 1-040-002-000 ========== + ErrorCode CHAT_ROLE_NOT_EXISTS = new ErrorCode(1_040_002_000, "聊天角色不存在"); + ErrorCode CHAT_ROLE_DISABLE = new ErrorCode(1_040_001_001, "聊天角色({})已禁用!"); + + // ========== API 聊天会话 1-040-003-000 ========== + ErrorCode CHAT_CONVERSATION_NOT_EXISTS = new ErrorCode(1_040_003_000, "对话不存在!"); + ErrorCode CHAT_CONVERSATION_MODEL_ERROR = new ErrorCode(1_040_003_001, "操作失败,该聊天模型的配置不完整"); + + // ========== API 聊天消息 1-040-004-000 ========== + ErrorCode CHAT_MESSAGE_NOT_EXIST = new ErrorCode(1_040_004_000, "消息不存在!"); + ErrorCode CHAT_STREAM_ERROR = new ErrorCode(1_040_004_001, "对话生成异常!"); + + // ========== API 绘画 1-040-005-000 ========== + ErrorCode IMAGE_NOT_EXISTS = new ErrorCode(1_040_005_000, "图片不存在!"); + ErrorCode IMAGE_MIDJOURNEY_SUBMIT_FAIL = new ErrorCode(1_040_005_001, "Midjourney 提交失败!原因:{}"); + ErrorCode IMAGE_CUSTOM_ID_NOT_EXISTS = new ErrorCode(1_040_005_002, "Midjourney 按钮 customId 不存在! {}"); + + // ========== API 音乐 1-040-006-000 ========== + ErrorCode MUSIC_NOT_EXISTS = new ErrorCode(1_040_006_000, "音乐不存在!"); + + // ========== API 写作 1-040-007-000 ========== + ErrorCode WRITE_NOT_EXISTS = new ErrorCode(1_040_007_000, "作文不存在!"); + ErrorCode WRITE_STREAM_ERROR = new ErrorCode(1_040_07_001, "写作生成异常!"); + + // ========== API 思维导图 1-040-008-000 ========== + ErrorCode MIND_MAP_NOT_EXISTS = new ErrorCode(1_040_008_000, "思维导图不存在!"); + + // ========== API 知识库 1-040-009-000 ========== + ErrorCode KNOWLEDGE_NOT_EXISTS = new ErrorCode(1_040_009_000, "知识库不存在!"); + + ErrorCode KNOWLEDGE_DOCUMENT_NOT_EXISTS = new ErrorCode(1_040_009_101, "文档不存在!"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_EMPTY = new ErrorCode(1_040_009_102, "文档内容为空!"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_DOWNLOAD_FAIL = new ErrorCode(1_040_009_102, "文件下载失败!"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_READ_FAIL = new ErrorCode(1_040_009_102, "文档加载失败!"); + + ErrorCode KNOWLEDGE_SEGMENT_NOT_EXISTS = new ErrorCode(1_040_009_202, "段落不存在!"); + ErrorCode KNOWLEDGE_SEGMENT_CONTENT_TOO_LONG = new ErrorCode(1_040_009_203, "内容 Token 数为 {},超过最大限制 {}"); + + // ========== AI 工具 1-040-010-000 ========== + ErrorCode TOOL_NOT_EXISTS = new ErrorCode(1_040_010_000, "工具不存在"); + ErrorCode TOOL_NAME_NOT_EXISTS = new ErrorCode(1_040_010_001, "工具({})找不到 Bean"); + + // ========== AI 工作流 1-040-011-000 ========== + ErrorCode WORKFLOW_NOT_EXISTS = new ErrorCode(1_040_011_000, "工作流不存在"); + ErrorCode WORKFLOW_CODE_EXISTS = new ErrorCode(1_040_011_001, "工作流标识已存在"); + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/image/AiImageStatusEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/image/AiImageStatusEnum.java new file mode 100644 index 0000000..4bb9f29 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/image/AiImageStatusEnum.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.module.ai.enums.image; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * AI 绘画状态的枚举 + * + * @author fansili + */ +@AllArgsConstructor +@Getter +public enum AiImageStatusEnum { + + IN_PROGRESS(10, "进行中"), + SUCCESS(20, "已完成"), + FAIL(30, "已失败"); + + /** + * 状态 + */ + private final Integer status; + /** + * 状态名 + */ + private final String name; + + public static AiImageStatusEnum valueOfStatus(Integer status) { + for (AiImageStatusEnum statusEnum : AiImageStatusEnum.values()) { + if (statusEnum.getStatus().equals(status)) { + return statusEnum; + } + } + throw new IllegalArgumentException("未知会话状态: " + status); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/model/AiModelTypeEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/model/AiModelTypeEnum.java new file mode 100644 index 0000000..1719f98 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/model/AiModelTypeEnum.java @@ -0,0 +1,41 @@ +package cn.aagro.pp.module.ai.enums.model; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * AI 模型类型的枚举 + * + * @author 芋道源码 + */ +@Getter +@RequiredArgsConstructor +public enum AiModelTypeEnum implements ArrayValuable { + + CHAT(1, "对话"), + IMAGE(2, "图片"), + VOICE(3, "语音"), + VIDEO(4, "视频"), + EMBEDDING(5, "向量"), + RERANK(6, "重排序"); + + /** + * 类型 + */ + private final Integer type; + /** + * 类型名 + */ + private final String name; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(AiModelTypeEnum::getType).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/model/AiPlatformEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/model/AiPlatformEnum.java new file mode 100644 index 0000000..1a075f6 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/model/AiPlatformEnum.java @@ -0,0 +1,71 @@ +package cn.aagro.pp.module.ai.enums.model; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI 模型平台 + * + * @author fansili + */ +@Getter +@AllArgsConstructor +public enum AiPlatformEnum implements ArrayValuable { + + // ========== 国内平台 ========== + + TONG_YI("TongYi", "通义千问"), // 阿里 + YI_YAN("YiYan", "文心一言"), // 百度 + DEEP_SEEK("DeepSeek", "DeepSeek"), // DeepSeek + ZHI_PU("ZhiPu", "智谱"), // 智谱 AI + XING_HUO("XingHuo", "星火"), // 讯飞 + DOU_BAO("DouBao", "豆包"), // 字节 + HUN_YUAN("HunYuan", "混元"), // 腾讯 + SILICON_FLOW("SiliconFlow", "硅基流动"), // 硅基流动 + MINI_MAX("MiniMax", "MiniMax"), // 稀宇科技 + MOONSHOT("Moonshot", "月之暗灭"), // KIMI + BAI_CHUAN("BaiChuan", "百川智能"), // 百川智能 + + // ========== 国外平台 ========== + + OPENAI("OpenAI", "OpenAI"), // OpenAI 官方 + AZURE_OPENAI("AzureOpenAI", "AzureOpenAI"), // OpenAI 微软 + ANTHROPIC("Anthropic", "Anthropic"), // Anthropic Claude + GEMINI("Gemini", "Gemini"), // 谷歌 Gemini + OLLAMA("Ollama", "Ollama"), + + STABLE_DIFFUSION("StableDiffusion", "StableDiffusion"), // Stability AI + MIDJOURNEY("Midjourney", "Midjourney"), // Midjourney + SUNO("Suno", "Suno"), // Suno AI + + ; + + /** + * 平台 + */ + private final String platform; + /** + * 平台名 + */ + private final String name; + + public static final String[] ARRAYS = Arrays.stream(values()).map(AiPlatformEnum::getPlatform).toArray(String[]::new); + + public static AiPlatformEnum validatePlatform(String platform) { + for (AiPlatformEnum platformEnum : AiPlatformEnum.values()) { + if (platformEnum.getPlatform().equals(platform)) { + return platformEnum; + } + } + throw new IllegalArgumentException("非法平台: " + platform); + } + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/music/AiMusicGenerateModeEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/music/AiMusicGenerateModeEnum.java new file mode 100644 index 0000000..86784a3 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/music/AiMusicGenerateModeEnum.java @@ -0,0 +1,37 @@ +package cn.aagro.pp.module.ai.enums.music; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI 音乐生成模式的枚举 + * + * @author xiaoxin + */ +@AllArgsConstructor +@Getter +public enum AiMusicGenerateModeEnum implements ArrayValuable { + + DESCRIPTION(1, "描述模式"), + LYRIC(2, "歌词模式"); + + /** + * 模式 + */ + private final Integer mode; + /** + * 模式名 + */ + private final String name; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(AiMusicGenerateModeEnum::getMode).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/music/AiMusicStatusEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/music/AiMusicStatusEnum.java new file mode 100644 index 0000000..d3a3520 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/music/AiMusicStatusEnum.java @@ -0,0 +1,39 @@ +package cn.aagro.pp.module.ai.enums.music; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI 音乐状态的枚举 + * + * @author xiaoxin + */ +@AllArgsConstructor +@Getter +public enum AiMusicStatusEnum implements ArrayValuable { + + IN_PROGRESS(10, "进行中"), + SUCCESS(20, "已完成"), + FAIL(30, "已失败"); + + /** + * 状态 + */ + private final Integer status; + + /** + * 状态名 + */ + private final String name; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(AiMusicStatusEnum::getStatus).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/write/AiWriteTypeEnum.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/write/AiWriteTypeEnum.java new file mode 100644 index 0000000..90c8399 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/enums/write/AiWriteTypeEnum.java @@ -0,0 +1,42 @@ +package cn.aagro.pp.module.ai.enums.write; + +import cn.aagro.pp.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI 写作类型的枚举 + * + * @author xiaoxin + */ +@AllArgsConstructor +@Getter +public enum AiWriteTypeEnum implements ArrayValuable { + + WRITING(1, "撰写", "请撰写一篇关于 [{}] 的文章。文章的内容格式:{},语气:{},语言:{},长度:{}。请确保涵盖主要内容,不需要除了正文内容外的其他回复,如标题、额外的解释或道歉。"), + REPLY(2, "回复", "请针对如下内容:[{}] 做个回复。回复内容参考:[{}], 回复格式:{},语气:{},语言:{},长度:{}。不需要除了正文内容外的其他回复,如标题、开头、额外的解释或道歉。"); + + /** + * 类型 + */ + private final Integer type; + /** + * 类型名 + */ + private final String name; + + /** + * 模版 + */ + private final String prompt; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(AiWriteTypeEnum::getType).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/config/AagroAiProperties.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/config/AagroAiProperties.java new file mode 100644 index 0000000..572f291 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/config/AagroAiProperties.java @@ -0,0 +1,172 @@ +package cn.aagro.pp.module.ai.framework.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 芋道 AI 配置类 + * + * @author fansili + * @since 1.0 + */ +@ConfigurationProperties(prefix = "aagro.ai") +@Data +public class AagroAiProperties { + + /** + * 谷歌 Gemini + */ + private Gemini gemini; + + /** + * 字节豆包 + */ + private DouBao doubao; + + /** + * 腾讯混元 + */ + private HunYuan hunyuan; + + /** + * 硅基流动 + */ + private SiliconFlow siliconflow; + + /** + * 讯飞星火 + */ + private XingHuo xinghuo; + + /** + * 百川 + */ + private BaiChuan baichuan; + + /** + * Midjourney 绘图 + */ + private Midjourney midjourney; + + /** + * Suno 音乐 + */ + @SuppressWarnings("SpellCheckingInspection") + private Suno suno; + + /** + * 网络搜索 + */ + private WebSearch webSearch; + + @Data + public static class Gemini { + + private String enable; + private String apiKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class DouBao { + + private String enable; + private String apiKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class HunYuan { + + private String enable; + private String baseUrl; + private String apiKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class SiliconFlow { + + private String enable; + private String apiKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class XingHuo { + + private String enable; + private String appId; + private String appKey; + private String secretKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class BaiChuan { + + private String enable; + private String apiKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class Midjourney { + + private String enable; + private String baseUrl; + + private String apiKey; + private String notifyUrl; + + } + + @Data + public static class Suno { + + private boolean enable; + + private String baseUrl; + + } + + @Data + public static class WebSearch { + + private boolean enable; + + private String apiKey; + + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/config/AiAutoConfiguration.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/config/AiAutoConfiguration.java new file mode 100644 index 0000000..c63ac04 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/config/AiAutoConfiguration.java @@ -0,0 +1,289 @@ +package cn.aagro.pp.module.ai.framework.ai.config; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.aagro.pp.module.ai.framework.ai.core.model.AiModelFactory; +import cn.aagro.pp.module.ai.framework.ai.core.model.AiModelFactoryImpl; +import cn.aagro.pp.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.doubao.DouBaoChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.gemini.GeminiChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.hunyuan.HunYuanChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.aagro.pp.module.ai.framework.ai.core.model.siliconflow.SiliconFlowApiConstants; +import cn.aagro.pp.module.ai.framework.ai.core.model.siliconflow.SiliconFlowChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.suno.api.SunoApi; +import cn.aagro.pp.module.ai.framework.ai.core.model.xinghuo.XingHuoChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.webserch.AiWebSearchClient; +import cn.aagro.pp.module.ai.framework.ai.core.webserch.bocha.AiBoChaWebSearchClient; +import cn.aagro.pp.module.ai.tool.method.PersonService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; +import org.springframework.ai.embedding.BatchingStrategy; +import org.springframework.ai.embedding.TokenCountBatchingStrategy; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator; +import org.springframework.ai.tokenizer.TokenCountEstimator; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusServiceClientProperties; +import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreProperties; +import org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreProperties; +import org.springframework.ai.vectorstore.redis.autoconfigure.RedisVectorStoreProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * 芋道 AI 自动配置 + * + * @author fansili + */ +@Configuration +@EnableConfigurationProperties({ AagroAiProperties.class, + QdrantVectorStoreProperties.class, // 解析 Qdrant 配置 + RedisVectorStoreProperties.class, // 解析 Redis 配置 + MilvusVectorStoreProperties.class, MilvusServiceClientProperties.class // 解析 Milvus 配置 +}) +@Slf4j +public class AiAutoConfiguration { + + @Bean + public AiModelFactory aiModelFactory() { + return new AiModelFactoryImpl(); + } + + // ========== 各种 AI Client 创建 ========== + + @Bean + @ConditionalOnProperty(value = "aagro.ai.gemini.enable", havingValue = "true") + public GeminiChatModel geminiChatModel(AagroAiProperties aagroAiProperties) { + AagroAiProperties.Gemini properties = aagroAiProperties.getGemini(); + return buildGeminiChatClient(properties); + } + + public GeminiChatModel buildGeminiChatClient(AagroAiProperties.Gemini properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(GeminiChatModel.MODEL_DEFAULT); + } + OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(OpenAiApi.builder() + .baseUrl(GeminiChatModel.BASE_URL) + .completionsPath(GeminiChatModel.COMPLETE_PATH) + .apiKey(properties.getApiKey()) + .build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + .toolCallingManager(getToolCallingManager()) + .build(); + return new GeminiChatModel(openAiChatModel); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.doubao.enable", havingValue = "true") + public DouBaoChatModel douBaoChatClient(AagroAiProperties aagroAiProperties) { + AagroAiProperties.DouBao properties = aagroAiProperties.getDoubao(); + return buildDouBaoChatClient(properties); + } + + public DouBaoChatModel buildDouBaoChatClient(AagroAiProperties.DouBao properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(DouBaoChatModel.MODEL_DEFAULT); + } + OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(OpenAiApi.builder() + .baseUrl(DouBaoChatModel.BASE_URL) + .completionsPath(DouBaoChatModel.COMPLETE_PATH) + .apiKey(properties.getApiKey()) + .build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + .toolCallingManager(getToolCallingManager()) + .build(); + return new DouBaoChatModel(openAiChatModel); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.siliconflow.enable", havingValue = "true") + public SiliconFlowChatModel siliconFlowChatClient(AagroAiProperties aagroAiProperties) { + AagroAiProperties.SiliconFlow properties = aagroAiProperties.getSiliconflow(); + return buildSiliconFlowChatClient(properties); + } + + public SiliconFlowChatModel buildSiliconFlowChatClient(AagroAiProperties.SiliconFlow properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(SiliconFlowApiConstants.MODEL_DEFAULT); + } + DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() + .baseUrl(SiliconFlowApiConstants.DEFAULT_BASE_URL) + .apiKey(properties.getApiKey()) + .build()) + .defaultOptions(DeepSeekChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + .toolCallingManager(getToolCallingManager()) + .build(); + return new SiliconFlowChatModel(openAiChatModel); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.hunyuan.enable", havingValue = "true") + public HunYuanChatModel hunYuanChatClient(AagroAiProperties aagroAiProperties) { + AagroAiProperties.HunYuan properties = aagroAiProperties.getHunyuan(); + return buildHunYuanChatClient(properties); + } + + public HunYuanChatModel buildHunYuanChatClient(AagroAiProperties.HunYuan properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(HunYuanChatModel.MODEL_DEFAULT); + } + // 特殊:由于混元大模型不提供 deepseek,而是通过知识引擎,所以需要区分下 URL + if (StrUtil.isEmpty(properties.getBaseUrl())) { + properties.setBaseUrl( + StrUtil.startWithIgnoreCase(properties.getModel(), "deepseek") ? HunYuanChatModel.DEEP_SEEK_BASE_URL + : HunYuanChatModel.BASE_URL); + } + // 创建 DeepSeekChatModel、HunYuanChatModel 对象 + DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() + .baseUrl(properties.getBaseUrl()) + .completionsPath(HunYuanChatModel.COMPLETE_PATH) + .apiKey(properties.getApiKey()) + .build()) + .defaultOptions(DeepSeekChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + .toolCallingManager(getToolCallingManager()) + .build(); + return new HunYuanChatModel(openAiChatModel); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.xinghuo.enable", havingValue = "true") + public XingHuoChatModel xingHuoChatClient(AagroAiProperties aagroAiProperties) { + AagroAiProperties.XingHuo properties = aagroAiProperties.getXinghuo(); + return buildXingHuoChatClient(properties); + } + + public XingHuoChatModel buildXingHuoChatClient(AagroAiProperties.XingHuo properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(XingHuoChatModel.MODEL_DEFAULT); + } + OpenAiApi.Builder builder = OpenAiApi.builder() + .baseUrl(XingHuoChatModel.BASE_URL_V1) + .apiKey(properties.getAppKey() + ":" + properties.getSecretKey()); + if ("x1".equals(properties.getModel())) { + builder.baseUrl(XingHuoChatModel.BASE_URL_V2) + .completionsPath(XingHuoChatModel.BASE_COMPLETIONS_PATH_V2); + } + OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(builder.build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + // TODO @芋艿:星火的 function call 有 bug,会报 ToolResponseMessage must have an id 错误!!! + .toolCallingManager(getToolCallingManager()) + .build(); + return new XingHuoChatModel(openAiChatModel); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.baichuan.enable", havingValue = "true") + public BaiChuanChatModel baiChuanChatClient(AagroAiProperties aagroAiProperties) { + AagroAiProperties.BaiChuan properties = aagroAiProperties.getBaichuan(); + return buildBaiChuanChatClient(properties); + } + + public BaiChuanChatModel buildBaiChuanChatClient(AagroAiProperties.BaiChuan properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(BaiChuanChatModel.MODEL_DEFAULT); + } + OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(OpenAiApi.builder() + .baseUrl(BaiChuanChatModel.BASE_URL) + .apiKey(properties.getApiKey()) + .build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + .toolCallingManager(getToolCallingManager()) + .build(); + return new BaiChuanChatModel(openAiChatModel); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.midjourney.enable", havingValue = "true") + public MidjourneyApi midjourneyApi(AagroAiProperties aagroAiProperties) { + AagroAiProperties.Midjourney config = aagroAiProperties.getMidjourney(); + return new MidjourneyApi(config.getBaseUrl(), config.getApiKey(), config.getNotifyUrl()); + } + + @Bean + @ConditionalOnProperty(value = "aagro.ai.suno.enable", havingValue = "true") + public SunoApi sunoApi(AagroAiProperties aagroAiProperties) { + return new SunoApi(aagroAiProperties.getSuno().getBaseUrl()); + } + + // ========== RAG 相关 ========== + + @Bean + public TokenCountEstimator tokenCountEstimator() { + return new JTokkitTokenCountEstimator(); + } + + @Bean + public BatchingStrategy batchingStrategy() { + return new TokenCountBatchingStrategy(); + } + + private static ToolCallingManager getToolCallingManager() { + return SpringUtil.getBean(ToolCallingManager.class); + } + + // ========== Web Search 相关 ========== + + @Bean + @ConditionalOnProperty(value = "aagro.ai.web-search.enable", havingValue = "true") + public AiWebSearchClient webSearchClient(AagroAiProperties aagroAiProperties) { + return new AiBoChaWebSearchClient(aagroAiProperties.getWebSearch().getApiKey()); + } + + // ========== MCP 相关 ========== + + /** + * 参考自 MCP Server Boot Starter + */ + @Bean + public List toolCallbacks(PersonService personService) { + return List.of(ToolCallbacks.from(personService)); + } + +} \ No newline at end of file diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/AiModelFactory.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/AiModelFactory.java new file mode 100644 index 0000000..dc9e3c6 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/AiModelFactory.java @@ -0,0 +1,113 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model; + +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.aagro.pp.module.ai.framework.ai.core.model.suno.api.SunoApi; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.vectorstore.VectorStore; + +import java.util.Map; + +/** + * AI Model 模型工厂的接口类 + * + * @author fansili + */ +public interface AiModelFactory { + + /** + * 基于指定配置,获得 ChatModel 对象 + * + * 如果不存在,则进行创建 + * + * @param platform 平台 + * @param apiKey API KEY + * @param url API URL + * @return ChatModel 对象 + */ + ChatModel getOrCreateChatModel(AiPlatformEnum platform, String apiKey, String url); + + /** + * 基于默认配置,获得 ChatModel 对象 + * + * 默认配置,指的是在 application.yaml 配置文件中的 spring.ai 相关的配置 + * + * @param platform 平台 + * @return ChatModel 对象 + */ + ChatModel getDefaultChatModel(AiPlatformEnum platform); + + /** + * 基于默认配置,获得 ImageModel 对象 + * + * 默认配置,指的是在 application.yaml 配置文件中的 spring.ai 相关的配置 + * + * @param platform 平台 + * @return ImageModel 对象 + */ + ImageModel getDefaultImageModel(AiPlatformEnum platform); + + /** + * 基于指定配置,获得 ImageModel 对象 + * + * 如果不存在,则进行创建 + * + * @param platform 平台 + * @param apiKey API KEY + * @param url API URL + * @return ImageModel 对象 + */ + ImageModel getOrCreateImageModel(AiPlatformEnum platform, String apiKey, String url); + + /** + * 基于指定配置,获得 MidjourneyApi 对象 + * + * 如果不存在,则进行创建 + * + * @param apiKey API KEY + * @param url API URL + * @return MidjourneyApi 对象 + */ + MidjourneyApi getOrCreateMidjourneyApi(String apiKey, String url); + + /** + * 基于指定配置,获得 SunoApi 对象 + * + * 如果不存在,则进行创建 + * + * @param apiKey API KEY + * @param url API URL + * @return SunoApi 对象 + */ + SunoApi getOrCreateSunoApi(String apiKey, String url); + + /** + * 基于指定配置,获得 EmbeddingModel 对象 + * + * 如果不存在,则进行创建 + * + * @param platform 平台 + * @param apiKey API KEY + * @param url API URL + * @param model 模型 + * @return ChatModel 对象 + */ + EmbeddingModel getOrCreateEmbeddingModel(AiPlatformEnum platform, String apiKey, String url, String model); + + /** + * 基于指定配置,获得 VectorStore 对象 + * + * 如果不存在,则进行创建 + * + * @param type 向量存储类型 + * @param embeddingModel 向量模型 + * @param metadataFields 元数据字段 + * @return VectorStore 对象 + */ + VectorStore getOrCreateVectorStore(Class type, + EmbeddingModel embeddingModel, + Map> metadataFields); + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/AiModelFactoryImpl.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/AiModelFactoryImpl.java new file mode 100644 index 0000000..8edfa84 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/AiModelFactoryImpl.java @@ -0,0 +1,832 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.lang.Singleton; +import cn.hutool.core.lang.func.Func0; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.RuntimeUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.aagro.pp.framework.common.util.spring.SpringUtils; +import cn.aagro.pp.module.ai.enums.model.AiPlatformEnum; +import cn.aagro.pp.module.ai.framework.ai.config.AiAutoConfiguration; +import cn.aagro.pp.module.ai.framework.ai.config.AagroAiProperties; +import cn.aagro.pp.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.doubao.DouBaoChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.gemini.GeminiChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.hunyuan.HunYuanChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.aagro.pp.module.ai.framework.ai.core.model.siliconflow.SiliconFlowApiConstants; +import cn.aagro.pp.module.ai.framework.ai.core.model.siliconflow.SiliconFlowChatModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.siliconflow.SiliconFlowImageApi; +import cn.aagro.pp.module.ai.framework.ai.core.model.siliconflow.SiliconFlowImageModel; +import cn.aagro.pp.module.ai.framework.ai.core.model.suno.api.SunoApi; +import cn.aagro.pp.module.ai.framework.ai.core.model.xinghuo.XingHuoChatModel; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeChatAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeEmbeddingAutoConfiguration; +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeImageAutoConfiguration; +import com.alibaba.cloud.ai.dashscope.api.DashScopeApi; +import com.alibaba.cloud.ai.dashscope.api.DashScopeImageApi; +import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel; +import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions; +import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel; +import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingOptions; +import com.alibaba.cloud.ai.dashscope.image.DashScopeImageModel; +import com.azure.ai.openai.OpenAIClientBuilder; +import com.azure.core.credential.KeyCredential; +import io.micrometer.observation.ObservationRegistry; +import io.milvus.client.MilvusServiceClient; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.QdrantGrpcClient; +import lombok.SneakyThrows; +import org.springaicommunity.moonshot.MoonshotChatModel; +import org.springaicommunity.moonshot.MoonshotChatOptions; +import org.springaicommunity.moonshot.api.MoonshotApi; +import org.springaicommunity.moonshot.autoconfigure.MoonshotChatAutoConfiguration; +import org.springaicommunity.qianfan.QianFanChatModel; +import org.springaicommunity.qianfan.QianFanEmbeddingModel; +import org.springaicommunity.qianfan.QianFanEmbeddingOptions; +import org.springaicommunity.qianfan.QianFanImageModel; +import org.springaicommunity.qianfan.api.QianFanApi; +import org.springaicommunity.qianfan.api.QianFanImageApi; +import org.springaicommunity.qianfan.autoconfigure.QianFanChatAutoConfiguration; +import org.springaicommunity.qianfan.autoconfigure.QianFanEmbeddingAutoConfiguration; +import org.springframework.ai.azure.openai.AzureOpenAiChatModel; +import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingModel; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.BatchingStrategy; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.minimax.MiniMaxChatModel; +import org.springframework.ai.minimax.MiniMaxChatOptions; +import org.springframework.ai.minimax.MiniMaxEmbeddingModel; +import org.springframework.ai.minimax.MiniMaxEmbeddingOptions; +import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.anthropic.autoconfigure.AnthropicChatAutoConfiguration; +import org.springframework.ai.model.azure.openai.autoconfigure.AzureOpenAiChatAutoConfiguration; +import org.springframework.ai.model.azure.openai.autoconfigure.AzureOpenAiEmbeddingAutoConfiguration; +import org.springframework.ai.model.azure.openai.autoconfigure.AzureOpenAiEmbeddingProperties; +import org.springframework.ai.model.deepseek.autoconfigure.DeepSeekChatAutoConfiguration; +import org.springframework.ai.model.minimax.autoconfigure.MiniMaxChatAutoConfiguration; +import org.springframework.ai.model.minimax.autoconfigure.MiniMaxEmbeddingAutoConfiguration; +import org.springframework.ai.model.ollama.autoconfigure.OllamaChatAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration; +import org.springframework.ai.model.stabilityai.autoconfigure.StabilityAiImageAutoConfiguration; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.zhipuai.autoconfigure.ZhiPuAiChatAutoConfiguration; +import org.springframework.ai.model.zhipuai.autoconfigure.ZhiPuAiEmbeddingAutoConfiguration; +import org.springframework.ai.model.zhipuai.autoconfigure.ZhiPuAiImageAutoConfiguration; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.OllamaEmbeddingModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.OpenAiImageModel; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiImageApi; +import org.springframework.ai.openai.api.common.OpenAiApiConstants; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.stabilityai.StabilityAiImageModel; +import org.springframework.ai.stabilityai.api.StabilityAiApi; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.ai.vectorstore.milvus.MilvusVectorStore; +import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusServiceClientConnectionDetails; +import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusServiceClientProperties; +import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration; +import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreProperties; +import org.springframework.ai.vectorstore.observation.DefaultVectorStoreObservationConvention; +import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention; +import org.springframework.ai.vectorstore.qdrant.QdrantVectorStore; +import org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreAutoConfiguration; +import org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreProperties; +import org.springframework.ai.vectorstore.redis.RedisVectorStore; +import org.springframework.ai.vectorstore.redis.autoconfigure.RedisVectorStoreAutoConfiguration; +import org.springframework.ai.vectorstore.redis.autoconfigure.RedisVectorStoreProperties; +import org.springframework.ai.zhipuai.*; +import org.springframework.ai.zhipuai.api.ZhiPuAiApi; +import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.web.client.RestClient; +import redis.clients.jedis.JedisPooled; + +import java.io.File; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; + +import static cn.aagro.pp.framework.common.util.collection.CollectionUtils.convertList; +import static org.springframework.ai.retry.RetryUtils.DEFAULT_RETRY_TEMPLATE; + +/** + * AI Model 模型工厂的实现类 + * + * @author 芋道源码 + */ +public class AiModelFactoryImpl implements AiModelFactory { + + @Override + public ChatModel getOrCreateChatModel(AiPlatformEnum platform, String apiKey, String url) { + String cacheKey = buildClientCacheKey(ChatModel.class, platform, apiKey, url); + return Singleton.get(cacheKey, (Func0) () -> { + // noinspection EnhancedSwitchMigration + switch (platform) { + case TONG_YI: + return buildTongYiChatModel(apiKey); + case YI_YAN: + return buildYiYanChatModel(apiKey); + case DEEP_SEEK: + return buildDeepSeekChatModel(apiKey); + case DOU_BAO: + return buildDouBaoChatModel(apiKey); + case HUN_YUAN: + return buildHunYuanChatModel(apiKey, url); + case SILICON_FLOW: + return buildSiliconFlowChatModel(apiKey); + case ZHI_PU: + return buildZhiPuChatModel(apiKey, url); + case MINI_MAX: + return buildMiniMaxChatModel(apiKey, url); + case MOONSHOT: + return buildMoonshotChatModel(apiKey, url); + case XING_HUO: + return buildXingHuoChatModel(apiKey); + case BAI_CHUAN: + return buildBaiChuanChatModel(apiKey); + case OPENAI: + return buildOpenAiChatModel(apiKey, url); + case AZURE_OPENAI: + return buildAzureOpenAiChatModel(apiKey, url); + case ANTHROPIC: + return buildAnthropicChatModel(apiKey, url); + case GEMINI: + return buildGeminiChatModel(apiKey); + case OLLAMA: + return buildOllamaChatModel(url); + default: + throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); + } + }); + } + + @Override + public ChatModel getDefaultChatModel(AiPlatformEnum platform) { + // noinspection EnhancedSwitchMigration + switch (platform) { + case TONG_YI: + return SpringUtil.getBean(DashScopeChatModel.class); + case YI_YAN: + return SpringUtil.getBean(QianFanChatModel.class); + case DEEP_SEEK: + return SpringUtil.getBean(DeepSeekChatModel.class); + case DOU_BAO: + return SpringUtil.getBean(DouBaoChatModel.class); + case HUN_YUAN: + return SpringUtil.getBean(HunYuanChatModel.class); + case SILICON_FLOW: + return SpringUtil.getBean(SiliconFlowChatModel.class); + case ZHI_PU: + return SpringUtil.getBean(ZhiPuAiChatModel.class); + case MINI_MAX: + return SpringUtil.getBean(MiniMaxChatModel.class); + case MOONSHOT: + return SpringUtil.getBean(MoonshotChatModel.class); + case XING_HUO: + return SpringUtil.getBean(XingHuoChatModel.class); + case BAI_CHUAN: + return SpringUtil.getBean(BaiChuanChatModel.class); + case OPENAI: + return SpringUtil.getBean(OpenAiChatModel.class); + case AZURE_OPENAI: + return SpringUtil.getBean(AzureOpenAiChatModel.class); + case ANTHROPIC: + return SpringUtil.getBean(AnthropicChatModel.class); + case GEMINI: + return SpringUtil.getBean(GeminiChatModel.class); + case OLLAMA: + return SpringUtil.getBean(OllamaChatModel.class); + default: + throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); + } + } + + @Override + public ImageModel getDefaultImageModel(AiPlatformEnum platform) { + // noinspection EnhancedSwitchMigration + switch (platform) { + case TONG_YI: + return SpringUtil.getBean(DashScopeImageModel.class); + case YI_YAN: + return SpringUtil.getBean(QianFanImageModel.class); + case ZHI_PU: + return SpringUtil.getBean(ZhiPuAiImageModel.class); + case SILICON_FLOW: + return SpringUtil.getBean(SiliconFlowImageModel.class); + case OPENAI: + return SpringUtil.getBean(OpenAiImageModel.class); + case STABLE_DIFFUSION: + return SpringUtil.getBean(StabilityAiImageModel.class); + default: + throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); + } + } + + @Override + public ImageModel getOrCreateImageModel(AiPlatformEnum platform, String apiKey, String url) { + // noinspection EnhancedSwitchMigration + switch (platform) { + case TONG_YI: + return buildTongYiImagesModel(apiKey); + case YI_YAN: + return buildQianFanImageModel(apiKey); + case ZHI_PU: + return buildZhiPuAiImageModel(apiKey, url); + case OPENAI: + return buildOpenAiImageModel(apiKey, url); + case SILICON_FLOW: + return buildSiliconFlowImageModel(apiKey,url); + case STABLE_DIFFUSION: + return buildStabilityAiImageModel(apiKey, url); + default: + throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); + } + } + + @Override + public MidjourneyApi getOrCreateMidjourneyApi(String apiKey, String url) { + String cacheKey = buildClientCacheKey(MidjourneyApi.class, AiPlatformEnum.MIDJOURNEY.getPlatform(), apiKey, + url); + return Singleton.get(cacheKey, (Func0) () -> { + AagroAiProperties.Midjourney properties = SpringUtil.getBean(AagroAiProperties.class) + .getMidjourney(); + return new MidjourneyApi(url, apiKey, properties.getNotifyUrl()); + }); + } + + @Override + public SunoApi getOrCreateSunoApi(String apiKey, String url) { + String cacheKey = buildClientCacheKey(SunoApi.class, AiPlatformEnum.SUNO.getPlatform(), apiKey, url); + return Singleton.get(cacheKey, (Func0) () -> new SunoApi(url)); + } + + @Override + @SuppressWarnings("EnhancedSwitchMigration") + public EmbeddingModel getOrCreateEmbeddingModel(AiPlatformEnum platform, String apiKey, String url, String model) { + String cacheKey = buildClientCacheKey(EmbeddingModel.class, platform, apiKey, url, model); + return Singleton.get(cacheKey, (Func0) () -> { + switch (platform) { + case TONG_YI: + return buildTongYiEmbeddingModel(apiKey, model); + case YI_YAN: + return buildYiYanEmbeddingModel(apiKey, model); + case ZHI_PU: + return buildZhiPuEmbeddingModel(apiKey, url, model); + case MINI_MAX: + return buildMiniMaxEmbeddingModel(apiKey, url, model); + case OPENAI: + return buildOpenAiEmbeddingModel(apiKey, url, model); + case AZURE_OPENAI: + return buildAzureOpenAiEmbeddingModel(apiKey, url, model); + case OLLAMA: + return buildOllamaEmbeddingModel(url, model); + default: + throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); + } + }); + } + + @Override + public VectorStore getOrCreateVectorStore(Class type, + EmbeddingModel embeddingModel, + Map> metadataFields) { + String cacheKey = buildClientCacheKey(VectorStore.class, embeddingModel, type); + return Singleton.get(cacheKey, (Func0) () -> { + if (type == SimpleVectorStore.class) { + return buildSimpleVectorStore(embeddingModel); + } + if (type == QdrantVectorStore.class) { + return buildQdrantVectorStore(embeddingModel); + } + if (type == RedisVectorStore.class) { + return buildRedisVectorStore(embeddingModel, metadataFields); + } + if (type == MilvusVectorStore.class) { + return buildMilvusVectorStore(embeddingModel); + } + throw new IllegalArgumentException(StrUtil.format("未知类型({})", type)); + }); + } + + private static String buildClientCacheKey(Class clazz, Object... params) { + if (ArrayUtil.isEmpty(params)) { + return clazz.getName(); + } + return StrUtil.format("{}#{}", clazz.getName(), ArrayUtil.join(params, "_")); + } + + // ========== 各种创建 spring-ai 客户端的方法 ========== + + /** + * 可参考 {@link DashScopeChatAutoConfiguration} 的 dashscopeChatModel 方法 + */ + private static DashScopeChatModel buildTongYiChatModel(String key) { + DashScopeApi dashScopeApi = DashScopeApi.builder().apiKey(key).build(); + DashScopeChatOptions options = DashScopeChatOptions.builder().withModel(DashScopeApi.DEFAULT_CHAT_MODEL) + .withTemperature(0.7).build(); + return DashScopeChatModel.builder() + .dashScopeApi(dashScopeApi) + .defaultOptions(options) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link DashScopeImageAutoConfiguration} 的 dashScopeImageModel 方法 + */ + private static DashScopeImageModel buildTongYiImagesModel(String key) { + DashScopeImageApi dashScopeImageApi = DashScopeImageApi.builder().apiKey(key).build(); + return DashScopeImageModel.builder() + .dashScopeApi(dashScopeImageApi) + .build(); + } + + /** + * 可参考 {@link QianFanChatAutoConfiguration} 的 qianFanChatModel 方法 + */ + private static QianFanChatModel buildYiYanChatModel(String key) { + // TODO spring ai qianfan 有 bug,无法使用 https://github.com/spring-ai-community/qianfan/issues/6 + List keys = StrUtil.split(key, '|'); + Assert.equals(keys.size(), 2, "YiYanChatClient 的密钥需要 (appKey|secretKey) 格式"); + String appKey = keys.get(0); + String secretKey = keys.get(1); + QianFanApi qianFanApi = new QianFanApi(appKey, secretKey); + return new QianFanChatModel(qianFanApi); + } + + /** + * 可参考 {@link QianFanEmbeddingAutoConfiguration} 的 qianFanImageModel 方法 + */ + private QianFanImageModel buildQianFanImageModel(String key) { + // TODO spring ai qianfan 有 bug,无法使用 https://github.com/spring-ai-community/qianfan/issues/6 + List keys = StrUtil.split(key, '|'); + Assert.equals(keys.size(), 2, "YiYanChatClient 的密钥需要 (appKey|secretKey) 格式"); + String appKey = keys.get(0); + String secretKey = keys.get(1); + QianFanImageApi qianFanApi = new QianFanImageApi(appKey, secretKey); + return new QianFanImageModel(qianFanApi); + } + + /** + * 可参考 {@link DeepSeekChatAutoConfiguration} 的 deepSeekChatModel 方法 + */ + private static DeepSeekChatModel buildDeepSeekChatModel(String apiKey) { + DeepSeekApi deepSeekApi = DeepSeekApi.builder().apiKey(apiKey).build(); + DeepSeekChatOptions options = DeepSeekChatOptions.builder().model(DeepSeekApi.DEFAULT_CHAT_MODEL) + .temperature(0.7).build(); + return DeepSeekChatModel.builder() + .deepSeekApi(deepSeekApi) + .defaultOptions(options) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link AiAutoConfiguration#douBaoChatClient(AagroAiProperties)} + */ + private ChatModel buildDouBaoChatModel(String apiKey) { + AagroAiProperties.DouBao properties = new AagroAiProperties.DouBao() + .setApiKey(apiKey); + return new AiAutoConfiguration().buildDouBaoChatClient(properties); + } + + /** + * 可参考 {@link AiAutoConfiguration#hunYuanChatClient(AagroAiProperties)} + */ + private ChatModel buildHunYuanChatModel(String apiKey, String url) { + AagroAiProperties.HunYuan properties = new AagroAiProperties.HunYuan() + .setBaseUrl(url).setApiKey(apiKey); + return new AiAutoConfiguration().buildHunYuanChatClient(properties); + } + + /** + * 可参考 {@link AiAutoConfiguration#siliconFlowChatClient(AagroAiProperties)} + */ + private ChatModel buildSiliconFlowChatModel(String apiKey) { + AagroAiProperties.SiliconFlow properties = new AagroAiProperties.SiliconFlow() + .setApiKey(apiKey); + return new AiAutoConfiguration().buildSiliconFlowChatClient(properties); + } + + /** + * 可参考 {@link ZhiPuAiChatAutoConfiguration} 的 zhiPuAiChatModel 方法 + */ + private ZhiPuAiChatModel buildZhiPuChatModel(String apiKey, String url) { + ZhiPuAiApi zhiPuAiApi = StrUtil.isEmpty(url) ? new ZhiPuAiApi(apiKey) + : new ZhiPuAiApi(url, apiKey); + ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder().model(ZhiPuAiApi.DEFAULT_CHAT_MODEL).temperature(0.7).build(); + return new ZhiPuAiChatModel(zhiPuAiApi, options, getToolCallingManager(), DEFAULT_RETRY_TEMPLATE, + getObservationRegistry().getIfAvailable()); + } + + /** + * 可参考 {@link ZhiPuAiImageAutoConfiguration} 的 zhiPuAiImageModel 方法 + */ + private ZhiPuAiImageModel buildZhiPuAiImageModel(String apiKey, String url) { + ZhiPuAiImageApi zhiPuAiApi = StrUtil.isEmpty(url) ? new ZhiPuAiImageApi(apiKey) + : new ZhiPuAiImageApi(url, apiKey, RestClient.builder()); + return new ZhiPuAiImageModel(zhiPuAiApi); + } + + /** + * 可参考 {@link MiniMaxChatAutoConfiguration} 的 miniMaxChatModel 方法 + */ + private MiniMaxChatModel buildMiniMaxChatModel(String apiKey, String url) { + MiniMaxApi miniMaxApi = StrUtil.isEmpty(url) ? new MiniMaxApi(apiKey) + : new MiniMaxApi(url, apiKey); + MiniMaxChatOptions options = MiniMaxChatOptions.builder().model(MiniMaxApi.DEFAULT_CHAT_MODEL).temperature(0.7).build(); + return new MiniMaxChatModel(miniMaxApi, options, getToolCallingManager(), DEFAULT_RETRY_TEMPLATE); + } + + /** + * 可参考 {@link MoonshotChatAutoConfiguration} 的 moonshotChatModel 方法 + */ + private MoonshotChatModel buildMoonshotChatModel(String apiKey, String url) { + MoonshotApi.Builder moonshotApiBuilder = MoonshotApi.builder() + .apiKey(apiKey); + if (StrUtil.isNotEmpty(url)) { + moonshotApiBuilder.baseUrl(url); + } + MoonshotChatOptions options = MoonshotChatOptions.builder().model(MoonshotApi.DEFAULT_CHAT_MODEL).build(); + return MoonshotChatModel.builder() + .moonshotApi(moonshotApiBuilder.build()) + .defaultOptions(options) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link AiAutoConfiguration#xingHuoChatClient(AagroAiProperties)} + */ + private static XingHuoChatModel buildXingHuoChatModel(String key) { + List keys = StrUtil.split(key, '|'); + Assert.equals(keys.size(), 2, "XingHuoChatClient 的密钥需要 (appKey|secretKey) 格式"); + AagroAiProperties.XingHuo properties = new AagroAiProperties.XingHuo() + .setAppKey(keys.get(0)).setSecretKey(keys.get(1)); + return new AiAutoConfiguration().buildXingHuoChatClient(properties); + } + + /** + * 可参考 {@link AiAutoConfiguration#baiChuanChatClient(AagroAiProperties)} + */ + private BaiChuanChatModel buildBaiChuanChatModel(String apiKey) { + AagroAiProperties.BaiChuan properties = new AagroAiProperties.BaiChuan() + .setApiKey(apiKey); + return new AiAutoConfiguration().buildBaiChuanChatClient(properties); + } + + /** + * 可参考 {@link OpenAiChatAutoConfiguration} 的 openAiChatModel 方法 + */ + private static OpenAiChatModel buildOpenAiChatModel(String openAiToken, String url) { + url = StrUtil.blankToDefault(url, OpenAiApiConstants.DEFAULT_BASE_URL); + OpenAiApi openAiApi = OpenAiApi.builder().baseUrl(url).apiKey(openAiToken).build(); + return OpenAiChatModel.builder() + .openAiApi(openAiApi) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link AzureOpenAiChatAutoConfiguration} + */ + private static AzureOpenAiChatModel buildAzureOpenAiChatModel(String apiKey, String url) { + // TODO @芋艿:使用前,请测试,暂时没密钥!!! + OpenAIClientBuilder openAIClientBuilder = new OpenAIClientBuilder() + .endpoint(url).credential(new KeyCredential(apiKey)); + return AzureOpenAiChatModel.builder() + .openAIClientBuilder(openAIClientBuilder) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link AnthropicChatAutoConfiguration} 的 anthropicApi 方法 + */ + private static AnthropicChatModel buildAnthropicChatModel(String apiKey, String url) { + AnthropicApi.Builder builder = AnthropicApi.builder().apiKey(apiKey); + if (StrUtil.isNotEmpty(url)) { + builder.baseUrl(url); + } + AnthropicApi anthropicApi = builder.build(); + return AnthropicChatModel.builder() + .anthropicApi(anthropicApi) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link AiAutoConfiguration#buildGeminiChatClient(AagroAiProperties.Gemini)} + */ + private static GeminiChatModel buildGeminiChatModel(String apiKey) { + AagroAiProperties.Gemini properties = SpringUtil.getBean(AagroAiProperties.class) + .getGemini().setApiKey(apiKey); + return new AiAutoConfiguration().buildGeminiChatClient(properties); + } + + /** + * 可参考 {@link OpenAiImageAutoConfiguration} 的 openAiImageModel 方法 + */ + private OpenAiImageModel buildOpenAiImageModel(String openAiToken, String url) { + url = StrUtil.blankToDefault(url, OpenAiApiConstants.DEFAULT_BASE_URL); + OpenAiImageApi openAiApi = OpenAiImageApi.builder().baseUrl(url).apiKey(openAiToken).build(); + return new OpenAiImageModel(openAiApi); + } + + /** + * 创建 SiliconFlowImageModel 对象 + */ + private SiliconFlowImageModel buildSiliconFlowImageModel(String apiToken, String url) { + url = StrUtil.blankToDefault(url, SiliconFlowApiConstants.DEFAULT_BASE_URL); + SiliconFlowImageApi openAiApi = new SiliconFlowImageApi(url, apiToken); + return new SiliconFlowImageModel(openAiApi); + } + + /** + * 可参考 {@link OllamaChatAutoConfiguration} 的 ollamaChatModel 方法 + */ + private static OllamaChatModel buildOllamaChatModel(String url) { + OllamaApi ollamaApi = OllamaApi.builder().baseUrl(url).build(); + return OllamaChatModel.builder() + .ollamaApi(ollamaApi) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link StabilityAiImageAutoConfiguration} 的 stabilityAiImageModel 方法 + */ + private StabilityAiImageModel buildStabilityAiImageModel(String apiKey, String url) { + url = StrUtil.blankToDefault(url, StabilityAiApi.DEFAULT_BASE_URL); + StabilityAiApi stabilityAiApi = new StabilityAiApi(apiKey, StabilityAiApi.DEFAULT_IMAGE_MODEL, url); + return new StabilityAiImageModel(stabilityAiApi); + } + + // ========== 各种创建 EmbeddingModel 的方法 ========== + + /** + * 可参考 {@link DashScopeEmbeddingAutoConfiguration} 的 dashscopeEmbeddingModel 方法 + */ + private DashScopeEmbeddingModel buildTongYiEmbeddingModel(String apiKey, String model) { + DashScopeApi dashScopeApi = DashScopeApi.builder().apiKey(apiKey).build(); + DashScopeEmbeddingOptions dashScopeEmbeddingOptions = DashScopeEmbeddingOptions.builder().withModel(model).build(); + return new DashScopeEmbeddingModel(dashScopeApi, MetadataMode.EMBED, dashScopeEmbeddingOptions); + } + + /** + * 可参考 {@link ZhiPuAiEmbeddingAutoConfiguration} 的 zhiPuAiEmbeddingModel 方法 + */ + private ZhiPuAiEmbeddingModel buildZhiPuEmbeddingModel(String apiKey, String url, String model) { + ZhiPuAiApi zhiPuAiApi = StrUtil.isEmpty(url) ? new ZhiPuAiApi(apiKey) + : new ZhiPuAiApi(url, apiKey); + ZhiPuAiEmbeddingOptions zhiPuAiEmbeddingOptions = ZhiPuAiEmbeddingOptions.builder().model(model).build(); + return new ZhiPuAiEmbeddingModel(zhiPuAiApi, MetadataMode.EMBED, zhiPuAiEmbeddingOptions); + } + + /** + * 可参考 {@link MiniMaxEmbeddingAutoConfiguration} 的 miniMaxEmbeddingModel 方法 + */ + private EmbeddingModel buildMiniMaxEmbeddingModel(String apiKey, String url, String model) { + MiniMaxApi miniMaxApi = StrUtil.isEmpty(url)? new MiniMaxApi(apiKey) + : new MiniMaxApi(url, apiKey); + MiniMaxEmbeddingOptions miniMaxEmbeddingOptions = MiniMaxEmbeddingOptions.builder().model(model).build(); + return new MiniMaxEmbeddingModel(miniMaxApi, MetadataMode.EMBED, miniMaxEmbeddingOptions); + } + + /** + * 可参考 {@link QianFanEmbeddingAutoConfiguration} 的 qianFanEmbeddingModel 方法 + */ + private QianFanEmbeddingModel buildYiYanEmbeddingModel(String key, String model) { + List keys = StrUtil.split(key, '|'); + Assert.equals(keys.size(), 2, "YiYanChatClient 的密钥需要 (appKey|secretKey) 格式"); + String appKey = keys.get(0); + String secretKey = keys.get(1); + QianFanApi qianFanApi = new QianFanApi(appKey, secretKey); + QianFanEmbeddingOptions qianFanEmbeddingOptions = QianFanEmbeddingOptions.builder().model(model).build(); + return new QianFanEmbeddingModel(qianFanApi, MetadataMode.EMBED, qianFanEmbeddingOptions); + } + + private OllamaEmbeddingModel buildOllamaEmbeddingModel(String url, String model) { + OllamaApi ollamaApi = OllamaApi.builder().baseUrl(url).build(); + OllamaOptions ollamaOptions = OllamaOptions.builder().model(model).build(); + return OllamaEmbeddingModel.builder() + .ollamaApi(ollamaApi) + .defaultOptions(ollamaOptions) + .build(); + } + + /** + * 可参考 {@link OpenAiEmbeddingAutoConfiguration} 的 openAiEmbeddingModel 方法 + */ + private OpenAiEmbeddingModel buildOpenAiEmbeddingModel(String openAiToken, String url, String model) { + url = StrUtil.blankToDefault(url, OpenAiApiConstants.DEFAULT_BASE_URL); + OpenAiApi openAiApi = OpenAiApi.builder().baseUrl(url).apiKey(openAiToken).build(); + OpenAiEmbeddingOptions openAiEmbeddingProperties = OpenAiEmbeddingOptions.builder().model(model).build(); + return new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED, openAiEmbeddingProperties); + } + + /** + * 可参考 {@link AzureOpenAiEmbeddingAutoConfiguration} 的 azureOpenAiEmbeddingModel 方法 + */ + private AzureOpenAiEmbeddingModel buildAzureOpenAiEmbeddingModel(String apiKey, String url, String model) { + // TODO @芋艿:手头暂时没密钥,使用建议再测试下 + AzureOpenAiEmbeddingAutoConfiguration azureOpenAiAutoConfiguration = new AzureOpenAiEmbeddingAutoConfiguration(); + // 创建 OpenAIClientBuilder 对象 + OpenAIClientBuilder openAIClientBuilder = new OpenAIClientBuilder() + .endpoint(url).credential(new KeyCredential(apiKey)); + // 获取 AzureOpenAiChatProperties 对象 + AzureOpenAiEmbeddingProperties embeddingProperties = SpringUtil.getBean(AzureOpenAiEmbeddingProperties.class); + return azureOpenAiAutoConfiguration.azureOpenAiEmbeddingModel(openAIClientBuilder, embeddingProperties, + getObservationRegistry(), getEmbeddingModelObservationConvention()); + } + + // ========== 各种创建 VectorStore 的方法 ========== + + /** + * 注意:仅适合本地测试使用,生产建议还是使用 Qdrant、Milvus 等 + */ + @SneakyThrows + @SuppressWarnings("ResultOfMethodCallIgnored") + private SimpleVectorStore buildSimpleVectorStore(EmbeddingModel embeddingModel) { + SimpleVectorStore vectorStore = SimpleVectorStore.builder(embeddingModel).build(); + // 启动加载 + File file = new File(StrUtil.format("{}/vector_store/simple_{}.json", + FileUtil.getUserHomePath(), embeddingModel.getClass().getSimpleName())); + if (!file.exists()) { + FileUtil.mkParentDirs(file); + file.createNewFile(); + } else if (file.length() > 0) { + vectorStore.load(file); + } + // 定时持久化,每分钟一次 + Timer timer = new Timer("SimpleVectorStoreTimer-" + file.getAbsolutePath()); + timer.scheduleAtFixedRate(new TimerTask() { + + @Override + public void run() { + vectorStore.save(file); + } + + }, Duration.ofMinutes(1).toMillis(), Duration.ofMinutes(1).toMillis()); + // 关闭时,进行持久化 + RuntimeUtil.addShutdownHook(() -> vectorStore.save(file)); + return vectorStore; + } + + /** + * 参考 {@link QdrantVectorStoreAutoConfiguration} 的 vectorStore 方法 + */ + @SneakyThrows + private QdrantVectorStore buildQdrantVectorStore(EmbeddingModel embeddingModel) { + QdrantVectorStoreAutoConfiguration configuration = new QdrantVectorStoreAutoConfiguration(); + QdrantVectorStoreProperties properties = SpringUtil.getBean(QdrantVectorStoreProperties.class); + // 参考 QdrantVectorStoreAutoConfiguration 实现,创建 QdrantClient 对象 + QdrantGrpcClient.Builder grpcClientBuilder = QdrantGrpcClient.newBuilder( + properties.getHost(), properties.getPort(), properties.isUseTls()); + if (StrUtil.isNotEmpty(properties.getApiKey())) { + grpcClientBuilder.withApiKey(properties.getApiKey()); + } + QdrantClient qdrantClient = new QdrantClient(grpcClientBuilder.build()); + // 创建 QdrantVectorStore 对象 + QdrantVectorStore vectorStore = configuration.vectorStore(embeddingModel, properties, qdrantClient, + getObservationRegistry(), getCustomObservationConvention(), getBatchingStrategy()); + // 初始化索引 + vectorStore.afterPropertiesSet(); + return vectorStore; + } + + /** + * 参考 {@link RedisVectorStoreAutoConfiguration} 的 vectorStore 方法 + */ + private RedisVectorStore buildRedisVectorStore(EmbeddingModel embeddingModel, + Map> metadataFields) { + // 创建 JedisPooled 对象 + RedisProperties redisProperties = SpringUtils.getBean(RedisProperties.class); + JedisPooled jedisPooled = new JedisPooled(redisProperties.getHost(), redisProperties.getPort(), + redisProperties.getUsername(), redisProperties.getPassword()); + // 创建 RedisVectorStoreProperties 对象 + RedisVectorStoreProperties properties = SpringUtil.getBean(RedisVectorStoreProperties.class); + RedisVectorStore redisVectorStore = RedisVectorStore.builder(jedisPooled, embeddingModel) + .indexName(properties.getIndexName()).prefix(properties.getPrefix()) + .initializeSchema(properties.isInitializeSchema()) + .metadataFields(convertList(metadataFields.entrySet(), entry -> { + String fieldName = entry.getKey(); + Class fieldType = entry.getValue(); + if (Number.class.isAssignableFrom(fieldType)) { + return RedisVectorStore.MetadataField.numeric(fieldName); + } + if (Boolean.class.isAssignableFrom(fieldType)) { + return RedisVectorStore.MetadataField.tag(fieldName); + } + return RedisVectorStore.MetadataField.text(fieldName); + })) + .observationRegistry(getObservationRegistry().getObject()) + .customObservationConvention(getCustomObservationConvention().getObject()) + .batchingStrategy(getBatchingStrategy()) + .build(); + // 初始化索引 + redisVectorStore.afterPropertiesSet(); + return redisVectorStore; + } + + /** + * 参考 {@link MilvusVectorStoreAutoConfiguration} 的 vectorStore 方法 + */ + @SneakyThrows + private MilvusVectorStore buildMilvusVectorStore(EmbeddingModel embeddingModel) { + MilvusVectorStoreAutoConfiguration configuration = new MilvusVectorStoreAutoConfiguration(); + // 获取配置属性 + MilvusVectorStoreProperties serverProperties = SpringUtil.getBean(MilvusVectorStoreProperties.class); + MilvusServiceClientProperties clientProperties = SpringUtil.getBean(MilvusServiceClientProperties.class); + + // 创建 MilvusServiceClient 对象 + MilvusServiceClient milvusClient = configuration.milvusClient(serverProperties, clientProperties, + new MilvusServiceClientConnectionDetails() { + + @Override + public String getHost() { + return clientProperties.getHost(); + } + + @Override + public int getPort() { + return clientProperties.getPort(); + } + + } + ); + // 创建 MilvusVectorStore 对象 + MilvusVectorStore vectorStore = configuration.vectorStore(milvusClient, embeddingModel, serverProperties, + getBatchingStrategy(), getObservationRegistry(), getCustomObservationConvention()); + + // 初始化索引 + vectorStore.afterPropertiesSet(); + return vectorStore; + } + + private static ObjectProvider getObservationRegistry() { + return new ObjectProvider<>() { + + @Override + public ObservationRegistry getObject() throws BeansException { + return SpringUtil.getBean(ObservationRegistry.class); + } + + }; + } + + private static ObjectProvider getCustomObservationConvention() { + return new ObjectProvider<>() { + + @Override + public VectorStoreObservationConvention getObject() throws BeansException { + return new DefaultVectorStoreObservationConvention(); + } + + }; + } + + private static BatchingStrategy getBatchingStrategy() { + return SpringUtil.getBean(BatchingStrategy.class); + } + + private static ToolCallingManager getToolCallingManager() { + return SpringUtil.getBean(ToolCallingManager.class); + } + + private static ObjectProvider getEmbeddingModelObservationConvention() { + return new ObjectProvider<>() { + + @Override + public EmbeddingModelObservationConvention getObject() throws BeansException { + return SpringUtil.getBean(EmbeddingModelObservationConvention.class); + } + + }; + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/baichuan/BaiChuanChatModel.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/baichuan/BaiChuanChatModel.java new file mode 100644 index 0000000..99be633 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/baichuan/BaiChuanChatModel.java @@ -0,0 +1,45 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model.baichuan; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import reactor.core.publisher.Flux; + +/** + * 百川 {@link ChatModel} 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class BaiChuanChatModel implements ChatModel { + + public static final String BASE_URL = "https://api.baichuan-ai.com"; + + public static final String MODEL_DEFAULT = "Baichuan4-Turbo"; + + /** + * 兼容 OpenAI 接口,进行复用 + */ + private final OpenAiChatModel openAiChatModel; + + @Override + public ChatResponse call(Prompt prompt) { + return openAiChatModel.call(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return openAiChatModel.stream(prompt); + } + + @Override + public ChatOptions getDefaultOptions() { + return openAiChatModel.getDefaultOptions(); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java new file mode 100644 index 0000000..7c04fcb --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java @@ -0,0 +1,45 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model.doubao; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +/** + * 字节豆包 {@link ChatModel} 实现类 + * + * @author fansili + */ +@Slf4j +@RequiredArgsConstructor +public class DouBaoChatModel implements ChatModel { + + public static final String BASE_URL = "https://ark.cn-beijing.volces.com/api"; + public static final String COMPLETE_PATH = "/v3/chat/completions"; + + public static final String MODEL_DEFAULT = "doubao-1-5-lite-32k-250115"; + + /** + * 兼容 OpenAI 接口,进行复用 + */ + private final ChatModel openAiChatModel; + + @Override + public ChatResponse call(Prompt prompt) { + return openAiChatModel.call(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return openAiChatModel.stream(prompt); + } + + @Override + public ChatOptions getDefaultOptions() { + return openAiChatModel.getDefaultOptions(); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java new file mode 100644 index 0000000..25731c3 --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java @@ -0,0 +1,46 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model.gemini; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import reactor.core.publisher.Flux; + +/** + * 谷歌 Gemini {@link ChatModel} 实现类,基于 Google AI Studio 提供的 OpenAI 兼容方案 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class GeminiChatModel implements ChatModel { + + public static final String BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; + public static final String COMPLETE_PATH = "/chat/completions"; + + public static final String MODEL_DEFAULT = "gemini-2.5-flash"; + + /** + * 兼容 OpenAI 接口,进行复用 + */ + private final OpenAiChatModel openAiChatModel; + + @Override + public ChatResponse call(Prompt prompt) { + return openAiChatModel.call(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return openAiChatModel.stream(prompt); + } + + @Override + public ChatOptions getDefaultOptions() { + return openAiChatModel.getDefaultOptions(); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java new file mode 100644 index 0000000..b98194c --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java @@ -0,0 +1,52 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model.hunyuan; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +/** + * 腾云混元 {@link ChatModel} 实现类 + * + * 1. 混元大模型:基于 知识引擎原子能力 实现 + * 2. 知识引擎原子能力:基于 知识引擎原子能力 实现 + * + * @author fansili + */ +@Slf4j +@RequiredArgsConstructor +public class HunYuanChatModel implements ChatModel { + + public static final String BASE_URL = "https://api.hunyuan.cloud.tencent.com"; + public static final String COMPLETE_PATH = "/v1/chat/completions"; + + public static final String MODEL_DEFAULT = "hunyuan-turbo"; + + public static final String DEEP_SEEK_BASE_URL = "https://api.lkeap.cloud.tencent.com"; + + public static final String DEEP_SEEK_MODEL_DEFAULT = "deepseek-v3"; + + /** + * 兼容 OpenAI 接口,进行复用 + */ + private final ChatModel openAiChatModel; + + @Override + public ChatResponse call(Prompt prompt) { + return openAiChatModel.call(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return openAiChatModel.stream(prompt); + } + + @Override + public ChatOptions getDefaultOptions() { + return openAiChatModel.getDefaultOptions(); + } + +} diff --git a/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/midjourney/api/MidjourneyApi.java b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/midjourney/api/MidjourneyApi.java new file mode 100644 index 0000000..03235af --- /dev/null +++ b/aagro-module-ai/src/main/java/cn/aagro/pp/module/ai/framework/ai/core/model/midjourney/api/MidjourneyApi.java @@ -0,0 +1,351 @@ +package cn.aagro.pp.module.ai.framework.ai.core.model.midjourney.api; + +import cn.hutool.core.util.StrUtil; +import cn.aagro.pp.framework.common.util.json.JsonUtils; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * Midjourney API + * + * @author fansili + * @since 1.0 + */ +@Slf4j +public class MidjourneyApi { + + private final Predicate STATUS_PREDICATE = status -> !status.is2xxSuccessful(); + + private final Function>> EXCEPTION_FUNCTION = + reqParam -> response -> response.bodyToMono(String.class).handle((responseBody, sink) -> { + HttpRequest request = response.request(); + log.error("[midjourney-api] 调用失败!请求方式:[{}],请求地址:[{}],请求参数:[{}],响应数据: [{}]", + request.getMethod(), request.getURI(), reqParam, responseBody); + sink.error(new IllegalStateException("[midjourney-api] 调用失败!")); + }); + + private final WebClient webClient; + + /** + * 回调地址 + */ + private final String notifyUrl; + + public MidjourneyApi(String baseUrl, String apiKey, String notifyUrl) { + this.webClient = WebClient.builder() + .baseUrl(baseUrl) + .defaultHeaders(httpHeaders -> { + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + httpHeaders.setBearerAuth(apiKey); + }) + .build(); + this.notifyUrl = notifyUrl; + } + + /** + * imagine - 根据提示词提交绘画任务 + * + * @param request 请求 + * @return 提交结果 + */ + public SubmitResponse imagine(ImagineRequest request) { + if (StrUtil.isEmpty(request.getNotifyHook())) { + request.setNotifyHook(notifyUrl); + } + String response = post("/submit/imagine", request); + return JsonUtils.parseObject(response, SubmitResponse.class); + } + + /** + * action - 放大、缩小、U1、U2... + * + * @param request 请求 + * @return 提交结果 + */ + public SubmitResponse action(ActionRequest request) { + if (StrUtil.isEmpty(request.getNotifyHook())) { + request.setNotifyHook(notifyUrl); + } + String response = post("/submit/action", request); + return JsonUtils.parseObject(response, SubmitResponse.class); + } + + /** + * 批量查询 task 任务 + * + * @param ids 任务编号数组 + * @return task 任务 + */ + public List getTaskList(Collection ids) { + String res = post("/task/list-by-condition", ImmutableMap.of("ids", ids)); + return JsonUtils.parseArray(res, Notify.class); + } + + private String post(String uri, Object body) { + return webClient.post() + .uri(uri) + .body(Mono.just(JsonUtils.toJsonString(body)), String.class) + .retrieve() + .onStatus(STATUS_PREDICATE, EXCEPTION_FUNCTION.apply(body)) + .bodyToMono(String.class) + .block(); + } + + // ========== record 结构 ========== + + /** + * Imagine 请求(生成图片) + */ + @Data + public static final class ImagineRequest { + + /** + * 垫图(参考图) base64 数组 + */ + private List base64Array; + /** + * 提示词 + */ + private String prompt; + /** + * 通知地址 + */ + private String notifyHook; + /** + * 自定义参数 + */ + private String state; + + public ImagineRequest(List base64Array, String prompt, String notifyHook, String state) { + this.base64Array = base64Array; + this.prompt = prompt; + this.notifyHook = notifyHook; + this.state = state; + } + + public static String buildState(Integer width, Integer height, String version, String model) { + StringBuilder params = new StringBuilder(); + // --ar 来设置尺寸 + params.append(String.format(" --ar %s:%s ", width, height)); + // --niji 模型 + if (ModelEnum.NIJI.getModel().equals(model)) { + params.append(String.format(" --niji %s ", version)); + } else { + params.append(String.format(" --v %s ", version)); + } + return params.toString(); + } + + } + + /** + * Action 请求 + */ + @Data + public static final class ActionRequest { + + private String customId; + private String taskId; + private String notifyHook; + + public ActionRequest(String taskId, String customId, String notifyHook) { + this.customId = customId; + this.taskId = taskId; + this.notifyHook = notifyHook; + } + + } + + /** + * Submit 统一返回 + * + * @param code 状态码: 1(提交成功), 21(已存在), 22(排队中), other(错误) + * @param description 描述 + * @param properties 扩展字段 + * @param result 任务ID + */ + public record SubmitResponse(String code, + String description, + Map properties, + String result) { + } + + /** + * 通知 request + * + * @param id job id + * @param action 任务类型 {@link TaskActionEnum} + * @param status 任务状态 {@link TaskStatusEnum} + * @param prompt 提示词 + * @param promptEn 提示词-英文 + * @param description 任务描述 + * @param state 自定义参数 + * @param submitTime 提交时间 + * @param startTime 开始执行时间 + * @param finishTime 结束时间 + * @param imageUrl 图片url + * @param progress 任务进度 + * @param failReason 失败原因 + * @param buttons 任务完成后的可执行按钮 + */ + public record Notify(String id, + String action, + String status, + + String prompt, + String promptEn, + + String description, + String state, + + Long submitTime, + Long startTime, + Long finishTime, + + String imageUrl, + String progress, + String failReason, + List