112 Commits
Author SHA1 Message Date
蓝白 864435347e 更新描述 2026-08-12 15:36:43 +08:00
蓝白 202b1c430e 优化页面 2026-08-12 14:40:41 +08:00
蓝白 251bbc856c 优化 2026-08-12 14:25:34 +08:00
蓝白 6acdca8c4f 更新描述 2026-08-12 12:55:00 +08:00
蓝白 87610820e4 更新5.5版本 2026-08-11 23:04:19 +08:00
蓝白 1ebe2278f8 更新5.5版本 2026-08-11 23:03:27 +08:00
蓝白 b61c1c1949 优化页面 2026-08-11 23:02:17 +08:00
蓝白 6bf63c01c2 优化配置 2026-08-11 22:58:54 +08:00
蓝白 398d4a1557 优化日志批量写入,增强接口执行的并发 2026-08-11 22:39:05 +08:00
蓝白 44c7d0e427 增加执行SQL的安全校验 2026-08-11 21:32:45 +08:00
蓝白 0a16b23895 修复兼容性问题 2026-08-11 19:05:01 +08:00
蓝白 8635a512b8 优化元数据查询性能 2026-08-11 19:04:30 +08:00
蓝白 cf54f933ec 兼容postgresql数据库 2026-08-11 19:03:09 +08:00
蓝白 852535a8ed 版本升级5.2.0 2026-06-11 11:58:38 +08:00
蓝白 f2df37eefd 版本升级5.2.0 2026-06-11 11:49:26 +08:00
蓝白 a4b592bc22 页面优化 2026-06-11 11:47:33 +08:00
蓝白 da781f1851 优化数据源获取 2026-06-11 11:40:07 +08:00
蓝白 90d1c5c20f 更新 2026-05-28 11:25:53 +08:00
蓝白 1900cff52b 更新页面 2026-05-28 11:17:35 +08:00
蓝白 3642f5658f 更新版本5.1 2026-05-28 11:17:13 +08:00
蓝白 714506a7b3 优化签名认证 2026-05-28 11:09:11 +08:00
蓝白 ab5a7aa01d 更新描述 2026-05-27 15:12:02 +08:00
蓝白 905298e34d 更新脚本 2026-05-25 10:42:18 +08:00
蓝白 65cd42dac3 更新描述 2026-05-24 13:44:34 +08:00
蓝白 cbaac891df 更新描述 2026-05-24 13:43:24 +08:00
蓝白 333ebd0d98 更新描述 2026-05-24 13:25:39 +08:00
蓝白 f79a5fa909 更新描述 2026-05-24 13:05:07 +08:00
蓝白 02840ad077 更新描述 2026-05-24 12:48:29 +08:00
蓝白 9f8f37e938 更换UI 2026-05-24 11:00:10 +08:00
蓝白 90defd5e1d 更新描述 2026-05-24 08:39:43 +08:00
蓝白 b714a734bc 优化UI 2026-05-24 02:01:12 +08:00
蓝白 f93710ad2c 更新版本5.0 2026-05-24 01:31:39 +08:00
蓝白 b2dc24cab5 更新版本5.0 2026-05-23 13:24:51 +08:00
蓝白 2f81e1ec2a 更新版本5.0 2026-05-23 12:03:23 +08:00
蓝白 fd6f689643 更新版本5.0 2026-05-23 10:50:07 +08:00
蓝白 c2afdeee08 修复bug 2026-05-23 10:47:33 +08:00
蓝白 01234a740c 优化日志 2026-05-23 10:35:23 +08:00
蓝白 cad82d44f7 更新5.0版本 2026-05-23 09:49:21 +08:00
蓝白 6acd9315da 更新5.0版本 2026-05-23 01:11:26 +08:00
蓝白 c3c10e4f9a 更新5.0版本 2026-05-23 01:03:46 +08:00
蓝白 ce889dbfb0 优化日志 2026-05-23 01:03:04 +08:00
蓝白 ce0cafd294 新增接口限流 2026-05-23 00:35:51 +08:00
蓝白 61bd7b93d5 新增支持duckdb和dolphindb数据源 2026-05-22 23:21:02 +08:00
蓝白 e0813f0b34 新增指标统计 2026-05-22 23:02:34 +08:00
蓝白 26d07462c6 新增事务和多脚本语法 2026-05-22 23:02:13 +08:00
蓝白 3c5c76b92e 更新描述 2026-05-20 16:35:24 +08:00
蓝白 d9c5bd4047 更新描述 2026-05-09 17:58:03 +08:00
蓝白 f25b5c6d75 更新脚本 2026-05-09 17:41:18 +08:00
蓝白 f255e2355e 更新4.5.0版本 2026-05-09 17:37:12 +08:00
蓝白 7789d4dc9c 使用新的UI界面 2026-05-09 16:29:17 +08:00
蓝白 127d7dfce9 新增接口暂存功能 2026-05-09 16:19:39 +08:00
蓝白 736b500ce5 优化接口缓存问题 2026-05-09 15:28:26 +08:00
蓝白 f94c5e5fbb 更新字段类型 2026-04-30 16:25:33 +08:00
蓝白 25cabceca2 更新版本4.4.0 2026-04-06 22:23:56 +08:00
蓝白 65b9622666 更新版本4.4.0 2026-04-06 22:22:37 +08:00
蓝白 7cc3a9fd20 更新页面 2026-04-06 22:21:32 +08:00
蓝白 b80d8b293b 数据源菜单拆分 2026-04-06 22:04:46 +08:00
蓝白 79c1ce8e11 升级spring boot4版本 2026-04-06 13:03:27 +08:00
蓝白 2423ffd7ae 更新 4.3版本 2026-03-21 15:20:45 +08:00
蓝白 6cf3c47d09 更新 4.3版本 2026-03-21 15:12:07 +08:00
蓝白 6e2a0e2cb3 优化页面 2026-03-21 15:11:25 +08:00
蓝白 6d83e16b1c 修复序列化问题 2026-03-21 15:10:25 +08:00
蓝白 25b991c13a 更新脚本 2026-03-10 13:58:04 +08:00
蓝白 32b1c1045b 更新版本 4.2 2026-03-07 15:39:14 +08:00
蓝白 458266297c 表下拉框添加悬浮提示表名 2026-03-07 15:38:25 +08:00
蓝白 4139d5bd7b 修复post请求异常 2026-03-07 15:33:55 +08:00
蓝白 8b09976db3 更新版本 2026-02-04 14:03:01 +08:00
蓝白 8e61b43c3f 更新版本 2026-02-04 14:01:21 +08:00
蓝白 b2f2772a17 更新页面 2026-02-04 13:59:58 +08:00
蓝白 4314d3410f 更新描述 2026-02-04 13:57:12 +08:00
蓝白 a2a07a2549 修复调用失效问题 2026-02-04 13:56:34 +08:00
蓝白 25c6b49775 修复调用失效问题 2026-02-04 13:56:19 +08:00
蓝白 ee9e51947e 修复连接泄露问题 2026-02-03 21:37:16 +08:00
蓝白 49e05081f9 修复连接池泄露问题 2026-02-03 21:33:09 +08:00
蓝白 9420a17bcc 修复bug 2026-02-03 12:56:29 +08:00
蓝白 3b6a95c731 更新 2026-01-20 19:54:45 +08:00
蓝白 c59192c144 更新版本至4.0 2026-01-18 16:18:50 +08:00
蓝白 299bec29e0 更新版本至4.0 2026-01-18 15:58:26 +08:00
蓝白 86bb9c2f2a 更新版本至4.0 2026-01-18 15:56:05 +08:00
蓝白 121b6653ab 更新版本至4.0 2026-01-18 15:45:01 +08:00
蓝白 9fc3990411 更新描述 2026-01-13 23:03:46 +08:00
蓝白 7a53390946 更新描述 2026-01-04 16:47:29 +08:00
蓝白 2e8a930b16 修复ck链接问题 2025-12-30 14:32:01 +08:00
蓝白 444c288079 更新描述 2025-12-26 13:04:25 +08:00
蓝白 669c8261f5 修复数据库类型图标匹配问题 2025-12-25 22:21:57 +08:00
蓝白 2b752f56e1 更新驱动 2025-12-25 11:20:36 +08:00
蓝白 9b8858999c 优化查询返回字段排序问题 2025-12-25 11:15:11 +08:00
蓝白 548919485c 更新页面 2025-12-24 15:43:18 +08:00
蓝白 464ee5c738 更新驱动 2025-12-24 15:39:43 +08:00
蓝白 623599998f 更新描述 2025-10-27 23:52:50 +08:00
蓝白 37ba418f87 更新描述 2025-10-18 10:51:49 +08:00
蓝白 affcfb9234 更新描述 2025-10-13 16:01:52 +08:00
蓝白 add6362fb8 更新jdk环境 2025-10-01 21:09:57 +08:00
蓝白 ea85ff64e1 更新版本3.5.2 2025-10-01 11:04:35 +08:00
蓝白 f5ff5e4f54 更新版本3.5.2 2025-10-01 11:02:05 +08:00
蓝白 2183f2d2f6 更新版本3.5.2 2025-10-01 10:55:47 +08:00
蓝白 4e477d86c3 更新版本3.5.2 2025-10-01 10:40:07 +08:00
蓝白 284eb0394d 优化页面参数设置和样式图标 2025-10-01 10:39:06 +08:00
蓝白 8e56270cf5 1、接口日志新增响应结果
2、优化接口加载
3、优化异常处理
4、优化日志查询
2025-10-01 10:04:28 +08:00
蓝白 6b0c4bea49 版本更新至3.5.1 2025-09-30 20:05:18 +08:00
蓝白 9edd1c3e00 优化请求参数和优化测试bug 2025-09-30 20:04:53 +08:00
蓝白 3f0cf9af78 优化接口测试和token有效期 2025-09-30 19:51:07 +08:00
蓝白 ad8636ced0 更新描述 2025-09-25 10:47:18 +08:00
蓝白 03486448f1 升级达梦数据库驱动 2025-09-12 22:48:05 +08:00
蓝白 5925263443 更新描述 2025-07-16 12:30:00 +08:00
蓝白 dc7cc0bd79 跳转字段类型 2025-07-16 12:29:20 +08:00
蓝白 a805e90911 版本更新至3.5.0 2025-06-04 10:47:48 +08:00
蓝白 9225973cf8 spring boot更新至3.5.0 2025-06-04 10:47:31 +08:00
蓝白 3243a4b4e3 优化页面 2025-06-04 10:46:53 +08:00
蓝白 fd45c67416 更换本地线程对象 2025-06-04 10:34:40 +08:00
蓝白 75025818f5 appkey认证替换appcode 2025-06-04 10:34:17 +08:00
蓝白 0e954b2d1d 修复code认证问题 2025-05-21 18:55:51 +08:00
267 changed files with 5637 additions and 2437 deletions

No files matched your search

+7 -4
View File
@@ -45,14 +45,14 @@ buildNumber.properties
####################### svn ##############################
.svn
####################### eclipse ##############################
.project
.settings
bin/
logs/
.classpath
crabc-web/
.classpathc
开发计划.md
####################### idea ##############################
.idea
@@ -67,4 +67,7 @@ logs/
.flattened-pom.xml
*.log
tmp/
AGENTS.md
tmp/
.pnpm-store/
.omx/
+2 -2
View File
@@ -1,5 +1,5 @@
FROM openjdk:17-jdk-alpine
LABEL version="2.3.2"
FROM eclipse-temurin:21-jdk-alpine
LABEL version="5.0.0"
COPY crabc-admin.jar /app.jar
ENV LANG="en_US.UTF-8"
+103 -76
View File
@@ -1,21 +1,24 @@
# 由来
crabc由create + abc 组合而来,意在为项目系统构建的通用的接口底座。
# 介绍
**crabc** 是低代码接口开发平台,企业数据服务平台,深度整合SpringBoot和Mybatis实现动态数据源和动态SQL。
支持接入(mysql、oracle、postgresql、sqlserver、达梦、TiDB、es和mongodb)等SQL或/NoSQL数据源,
**ApiGo** 是接口开发平台,企业项目脚手架,sql2Api,数据预览等。深度整合SpringBoot和Mybatis实现动态数据源和动态SQL、函数和存储过程。
支持接入(mysql、oracle、postgresql、sqlserver、达梦、TiDB、es和hive)等SQL或/NoSQL数据源,
在线可视化编写SQL后即可快速生成接口对外提供服务,接口一键上下线。减少通用接口的SQL编写,让开发人员专注更复杂的业务逻辑实现。
支持Mybatis中if等标签语法、数据脱敏、数据转换等功能,集成微服务网关支持接入第三方接口、权限认证、限流、缓存、告警监控和统一日志等一站式API数据服务。
支持Mybatis中if等标签语法、数据脱敏、数据转换、国密加密、协同开发、接口编排等功能,可集成微服务网关支持接入第三方接口、权限认证、限流、缓存、告警监控和统一日志等一站式API数据服务,
助力于企业数据资产价值升级,成为企业数字化转型的重要推动力。
![img.png](doc/home.png)
## 功能
## 社区版功能
- **工作台**:可视化编写SQL快速的对外发布成API接口,支持多SQL同时执行、Mybatis标签语法,实现动态SQL和动态标签
- **数据源管理**:动态加载数据库,支持多种SQL/NoSQL数据库。
- **应用列表**:Code认证、签名认证的应用创建管理以及对API调用在线授权。
- **接口列表**:查看开发中和已发布的API接口,可进行上下线管理,编辑升级等
- **数据源**:数据源列表和数据查询,支持多种SQL/NoSQL数据库添加。
- **应用凭证**:AppKey认证、签名认证的应用创建管理以及对API调用在线授权。
- **接口列表**:查看开发中和已发布的API接口,可进行上下线管理,限流控制等
- **接口文档**:生成在线接口文档,支持在线测试。
- **指标统计**:展示场景的接口调用指标统计
- **接口日志**:查看发布的接口被调用日志列表和请求详情。
## 接口开发流程
![img.png](doc/dev_flow.png)
## 模块
~~~
cn.crabc
@@ -28,40 +31,72 @@ cn.crabc
├── crabc-spring-boot-starter // 第三方项目maven集成依赖包
├── db // SQL脚本
├──pom.xml // 依赖
~~~
~~~
## 版本说明
dev分支框架SpringBoot已升级至3.4,jdk需17及以上版本 \
2.x分支框架SpringBoot还是2.x
dev分支框架SpringBoot已升级至4.1.0,jdk需21及以上版本 \
jdk8分支框架SpringBoot是2.x + jdk8
## 社区版压测报告
> 压测工具:JMeter | 部署环境:4c8g 服务器,jdk21
| 压测参数 | 值 |
|:--|:----------|
| 压测接口 | 一个 |
| 并发线程数 | 100 |
| 执行时长 | 180 秒 |
| 核心指标 | 数值 |
|:--|:---------------|
| **QPS(吞吐量)** | **287.8 请求/秒** |
| 总请求数 | 51,959 |
| 成功数 | 51,959 |
| 失败数 | 0 |
| **失败率** | **0.00%** |
| 平均响应时间 | 286 ms |
| 最大响应时间 | 1,118 ms |
| P50 | 291 ms |
| P90 | 457 ms |
| P95 | 602 ms |
| P99 | 785 ms |
## 企业版
https://apigo.cn
## 企业版架构图
## 运行启动
```
1、先执行db/dml.sql脚本,创建库表和初始化数据
2、在编辑工具中运行启动 crabc-admin/ AdminApplication.java
1、先执行db/dml.sql脚本,创建库表和初始化数据,支持mysql和postgresql,建议mysql 8.0+版本和postgresql 14+版本
2、在编辑工具中运行启动 crabc-api/ AdminApplication.java
```
访问地址:http://127.0.0.1:9377 \
账号密码:admin/admin123
## 官网
官网:https://www.crabc.cn/ \
使用指南:https://www.crabc.cn/introduction/ \
github:https://github.com/pingapi/crabc-api
官网:https://apigo.cn
使用指南:http://docs.apigo.cn
github:https://github.com/pingapi/apigo
## 商用授权
已申请软件著作,开源版仅供个人学习使用 \
企业版功能更强大,商用请加群联系\
## 源码地址
github: https://github.com/pingapi/crabc-api \
gitee: https://gitee.com/linebyte/crabc
github: https://github.com/pingapi/apigo \
gitee: https://gitee.com/pingapi/crabc-api
## Maven集成crabc-api
## Maven集成apigo
```
<dependency>
<groupId>cn.crabc</groupId>
<artifactId>crabc-spring-boot-starter</artifactId>
<version>3.3.2</version>
<version>5.5.0</version>
</dependency>
```
在程序启动类中添加下面注解
@@ -72,9 +107,14 @@ gitee: https://gitee.com/linebyte/crabc
## Docker启动
先执行db/dml.sql脚本,创建库表和初始化数据,建议mysql 8.0+版本
X86/AMD架构
```
docker pull crabc/crabc-admin:latest
docker run -p 9377:9377 -e DB_URL='jdbc:mysql://localhost:3306/crabc' -e DB_USER=root -e DB_PWD=root -d --name crabc-admin crabc/crabc-admin:latest
docker run -p 9377:9377 -e DB_URL=jdbc:mysql://localhost:3306/apigo -e DB_USER=root -e DB_PWD=root -d --name apigo crabc/apigo:latest
```
ARM架构
```
docker run -p 9377:9377 -e DB_URL=jdbc:mysql://localhost:3306/apigo -e DB_USER=root -e DB_PWD=root -d --name apigo crabc/crabc-admin:latest
```
访问地址:http://127.0.0.1:9377
账号密码:admin/admin123
@@ -82,72 +122,59 @@ docker run -p 9377:9377 -e DB_URL='jdbc:mysql://localhost:3306/crabc' -e DB_USER
## Windows桌面运行
先执行db/dml.sql脚本,创建库表和初始化数据,建议mysql 8.0+版本
```
java -jar crabc-admin.jar --spring.datasource.url=jdbc:mysql://127.0.0.1:3306/crabc --spring.datasource.username=root --spring.datasource.password=root
java -jar crabc-api.jar --spring.datasource.url=jdbc:mysql://127.0.0.1:3306/crabc --spring.datasource.username=root --spring.datasource.password=root
```
## 企业版和开源版对比
| 功能 | 社区版 | 企业版 |
|--------|------------------------------| --------------------------|
| 架构设计 | 单体 | 微服务 |
| 接口创建模式 | SQL脚本 | SQL脚本、图表、代理转发 |
| 动态标签 | 支持 | 支持 |
| 动态路由 | 不支持 | 支持 |
| 数据库 | SQL:mysql/oracle/postgresql/sqlserver <br>TiDB/OpenGauss/Doris |SQL:mysql/oracle/postgresql/sqlserver/tidb<br>/opengauss/doris/oceanbase/dm/gbase <br> NoSQL:es/mongodb <br> TSDB: TDengine |
| 认证方式 | 无/Code认证/签名认证 | 无/Code认证/签名认证 |
| SQL类型 | 查询SQL | 查询/插入/更新/删除等SQL |
| 结果类型 | json | json |
| 接口日志 | 支持 | 支持 |
| 数据转换 | 不支持 | 支持 |
| 嵌套结构 | 不支持 | 支持 |
| 接口市场 | 不支持 | 支持 |
| 申请审批 | 不支持 | 支持 |
| 接口授权 | 不支持 | 支持 |
| 系统权限 | 不支持 | 支持 |
| 用户管理 | 不支持 | 支持 |
| 数据脱敏 | 不支持 | 支持 |
| 版本管理 | 不支持 | 支持
| 接口缓存 | 不支持 | 支持 |
| 限流熔断 | 不支持 | 限流和熔断 |
| IP黑白名单 | 不支持 | 支持 |
| 监控报表 | 支持 | 支持 |
| 集群模式 | 不支持 | 支持 |
## 效果截图
### 接口开发
![img.png](doc/sql.png)
![img.png](doc/dev.png)
#### 接口属性
![img.png](doc/detail.png)
#### 请求参数
#### 返回参数
![img.png](doc/param.png)
#### 在线预览
![img_2.png](doc/img_test.png)
#### 接口列表
![img.png](doc/api.png)
#### 数据库类型
#### 接口列表
![img.png](doc/apis.png)
#### 接口限流
![img.png](doc/limit.png)
#### 指标统计
![img.png](doc/monitor.png)
#### 日志管理
![img.png](doc/logs.png)
#### 数据源列表
![img.png](doc/db.png)
### 限流熔断
![img.png](doc/flow.png)
## 产品使用登记
以下为部分接入crabc-api的用户 ,<a href="https://gitee.com/linebyte/crabc/issues/IA98CA" target="_blank"> 点击进行登记</a>
| 公司机构 |
|:-------------|
| 西部通信研究院 |
| 青岛巴士科技有限公司 |
| 阳光智维科技股份有限公司 |
| 一步数据科技有限公司 |
| 像素云图科技有限公司 |
| 前景科技有限公司 |
| 方寸图信息有限公司 |
| 鹭栖技术信息公司 |
| 循脉医疗科技有限公司 |
| 云上贵州大数据有限公司 |
#### 数据查询
![img.png](doc/data.png)
#### 数据源类型
![img.png](doc/type.png)
## 交流群
**进群前请先点【Star】谢谢**
QQ群:748993036
QQ群(请先Star):748993036
商用咨询加微信 (请备注crabc)
商用咨询加微信 (请备注apigo)
![img.png](doc/img.png)
![img.png](doc/img.png)
## 产品使用登记
以下为部分接入apigo的用户 ,<a href="https://gitee.com/linebyte/crabc/issues/IA98CA" target="_blank"> 点击进行登记</a>
| 产品使用公司 |
|:-------|
| 真情巴士 |
| 阳光智维 |
| 一步数据 |
| 像素云图 |
| 前景科技 |
| 方寸图信息 |
| 鹭栖技术 |
| 循脉医疗 |
| 云上贵州 |
| 蓝典科技 |
| 某市供电局 |
| 启瑞药业 |
| 今日资本 |
| 智汇大成 |
| 中科世通 |
@@ -5,14 +5,14 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.crabc</groupId>
<artifactId>crabc-boot</artifactId>
<artifactId>crabc-api</artifactId>
<version>${revision}</version>
</parent>
<artifactId>crabc-admin</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
@@ -19,7 +19,7 @@ package cn.crabc.core.admin;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
/**
@@ -33,6 +33,6 @@ import org.springframework.context.annotation.ComponentScan;
public class AdminApplication {
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
System.out.println("crabc-admin start success!!!");
System.out.println("apigo-admin start success!!!");
}
}
@@ -0,0 +1,30 @@
package cn.crabc.core.admin.config;
import org.apache.ibatis.mapping.DatabaseIdProvider;
import org.apache.ibatis.mapping.VendorDatabaseIdProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
/**
* MyBatis 配置类
* 配置多数据库支持
*/
@Configuration
public class MybatisConfig {
/**
* 配置数据库厂商标识
* 用于支持 MySQL 和 PostgreSQL
*/
@Bean
public DatabaseIdProvider databaseIdProvider() {
VendorDatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
Properties properties = new Properties();
properties.setProperty("MySQL", "mysql");
properties.setProperty("PostgreSQL", "postgresql");
databaseIdProvider.setProperties(properties);
return databaseIdProvider;
}
}
@@ -1,38 +1,42 @@
# Tomcat
# Server
server:
port: 9377
port: ${SERVER_PORT:9377}
tomcat:
# 连接建立后,等待客户端发送HTTP请求头超时
connection-timeout: 30s
# Keep-Alive 长连接空闲超时(请求处理完,等待下一次请求)
keep-alive-timeout: 20s
spring:
application:
name: crabc-admin
name: crabc-api
main:
banner-mode: off
threads:
virtual:
enabled: true
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
time-zone: Asia/Shanghai
default-property-inclusion: non_null
core:
recycler-pool: thread-local # Jackson3性能恢复
datasource:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://127.0.0.1:3306/crabc?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
password:
# mybatis配置
mybatis:
# 搜索指定包别名
typeAliasesPackage: cn.crabc.core.admin.entity
# 配置mapper的扫描,找到所有的mapper.xml映射文件
# 配置 mapper 扫描,找到所有 mapper.xml 映射文件
mapperLocations: classpath*:mapper/**/*.xml
config-location: classpath:mybatis-config.xml
# configuration:
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
pagehelper:
autoRuntimeDialect: true # 开启多数据源分页识别
# es健康检查
management:
health:
elasticsearch:
enabled: false
# 签名认证过期时间
crabc:
auth:
expiresTime: 10
api-log:
response-body: false # 日志是否保存响应结果
@@ -12,4 +12,10 @@ PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
<setting name="callSettersOnNulls" value="true" />
<setting name="mapUnderscoreToCamelCase" value="true" /><!-- 驼峰下划线映射 -->
</settings>
<!-- 数据库厂商标识配置,用于支持多数据库 -->
<databaseIdProvider type="DB_VENDOR">
<property name="MySQL" value="mysql"/>
<property name="PostgreSQL" value="postgresql"/>
</databaseIdProvider>
</configuration>
@@ -3,7 +3,7 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>crabc-boot</artifactId>
<artifactId>crabc-api</artifactId>
<groupId>cn.crabc</groupId>
<version>${revision}</version>
</parent>
@@ -12,8 +12,8 @@
<artifactId>crabc-core</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
@@ -39,6 +39,11 @@
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.17.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
@@ -72,4 +77,4 @@
<version>${jdk18on.version}</version>
</dependency>
</dependencies>
</project>
</project>
@@ -0,0 +1,250 @@
package cn.crabc.core.app.api;
import cn.crabc.core.app.entity.BaseApiParam;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.enums.ResultTypeEnum;
import cn.crabc.core.app.service.core.IBaseDataService;
import cn.crabc.core.app.util.ApiThreadLocal;
import cn.crabc.core.app.util.Result;
import cn.crabc.core.datasource.constant.BaseConstant;
import cn.crabc.core.datasource.enums.ErrorStatusEnum;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* API 接收处理层
*
* @author yuqf
*/
@RestController
@RequestMapping("/api/web")
public class ApiServiceController {
@Autowired
private IBaseDataService baseDataService;
// 参数安全配置
private static final int MAX_PARAM_VALUE_LENGTH = 10000; // 单个参数值最大长度
private static final int MAX_PARAMS_COUNT = 100; // 最大参数数量
private static final Pattern DANGEROUS_PATTERN = Pattern.compile(
".*(<script|javascript:|onerror=|onload=|eval\\(|exec\\(|<iframe).*",
Pattern.CASE_INSENSITIVE
);
/**
* API GET请求方法
*
* @return
*/
@RequestMapping(value = "/**", method = {RequestMethod.GET, RequestMethod.DELETE})
public Result getService(@RequestParam(required = false) Map<String, Object> paramMap, HttpServletResponse response) {
// 1. 获取API配置信息
ApiInfoDTO api = ApiThreadLocal.get();
if (api == null) {
response.setStatus(400);
return Result.error(ErrorStatusEnum.API_INVALID.getCode(), ErrorStatusEnum.API_INVALID.getMassage());
}
// 2. 参数安全验证
if (!validateParamsSafety(paramMap)) {
response.setStatus(400);
return Result.error(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), "参数包含非法内容");
}
// 3. 参数校验
if (!validateParams(api, paramMap)) {
response.setStatus(400);
return Result.error(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), ErrorStatusEnum.PARAM_NOT_FOUNT.getMassage());
}
// 4. 执行API
return executeApi(api, paramMap);
}
/**
* API post请求
*
* @param paramMap
* @param body
* @return
*/
@RequestMapping(value = "/**", method = {RequestMethod.POST, RequestMethod.PUT})
public Result postService(@RequestParam(required = false) Map<String, Object> paramMap, @RequestBody(required = false) Object body, HttpServletResponse response) {
// 1. 获取API配置信息
ApiInfoDTO api = ApiThreadLocal.get();
if (api == null) {
response.setStatus(400);
return Result.error(ErrorStatusEnum.API_INVALID.getCode(), ErrorStatusEnum.API_INVALID.getMassage());
}
// 2. 合并请求参数
Map<String, Object> mergedParams = mergeParams(paramMap, body);
// 3. 参数安全验证
if (!validateParamsSafety(mergedParams)) {
response.setStatus(400);
return Result.error(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), "参数包含非法内容");
}
// 4. 参数校验
if (!checkParams(api.getRequestParams(), mergedParams)) {
response.setStatus(400);
return Result.error(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), ErrorStatusEnum.PARAM_NOT_FOUNT.getMassage());
}
// 5. 设置分页配置
mergedParams.put(BaseConstant.PAGE_SETUP, api.getPageSetup());
// 6. 执行API
return executeApi(api, mergedParams);
}
/**
* 校验参数
*
* @param apiParams
* @param paramMap
* @return
*/
public boolean checkParams(List<BaseApiParam> apiParams, Map<String, Object> paramMap) {
if (apiParams == null || paramMap == null) {
return true;
}
for (BaseApiParam param : apiParams) {
String paramName = param.getParamName();
String paramType = param.getParamType();
Object value = paramMap.get(paramName);
// 1. 必填参数校验
if ("Y".equals(param.getRequired()) && !paramMap.containsKey(paramName)) {
return false;
}
// 2. 参数类型验证和转换
if (value != null && !"".equals(value)) {
// Array类型参数处理
if ("Array".equalsIgnoreCase(paramType)) {
String[] values = value.toString().split(",");
// 数组元素数量限制,防止超大数组
if (values.length > 1000) {
return false;
}
paramMap.put(paramName, Arrays.asList(values));
}
// Number类型验证
else if ("Number".equalsIgnoreCase(paramType) || "Integer".equalsIgnoreCase(paramType)) {
try {
Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return false;
}
}
} else if ("Y".equals(param.getRequired())) {
// 必填参数值为空
return false;
}
}
return true;
}
/**
* 验证请求参数
*/
private boolean validateParams(ApiInfoDTO api, Map<String, Object> paramMap) {
if (paramMap == null) {
return true;
}
paramMap.put(BaseConstant.PAGE_SETUP, api.getPageSetup() == null ? 0 : api.getPageSetup());
return checkParams(api.getRequestParams(), paramMap) &&
!(api.getPageSetup() == 1 && !paramMap.containsKey(BaseConstant.PAGE_NUM));
}
/**
* 参数安全验证:检查参数数量、长度和危险内容
*/
private boolean validateParamsSafety(Map<String, Object> paramMap) {
if (paramMap == null || paramMap.isEmpty()) {
return true;
}
// 1. 参数数量限制,防止参数过多导致的DoS攻击
if (paramMap.size() > MAX_PARAMS_COUNT) {
return false;
}
// 2. 逐个检查参数值
for (Map.Entry<String, Object> entry : paramMap.entrySet()) {
Object value = entry.getValue();
if (value == null) {
continue;
}
String valueStr = value.toString();
// 3. 参数长度限制
if (valueStr.length() > MAX_PARAM_VALUE_LENGTH) {
return false;
}
// 4. 检查危险内容(XSS、脚本注入等)
if (DANGEROUS_PATTERN.matcher(valueStr).matches()) {
return false;
}
}
return true;
}
/**
* 合并请求参数
*/
private Map<String, Object> mergeParams(Map<String, Object> paramMap, Object body) {
Map<String, Object> mergedParams = new HashMap<>();
if (paramMap != null) {
mergedParams.putAll(paramMap);
}
if (body instanceof Map) {
mergedParams.putAll((Map<String, Object>) body);
}
return mergedParams;
}
/**
* 执行API调用
*/
private Result executeApi(ApiInfoDTO api, Map<String, Object> params) {
if (params == null) {
params = new HashMap<>();
}
// 事务开关属于接口配置的内部执行参数,覆盖同名请求参数以避免外部调用篡改事务边界。
params.put(BaseConstant.TRANSACTION_ENABLED, normalizeTransactionEnabled(api.getTransactionEnabled()));
// 执行数据查询
Object data = baseDataService.execute(api.getDatasourceId().toString(), api.getDatasourceType(),
api.getSchemaName(), api.getSqlScript(), params);
// 根据返回类型处理结果
if (ResultTypeEnum.ONE.getName().equals(api.getResultType()) && data instanceof List) {
List<Object> list = (List<Object>) data;
return Result.success(list.isEmpty() ? null : list.get(0));
}
return Result.success(data);
}
/**
* 事务字段持久化使用1/0,执行层只需要稳定的数字语义。
*/
private Integer normalizeTransactionEnabled(Integer transactionEnabled) {
return transactionEnabled == null ? 0 : transactionEnabled;
}
}
@@ -1,5 +1,6 @@
package cn.crabc.core.app.config;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
@@ -13,24 +14,24 @@ import java.util.concurrent.TimeUnit;
public class CacheConfig {
/**
* 临时缓存对象(5分钟)
* 临时缓存对象(15分钟)
* @return
*/
@Bean("dataCache")
public Cache<String, Object> dataCache() {
return Caffeine.newBuilder()
.expireAfterWrite(300, TimeUnit.SECONDS)
.expireAfterWrite(15, TimeUnit.MINUTES)
.build();
}
/**
* 缓存API对象 10小时
* 缓存API对象 30分钟
* @return
*/
@Bean("apiCache")
public Cache<String, Object> apiCaffeine() {
public Cache<String, ApiInfoDTO> apiCaffeine() {
return Caffeine.newBuilder()
.expireAfterAccess(10, TimeUnit.HOURS)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build();
}
@@ -45,7 +46,7 @@ public class CacheConfig {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
// 设置最后一次写入或访问后经过固定时间过期
.expireAfterAccess(1800, TimeUnit.SECONDS);
.expireAfterAccess(15, TimeUnit.MINUTES);
cacheManager.setCaffeine(caffeine);
return cacheManager;
}
@@ -56,7 +56,10 @@ public class DataSourceConfig {
dataSource.setTestWhileIdle(true);
dataSource.setTestOnBorrow(false);
dataSource.setTestOnReturn(false);
dataSource.setTestOnReturn(false);
// 连接泄漏检测配置
dataSource.setRemoveAbandoned(true);
dataSource.setRemoveAbandonedTimeout(300);
dataSource.setLogAbandoned(true);
JdbcDataSourceRouter dynamic = new JdbcDataSourceRouter();
dynamic.setTargetDataSources(new HashMap<>());
// 设置默认数据源
@@ -3,6 +3,7 @@ package cn.crabc.core.app.config;
import cn.crabc.core.app.filter.ApiFilter;
import cn.crabc.core.app.filter.AuthInterceptor;
import cn.crabc.core.app.filter.JwtInterceptor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -10,7 +11,6 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 注册拦截器
*
@@ -41,6 +41,8 @@ public class InterceptorConfig implements WebMvcConfigurer {
.addPathPatterns("/api/web/**"); // 需要拦截的请求
}
@Value("${crabc.api-log.response-body:false}")
public Boolean responseBody = false;
/**
* 日志过滤器
* @return
@@ -49,7 +51,7 @@ public class InterceptorConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean builderRegistrationBean(){
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new ApiFilter());
registration.setFilter(new ApiFilter(responseBody));
registration.addUrlPatterns("/api/web/*");
registration.setName("apiFilter");
registration.setOrder(-1);
@@ -1,8 +1,8 @@
package cn.crabc.core.app.config;
import org.springframework.boot.web.error.ErrorPage;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -3,6 +3,7 @@ package cn.crabc.core.app.controller;
import cn.crabc.core.app.entity.BaseApiInfo;
import cn.crabc.core.app.entity.BaseAppApi;
import cn.crabc.core.app.entity.param.ApiInfoParam;
import cn.crabc.core.app.entity.param.ApiRateLimitParam;
import cn.crabc.core.app.entity.vo.*;
import cn.crabc.core.app.service.system.IBaseApiInfoService;
import cn.crabc.core.datasource.util.PageInfo;
@@ -15,8 +16,6 @@ import org.springframework.web.bind.annotation.*;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
@@ -41,7 +40,7 @@ public class ApiInfoController {
* @return
*/
@GetMapping("/page")
public Result page(String keyword, String devType, Integer pageNum, Integer pageSize) {
public Result page(@RequestParam(required = false) String keyword, String devType, Integer pageNum, Integer pageSize) {
PageInfo<BaseApiInfo> page = apiInfoService.getApiPage(keyword, devType, pageNum, pageSize);
return Result.success(page);
}
@@ -202,6 +201,28 @@ public class ApiInfoController {
return Result.success();
}
/**
* 查询发布接口限流配置
*
* @param apiId 接口ID
* @return 限流配置
*/
@GetMapping("/rateLimit")
public Result getRateLimit(Long apiId) {
return Result.success(apiInfoService.getRateLimit(apiId));
}
/**
* 保存或清空发布接口限流配置
*
* @param param 限流配置
* @return 影响行数
*/
@PostMapping("/rateLimit")
public Result saveRateLimit(@RequestBody ApiRateLimitParam param) {
return Result.success(apiInfoService.saveRateLimit(param));
}
/**
* 查询关联应用的API
*
@@ -0,0 +1,112 @@
package cn.crabc.core.app.controller;
import cn.crabc.core.app.entity.param.ApiLogParam;
import cn.crabc.core.app.service.system.IBaseApiLogService;
import cn.crabc.core.datasource.util.PageInfo;
import cn.crabc.core.app.util.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* API访问日志
*
* @author yuqf
*/
@RestController
@RequestMapping("/api/box/sys/api/log")
public class ApiLogController {
@Autowired
private IBaseApiLogService iBaseApiLogService;
/**
* 日志分页
* @param apiLogParam
* @return
*/
@PostMapping("/page")
public Result logList(@RequestBody ApiLogParam apiLogParam) {
PageInfo page = iBaseApiLogService.page(apiLogParam);
return Result.success(page);
}
/**
* 日志摘要统计
*
* @param apiLogParam 统计筛选条件,未传时间时默认近七天
* @return 监控页顶部指标卡数据
*/
@PostMapping("/summary")
public Result summary(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.summary(apiLogParam));
}
/**
* 日志每日调用趋势
*
* @param apiLogParam 统计筛选条件,未传时间时默认近七天
* @return 按天聚合的调用趋势
*/
@PostMapping("/dailyTrend")
public Result dailyTrend(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.dailyTrend(apiLogParam));
}
/**
* 日志请求结果占比
*
* @param apiLogParam 统计筛选条件,未传时间时默认近七天
* @return success/fail等状态分组计数
*/
@PostMapping("/statusPie")
public Result statusPie(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.statusPie(apiLogParam));
}
/**
* 接口调用量Top10
*
* @param apiLogParam 统计筛选条件,未传时间时默认近七天
* @return 按接口聚合的调用量Top10
*/
@PostMapping("/topApis")
public Result topApis(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.topApis(apiLogParam));
}
/**
* 接口耗时Top10
*
* @param apiLogParam 统计筛选条件,服务层固定只统计成功调用
* @return 按接口平均耗时聚合的成功调用Top10
*/
@PostMapping("/topCostApis")
public Result topCostApis(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.topCostApis(apiLogParam));
}
/**
* IP访问量Top10
*
* @param apiLogParam 统计筛选条件,未传时间时默认近七天
* @return 按request_ip分组聚合的访问量Top10
*/
@PostMapping("/topIps")
public Result topIps(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.topIps(apiLogParam));
}
/**
* 日志统计兼容接口
*
* @param apiLogParam 统计筛选条件,未传时间时默认近七天
* @return 监控页指标卡和图表数据
*/
@PostMapping("/statistics")
public Result statistics(@RequestBody ApiLogParam apiLogParam) {
return Result.success(iBaseApiLogService.statistics(apiLogParam));
}
}
@@ -0,0 +1,195 @@
package cn.crabc.core.app.controller;
import cn.crabc.core.app.entity.param.ApiTestParam;
import cn.crabc.core.app.entity.vo.PreviewVO;
import cn.crabc.core.app.enums.ResultTypeEnum;
import cn.crabc.core.app.service.core.IBaseDataService;
import cn.crabc.core.app.util.Result;
import cn.crabc.core.datasource.constant.BaseConstant;
import cn.crabc.core.datasource.enums.ErrorStatusEnum;
import cn.crabc.core.datasource.exception.CustomException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import tools.jackson.databind.json.JsonMapper;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* API运行和测试
*
* @author yuqf
*/
@RestController
@RequestMapping("/api/box/sys/test")
public class ApiTestController {
@Autowired
private IBaseDataService baseDataService;
@Autowired
private JsonMapper jsonMapper;
/**
* 运行预览
*
* @param api
* @return
*/
@PostMapping("/running")
public Result runApiSql(@RequestBody ApiTestParam api) {
if (api.getDatasourceId() == null) {
return Result.error(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), ErrorStatusEnum.PARAM_NOT_FOUNT.getMassage());
}
PreviewVO previewVO = baseDataService.sqlPreview(api.getDatasourceId(),api.getDatasourceType(), api.getSchemaName(), api.getSqlScript());
return Result.success(previewVO);
}
/**
* 在线测试API
*
* @param params
* @return
*/
@PostMapping("/verify/{apiId}")
public Result testApiSql(@PathVariable Long apiId, @RequestBody ApiTestParam params) throws Exception {
// 参数校验
if (params.getDatasourceId() == null) {
return Result.error(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), ErrorStatusEnum.PARAM_NOT_FOUNT.getMassage());
}
// 设置默认数据源类型
params.setDatasourceType(params.getDatasourceType() == null ? "mysql" : params.getDatasourceType());
try {
// 构建参数Map
Map<String, Object> paramsMap = buildParamsMap(params);
// 执行SQL
Object data = baseDataService.execute(
params.getDatasourceId(),
params.getDatasourceType(),
params.getSchemaName(),
params.getSqlScript(),
paramsMap
);
// 处理返回结果
return Result.success(formatResultData(data, params.getResultType()));
} catch (Exception e) {
if(e instanceof CustomException) {
CustomException ex = (CustomException) e;
return Result.error("测试异常,请检查参数或者SQL是否正常!" +ex.getMsg());
}
return Result.error("测试异常,请检查参数或者SQL是否正常!");
}
}
/**
* 构建运行时参数Map,供SQL执行层使用。
* body 参数来自 bodyData(页面Body编辑器,可能是JSON字符串或对象),
* query 参数来自 requestParams/requestParam 数组中 paramPosition=query 的项。
*/
private Map<String, Object> buildParamsMap(ApiTestParam params) throws Exception {
Map<String, Object> paramsMap = new HashMap<>();
// 1. 解析 body 参数:bodyData 可能是 JSON 字符串、双重编码字符串或对象
Object bodyData = params.getBodyData();
if (bodyData instanceof String) {
String bodyStr = (String) bodyData;
// 兼容双重序列化:bodyData 可能以引号开头(JSON 字符串字面量),先剥掉外层引号还原 JSON 文本
if (bodyStr.startsWith("\"") && bodyStr.endsWith("\"")) {
bodyStr = jsonMapper.readValue(bodyStr, String.class);
}
if (bodyStr.startsWith("{")) {
Map<String, Object> bodyMap = jsonMapper.readValue(bodyStr, HashMap.class);
if (bodyMap != null) {
paramsMap.putAll(bodyMap);
}
} else if (bodyStr.startsWith("[")) {
List<?> bodyList = jsonMapper.readValue(bodyStr, List.class);
paramsMap.put("list", bodyList);
}
} else if (bodyData instanceof Map) {
paramsMap.putAll((Map<String, Object>) bodyData);
} else if (bodyData instanceof List) {
paramsMap.put("list", bodyData);
}
// 2. 解析 requestParams:
// - Map 形式:可能是已组装好的键值对(如 {"list":["1","2"],"pageSetup":0}),直接合并;
// - List 形式:参数定义数组,仅取 paramPosition=query 的项,body/header 参数值由 bodyData/请求头单独注入
Object requestParams = params.getRequestParams();
if (requestParams instanceof Map) {
Map<String, Object> requestMap = (Map<String, Object>) requestParams;
// 单个参数定义(含 paramName/value 键)也按定义处理,否则视为键值对直接合并
if (requestMap.containsKey("paramName") && requestMap.containsKey("value")) {
Object name = requestMap.get("paramName");
Object value = requestMap.get("value");
if (name != null && value != null && !"".equals(value)) {
paramsMap.put(name.toString(), value);
}
} else {
paramsMap.putAll(requestMap);
}
} else if (requestParams instanceof List) {
List<Map<String, Object>> paramsList = (List<Map<String, Object>>) requestParams;
for (Map<String, Object> entry : paramsList) {
String position = entry.get("paramPosition") == null
? "query" : String.valueOf(entry.get("paramPosition"));
// 跳过 body/header 位置参数,避免无 value 的 Array 类型被误判为必填失败
if (!"query".equalsIgnoreCase(position)) {
continue;
}
Object name = entry.get("paramName");
if (name == null) {
continue;
}
Object value = entry.get("value");
if (value == null || "".equals(value)) {
continue;
}
// Array 类型做逗号分隔转换
String paramType = String.valueOf(entry.get("paramType"));
if ("Array".equalsIgnoreCase(paramType) && !(value instanceof List)) {
value = Arrays.asList(value.toString().split(","));
}
paramsMap.put(name.toString(), value);
}
}
// 3. 兼容旧字段 queryParam(Map 形式)
Map<String, Object> queryParam = params.getQueryParam();
if (queryParam != null && !queryParam.isEmpty()) {
paramsMap.putAll(queryParam);
}
// 分页开关
if (params.getPageSetup() != null) {
paramsMap.put("pageSetup", params.getPageSetup());
}
// 事务开关只由测试弹窗显式传入,开发页运行/预览不进入多脚本执行入口。
paramsMap.put(BaseConstant.TRANSACTION_ENABLED, normalizeTransactionEnabled(params.getTransactionEnabled()));
return paramsMap;
}
/**
* 测试参数兼容旧前端请求,未传事务开关时按关闭处理。
*/
private Integer normalizeTransactionEnabled(Integer transactionEnabled) {
return transactionEnabled == null ? 0 : transactionEnabled;
}
/**
* 格式化返回数据
*/
private String formatResultData(Object data, String resultType) {
if (ResultTypeEnum.ONE.getName().equals(resultType) && data instanceof List) {
List<Object> list = (List<Object>) data;
return jsonMapper.writeValueAsString(Result.success(list.isEmpty() ? null : list.get(0)));
}
return jsonMapper.writeValueAsString(Result.success(data));
}
}
@@ -35,9 +35,6 @@ public class CommonController {
public Result detail(Long apiId) {
Integer applyCount = 1;
BaseApiInfoVO apiDetail = apiInfoService.getApiDetail(apiId);
if ("0".equals(apiDetail.getShowSqlScript())){
apiDetail.setSqlScript("***");
}
apiDetail.setApplyed(applyCount);
return Result.success(apiDetail);
}
@@ -10,6 +10,7 @@ import cn.crabc.core.spi.MetaDataMapper;
import cn.crabc.core.spi.bean.Column;
import cn.crabc.core.spi.bean.Table;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -58,7 +59,7 @@ public class MetaDataController {
return Result.success(schemas);
}
//@Cacheable(cacheNames = "tableCache", cacheManager = "metaDataManager", key = "#datasourceId+'_'+#schema")
@Cacheable(cacheNames = "tableCache", cacheManager = "metaDataManager", key = "#datasourceId+'_'+#schema")
@GetMapping("/tables")
public Result getTables(@RequestParam("datasourceId") String datasourceId, @RequestParam("schema") String schema,String datasourceType) {
MetaDataMapper metaData = dataSourceManager.getMetaData(datasourceId);
@@ -5,14 +5,16 @@ import cn.crabc.core.app.entity.param.UserParam;
import cn.crabc.core.app.service.system.IBaseUserService;
import cn.crabc.core.app.util.*;
import com.github.benmanes.caffeine.cache.Cache;
import org.apache.tomcat.util.codec.binary.Base64;
import org.apache.commons.codec.binary.Base64;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.*;
import tools.jackson.databind.json.JsonMapper;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -26,7 +28,8 @@ public class SysUserController {
@Autowired
@Qualifier("dataCache")
Cache<String, Object> caffeineCache;
@Autowired
private JsonMapper jsonMapper;
/**
* 登录
*
@@ -75,6 +78,17 @@ public class SysUserController {
BaseUser userInfo = iBaseUserService.getUserById(Long.parseLong(UserThreadLocal.getUserId()));
return Result.success(userInfo);
}
/**
* 用户菜单
* @return
*/
@GetMapping("/menus")
public Result userMenus(){
// 固定菜单
String menus ="[{\"name\":\"API Hub\",\"checked\":null,\"id\":2,\"parentId\":1,\"open\":null,\"path\":null,\"url\":\"/hub\",\"sort\":2,\"menuType\":1,\"code\":\"Home\",\"hidden\":false,\"permission\":\"/api/box/sys/user/info;/api/box/sys/user/menus;/api/box/sys/message/**;/api/box/sys/user/update/pwd\",\"subMenus\":[{\"name\":\"接口详情\",\"checked\":null,\"id\":7,\"parentId\":2,\"open\":null,\"path\":null,\"url\":\"/doc\",\"sort\":6,\"menuType\":1,\"code\":\"DataCatelogApiDetails\",\"hidden\":true,\"permission\":\"doc\",\"subMenus\":[]}]},{\"name\":\"工作台\",\"checked\":null,\"id\":3,\"parentId\":1,\"open\":null,\"path\":null,\"url\":\"/working\",\"sort\":3,\"menuType\":1,\"code\":\"DataService\",\"hidden\":false,\"permission\":\"/api/box/sys/common/**;/api/box/sys/api/info/**;/api/box/sys/metadata/**;/api/box/sys/test/**;/api/box/sys/group/**;/api/box/sys/user/check/name\",\"subMenus\":[]},{\"name\":\"接口管理\",\"checked\":null,\"id\":4,\"parentId\":1,\"open\":null,\"path\":null,\"url\":\"/manage\",\"sort\":4,\"menuType\":1,\"code\":\"Manage\",\"hidden\":false,\"permission\":\"manage\",\"subMenus\":[{\"name\":\"应用管理\",\"checked\":null,\"id\":8,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/keys\",\"sort\":1,\"menuType\":1,\"code\":\"Application\",\"hidden\":true,\"permission\":\"/api/box/sys/app/**\",\"subMenus\":[]},{\"name\":\"接口列表\",\"checked\":null,\"id\":9,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/apis\",\"sort\":1,\"menuType\":1,\"code\":\"MyApi\",\"hidden\":true,\"permission\":\"/api/box/sys/api/info/**\",\"subMenus\":[]},{\"name\":\"指标统计\",\"checked\":null,\"id\":40,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/monitoring\",\"sort\":1,\"menuType\":1,\"code\":\"Monitoring\",\"hidden\":true,\"permission\":\"/api/box/sys/api/log/**\",\"subMenus\":[]},{\"name\":\"日志管理\",\"checked\":null,\"id\":12,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/logs\",\"sort\":1,\"menuType\":1,\"code\":\"UseLog\",\"hidden\":true,\"permission\":\"/api/box/sys/api/log/**\",\"subMenus\":[{\"name\":\"日志列表\",\"checked\":null,\"id\":13,\"parentId\":12,\"open\":null,\"path\":null,\"url\":\"/manage/logs/list\",\"sort\":1,\"menuType\":1,\"code\":\"UseLogList\",\"hidden\":true,\"permission\":\"/api/box/sys/api/log/**\",\"subMenus\":[]},{\"name\":\"日志详情\",\"checked\":null,\"id\":14,\"parentId\":12,\"open\":null,\"path\":null,\"url\":\"/manage/logs/detail\",\"sort\":1,\"menuType\":1,\"code\":\"DetailUesLog\",\"hidden\":true,\"permission\":null,\"subMenus\":[]}]},{\"name\":\"分类管理\",\"checked\":null,\"id\":30,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/classification\",\"sort\":1,\"menuType\":1,\"code\":\"Classification\",\"hidden\":true,\"permission\":\"manage:classification\",\"subMenus\":[]},{\"name\":\"数据源\",\"checked\":null,\"id\":31,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/datasource\",\"sort\":1,\"menuType\":1,\"code\":\"DataSource\",\"hidden\":true,\"permission\":\"/api/box/sys/datasource/**;/api/box/sys/user/pubKey\",\"subMenus\":[]},{\"checked\":null,\"code\":\"datasource\",\"hidden\":false,\"id\":33,\"menuType\":1,\"name\":\"数据源\",\"open\":null,\"parentId\":1,\"path\":null,\"permission\":\"/api/box/sys/datasource/**;/api/box/sys/user/pubKey\",\"sort\":4,\"subMenus\":[],\"url\":\"/datasource\"},{\"name\":\"密码重置\",\"checked\":null,\"id\":32,\"parentId\":4,\"open\":null,\"path\":null,\"url\":\"/manage/edit-pwd\",\"sort\":1,\"menuType\":1,\"code\":\"ChangePassword\",\"hidden\":true,\"permission\":\"/api/box/sys/user/update/pwd\",\"subMenus\":[]}]}]";
List<Map<String, Object>> list = jsonMapper.readValue(menus, List.class);
return Result.success(list);
}
/**
* 修改密码
@@ -80,20 +80,30 @@ public class BaseApiInfo extends BaseEntity {
*/
private String sqlScript;
/**
* 是否显示脚本1/0
*/
private String showSqlScript;
/**
* 分页设置,不分页:0、只分页:page、分页并统计:pageCount
*/
private Integer pageSetup;
/**
* 是否开启事务,1 开启、0 关闭;仅接口调用和测试多脚本DML时生效
*/
private Integer transactionEnabled;
/**
* 限流时间窗口秒数;为空或小于等于0时不启用限流。
*/
private Integer rateLimitWindowSeconds;
/**
* 限流窗口内允许请求次数;为空或小于等于0时不启用限流。
*/
private Integer rateLimitCount;
/**
* 数据源Id
*/
private String datasourceId;
private Integer datasourceId;
/**
* 数据源名称
@@ -104,6 +114,8 @@ public class BaseApiInfo extends BaseEntity {
* schema
*/
private String schemaName;
// 表名
private String tableName;
/**
* 数据源类型
@@ -120,4 +132,12 @@ public class BaseApiInfo extends BaseEntity {
*/
private Long parentId;
/** 分组名称 */
private String groupName;
/**
* 已发布接口的暂存内容
*/
private String draftContent;
}
@@ -40,7 +40,7 @@ public class BaseApiParam {
/**
* 是否必填
*/
private Boolean required;
private String required;
/**
* 操作
@@ -70,7 +70,7 @@ public class BaseApiParam {
/**
* 数据源Id
*/
private String datasourceId;
private Integer datasourceId;
/**
* schema
@@ -33,13 +33,15 @@ public class BaseApiSql extends BaseEntity {
/**
* 数据源Id
*/
private String datasourceId;
private Integer datasourceId;
/**
* schema
*/
private String schemaName;
private String tableName;
/**
* 数据源类型
*/
@@ -0,0 +1,48 @@
package cn.crabc.core.app.entity.param;
import lombok.Getter;
import lombok.Setter;
/**
* API日志查询请求参数
*
* @author yuqf
*/
@Setter
@Getter
public class ApiLogParam {
/**
* 请求结果,取值沿用日志写入的 success/fail。
*/
private String result;
/**
* 接口名称或接口路径关键字。
*/
private String keyword;
/**
* 应用名称或调用凭证名称。
*/
private String appName;
/**
* 查询开始时间,格式为 yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd。
*/
private String startTime;
/**
* 查询结束时间,格式为 yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd。
*/
private String endTime;
/**
* 分页页码,仅日志列表使用。
*/
private Integer pageNum;
/**
* 分页大小,仅日志列表使用。
*/
private Integer pageSize;
}
@@ -0,0 +1,32 @@
package cn.crabc.core.app.entity.param;
import lombok.Getter;
import lombok.Setter;
/**
* API限流配置参数,用于发布接口列表设置单窗口限流规则。
*/
@Getter
@Setter
public class ApiRateLimitParam {
/**
* 接口ID,保存限流配置时定位发布接口。
*/
private Long apiId;
/**
* 时间窗口数值,和windowUnit一起换算成秒;为空时表示清空限流。
*/
private Integer windowValue;
/**
* 时间窗口单位:SECOND、MINUTE、HOUR。
*/
private String windowUnit;
/**
* 窗口内允许请求次数;为空时表示清空限流。
*/
private Integer limitCount;
}
@@ -0,0 +1,42 @@
package cn.crabc.core.app.entity.param;
import lombok.Getter;
import lombok.Setter;
import java.util.Map;
/**
* API测试参数,承载测试弹窗传入的SQL、数据源和运行时参数。
*/
@Setter
@Getter
public class ApiTestParam {
private String sqlScript;
private String datasourceId;
private String datasourceType;
private String schemaName;
private String sqlParams;
private Integer pageSetup;
/**
* 是否开启事务,1 开启、0 关闭;仅测试多脚本DML时传给执行层
*/
private Integer transactionEnabled;
private String resultType;
private Object requestParams;
/**
* 请求体参数,页面测试弹窗传入:可能是 JSON 字符串("{\"list\":[1,2]}"),也可能是已反序列化的对象
*/
private Object bodyData;
private Map<String,Object> queryParam;
}
@@ -39,4 +39,9 @@ public class ApiInfoVO {
*/
private List<BaseApiParam> responseParam = new ArrayList<>();
/**
* 是否存在已发布接口暂存内容
*/
private Boolean hasDraft = false;
}
@@ -0,0 +1,39 @@
package cn.crabc.core.app.entity.vo;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* API日志每日趋势统计,用于监控页折线图展示调用量、成功量、失败量和平均耗时走势。
*/
@Setter
@Getter
public class ApiLogDailyTrendVO implements Serializable {
/**
* 统计日期,格式为 yyyy-MM-dd。
*/
private String statisticDate;
/**
* 当日总调用次数。
*/
private Long totalCount;
/**
* 当日成功调用次数。
*/
private Long successCount;
/**
* 当日失败调用次数。
*/
private Long failCount;
/**
* 当日平均耗时,单位毫秒。
*/
private Double avgCostTime;
}
@@ -0,0 +1,44 @@
package cn.crabc.core.app.entity.vo;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* API日志分组统计结果,用于状态占比、接口调用量Top和接口耗时Top等轻量图表。
*/
@Setter
@Getter
public class ApiLogGroupCountVO implements Serializable {
/**
* 分组主键,例如状态或API编号。
*/
private String groupKey;
/**
* 分组展示名称,例如接口名称或状态名称。
*/
private String groupName;
/**
* API路径,仅接口Top统计需要返回。
*/
private String apiPath;
/**
* 分组调用次数。
*/
private Long count;
/**
* 分组平均耗时,单位毫秒,仅接口耗时排行需要返回。
*/
private Double avgCostTime;
/**
* 分组最大耗时,单位毫秒,仅接口耗时排行需要返回。
*/
private Long maxCostTime;
}
@@ -0,0 +1,45 @@
package cn.crabc.core.app.entity.vo;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.List;
/**
* API日志统计总览,用于兼容旧接口一次性返回监控页指标卡和图表数据。
*/
@Setter
@Getter
public class ApiLogStatisticsVO implements Serializable {
/**
* 顶部摘要指标。
*/
private ApiLogSummaryVO summary;
/**
* 每日调用趋势数据。
*/
private List<ApiLogDailyTrendVO> dailyTrend;
/**
* 成功/失败状态占比数据。
*/
private List<ApiLogGroupCountVO> statusPie;
/**
* 调用量最高的接口列表,固定返回Top10。
*/
private List<ApiLogGroupCountVO> topApis;
/**
* 平均耗时最高的成功接口列表,固定返回Top10。
*/
private List<ApiLogGroupCountVO> topCostApis;
/**
* 访问量最高的IP列表,固定返回Top10。
*/
private List<ApiLogGroupCountVO> topIps;
}
@@ -0,0 +1,44 @@
package cn.crabc.core.app.entity.vo;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* API日志统计摘要,用于监控页顶部指标卡展示整体调用质量和耗时边界。
*/
@Setter
@Getter
public class ApiLogSummaryVO implements Serializable {
/**
* 时间范围内的总调用次数。
*/
private Long totalCount;
/**
* request_status 为 success 的调用次数。
*/
private Long successCount;
/**
* request_status 为 fail 的调用次数。
*/
private Long failCount;
/**
* 成功调用占比,服务层统一计算两位小数,避免前端和SQL重复计算。
*/
private Double successRate;
/**
* 平均耗时,单位毫秒。
*/
private Double avgCostTime;
/**
* 最大耗时,单位毫秒。
*/
private Long maxCostTime;
}
@@ -0,0 +1,47 @@
package cn.crabc.core.app.entity.vo;
import lombok.Getter;
import lombok.Setter;
/**
* API限流配置视图,回显单窗口限流规则和接口维度。
*/
@Getter
@Setter
public class ApiRateLimitVO {
/**
* 接口ID。
*/
private Long apiId;
/**
* 接口名称。
*/
private String apiName;
/**
* 请求方式,和URL共同作为限流维度。
*/
private String apiMethod;
/**
* 接口URL,前端展示时补/api/web/前缀。
*/
private String apiPath;
/**
* 时间窗口数值。
*/
private Integer windowValue;
/**
* 时间窗口单位:SECOND、MINUTE、HOUR。
*/
private String windowUnit;
/**
* 窗口内允许请求次数。
*/
private Integer limitCount;
}
@@ -70,9 +70,12 @@ public class BaseApiInfoVO extends BaseEntity {
*/
private Integer pageSetup;
private String sqlScript;
/**
* 是否开启事务,1 开启、0 关闭;仅接口调用和测试多脚本DML时生效
*/
private Integer transactionEnabled;
private String showSqlScript;
private String sqlScript;
/**
* 发布时间
*/
@@ -3,6 +3,8 @@ package cn.crabc.core.app.entity.vo;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
@Setter
@Getter
public class RequestParamsVO {
@@ -46,4 +48,5 @@ public class RequestParamsVO {
*/
private String paramDesc;
private List<RequestParamsVO> children;
}
@@ -79,6 +79,7 @@ public class ExceptionHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(value = HttpMessageNotReadableException.class)
@ResponseBody
public Result exceptionHandler(HttpServletRequest req, HttpMessageNotReadableException e) {
e.printStackTrace();
return Result.error(50002, "Body参数异常,请检查Body/json是否正确传参");
}
}
@@ -0,0 +1,53 @@
package cn.crabc.core.app.filter;
import cn.crabc.core.app.util.ApiThreadLocal;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
public class ApiFilter implements Filter {
public Boolean responseBody;
public ApiFilter( Boolean responseBody) {
this.responseBody = responseBody;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
ContentCachingResponseWrapper responseWrapper = null;
try {
// 包装类
if (responseBody && (request.getContentType() == null || request.getContentType().contains(MediaType.APPLICATION_JSON_VALUE))) {
BaseRequestWrapper requestWrapper = new BaseRequestWrapper(request);
responseWrapper = new ContentCachingResponseWrapper(response);
filterChain.doFilter(requestWrapper, responseWrapper);
} else {
filterChain.doFilter(servletRequest, servletResponse);
}
}finally {
ApiThreadLocal.remove();
if (responseWrapper != null) {
responseWrapper.copyBodyToResponse();
}
}
}
@Override
public void destroy() {
ApiThreadLocal.remove();
Filter.super.destroy();
}
}
@@ -0,0 +1,376 @@
package cn.crabc.core.app.filter;
import cn.crabc.core.app.entity.BaseApiLog;
import cn.crabc.core.app.entity.BaseApp;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.service.system.IBaseApiInfoService;
import cn.crabc.core.app.service.system.IBaseApiLogService;
import cn.crabc.core.app.service.system.impl.ApiRateLimitService;
import cn.crabc.core.app.util.ApiThreadLocal;
import cn.crabc.core.app.util.RequestUtils;
import cn.crabc.core.app.util.Result;
import cn.crabc.core.app.util.SM3Util;
import cn.crabc.core.datasource.enums.ErrorStatusEnum;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.lang.Nullable;
import org.springframework.util.StreamUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.ContentCachingResponseWrapper;
import tools.jackson.databind.json.JsonMapper;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* API开放接口鉴权过滤 拦截器
*
* @author yuqf
*/
public class AuthInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class);
// API开放接口前缀
private static final String API_PRE = "/api/web/";
// 日志异步处理配置
private static final int BATCH_SIZE = 500; // 每次消费200条
private static final long CONSUME_INTERVAL_MS = 500; // 每500ms消费一次
// 有界日志缓冲队列(超过容量则丢弃)
private final BlockingQueue<BaseApiLog> logQueue = new LinkedBlockingQueue<>(2000);
// 丢弃日志计数器
private final AtomicInteger droppedLogCount = new AtomicInteger(0);
// 定时任务调度器
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "api-log-consumer");
thread.setDaemon(true); //
return thread;
});
@Autowired
private IBaseApiLogService iBaseApiLogService;
@Autowired
private IBaseApiInfoService iBaseApiInfoService;
@Value("${crabc.auth.expiresTime:10}")
private Integer expiresTime;
@Autowired
private JsonMapper jsonMapper;
@Autowired
@Qualifier("apiCache")
private Cache<String, ApiInfoDTO> apiCache;
@Autowired
private ApiRateLimitService apiRateLimitService;
/**
* 构造函数:启动日志批量消费定时任务
*/
public AuthInterceptor() {
// 每500ms执行一次批量消费任务
scheduler.scheduleAtFixedRate(this::consumeLogBatch, CONSUME_INTERVAL_MS, CONSUME_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
/**
* 批量消费日志队列
*/
private void consumeLogBatch() {
try {
List<BaseApiLog> batch = new ArrayList<>(BATCH_SIZE);
logQueue.drainTo(batch, BATCH_SIZE);
if (!batch.isEmpty()) {
iBaseApiLogService.batchAddLog(batch);
log.debug("批量插入API日志成功,数量:{}", batch.size());
}
// 定期报告丢弃日志数
int dropped = droppedLogCount.getAndSet(0);
if (dropped > 0) {
log.warn("日志队列已满,丢弃日志数:{}", dropped);
}
} catch (Exception e) {
log.error("批量消费日志失败", e);
}
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String path = request.getRequestURI();
String method = request.getMethod();
String apiPath = IBaseApiInfoService.normalizeApiPath(path.replace(API_PRE, ""));
String cacheKey = IBaseApiInfoService.buildCacheKey(method, apiPath);
// 1. 获取API配置
ApiInfoDTO apiInfo = apiCache.getIfPresent(cacheKey);
if (apiInfo == null) {
apiInfo = iBaseApiInfoService.getApiInfoCache(method, apiPath);
if (apiInfo != null) {
apiCache.put(cacheKey, apiInfo);
}
}
// 2. API存在性验证
if (apiInfo == null) {
setErrorResponse(request, response,ErrorStatusEnum.API_INVALID.getCode(),ErrorStatusEnum.API_INVALID.getMassage());
return false;
}
// 3. 存入上下文(记录请求时间用于日志和性能监控)
apiInfo.setRequestDate(new Date());
apiInfo.setRequestTime(System.currentTimeMillis());
ApiThreadLocal.set(apiInfo);
// 4. API启用状态检查
if (apiInfo.getEnabled() == 0) {
setErrorResponse(request, response,ErrorStatusEnum.API_OFFLINE.getCode(),ErrorStatusEnum.API_OFFLINE.getMassage());
return false;
}
// 5. 限流检查(防止API被恶意频繁调用)
if (!apiRateLimitService.tryConsume(apiInfo)) {
setErrorResponse(request, response, ErrorStatusEnum.API_LIMIT.getCode(), ErrorStatusEnum.API_LIMIT.getMassage());
return false;
}
try {
// 6. 根据认证类型进行鉴权
List<BaseApp> appList = apiInfo.getAppList();
return switch (apiInfo.getAuthType().toUpperCase()) {
case "APP_CODE" -> checkAppCode(request,response, appList);
case "APP_KEY" -> checkAppKey(request,response, appList);
case "APP_SECRET" -> checkSM3(request,response, appList);
default -> true; // 无认证模式
};
}catch (Exception e) {
log.error("API认证异常: {}", e.getMessage(), e);
setErrorResponse(request,response,ErrorStatusEnum.API_UN_AUTH.getCode(),ErrorStatusEnum.API_UN_AUTH.getMassage());
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
addLogAsync(request, response, "");
// 清除上下文
ApiThreadLocal.remove();
}
/**
* 异常返回
* @param response
* @param status
* @param message
* @throws Exception
*/
private void setErrorResponse(HttpServletRequest request, HttpServletResponse response, int status, String message)
throws Exception {
// 指定错误码
response.setStatus(400);
response.setContentType("application/json;charset=UTF-8");
Result result = Result.error(status, message);
String json = jsonMapper.writeValueAsString(result);
// 日记记录
addLogAsync(request, response, json);
response.getWriter().write(json);
}
/**
* 异步记录访问日志(将日志加入队列)
*
* @param request
* @param response
*/
private void addLogAsync(HttpServletRequest request, HttpServletResponse response, String msg) {
try {
BaseApiLog apiLog = buildApiLog(request, response, msg);
// 尝试将日志加入队列,如果队列已满则丢弃
boolean offered = logQueue.offer(apiLog);
if (!offered) {
droppedLogCount.incrementAndGet();
}
} catch (Exception e) {
log.error("构建API日志失败", e);
}
}
/**
* 构建API日志对象
*
* @param request
* @param response
* @param msg
* @return
*/
private BaseApiLog buildApiLog(HttpServletRequest request, HttpServletResponse response, String msg) throws Exception {
ContentCachingResponseWrapper responseWrapper = null;
BaseApiLog apiLog = new BaseApiLog();
long endTime = System.currentTimeMillis();
ApiInfoDTO apiInfo = ApiThreadLocal.get();
Date date = new Date();
if (apiInfo != null) {
apiLog.setApiId(apiInfo.getApiId());
apiLog.setApiName(apiInfo.getApiName());
apiLog.setApiMethod(apiInfo.getApiMethod());
apiLog.setAuthType(apiInfo.getAuthType());
apiLog.setRequestTime(apiInfo.getRequestDate());
apiLog.setCostTime(endTime - apiInfo.getRequestTime());
}else{
apiLog.setRequestTime(date);
apiLog.setApiPath(request.getRequestURI());
apiLog.setApiMethod(request.getMethod());
}
int status = response.getStatus();
apiLog.setAppName(RequestUtils.getAppKey(request));
apiLog.setApiPath(request.getRequestURI());
apiLog.setRequestIp(RequestUtils.getIp(request));
apiLog.setResponseTime(date);
apiLog.setQueryParam(request.getQueryString());
apiLog.setResponseCode(status);
apiLog.setRequestStatus(status == 200 ? "success" : "fail");
try {
if (request instanceof BaseRequestWrapper) {
String requestBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
apiLog.setRequestBody(requestBody);
}
if (response instanceof ContentCachingResponseWrapper) {
responseWrapper = (ContentCachingResponseWrapper) response;
byte[] content = responseWrapper.getContentAsByteArray();
String responseBody = new String(content, StandardCharsets.UTF_8);
apiLog.setResponseBody(responseBody);
}
if (status == 400) {
apiLog.setResponseBody(msg);
}
} catch (Exception e) {
log.error("响应结果转换异常", e);
} finally {
if (responseWrapper != null) {
responseWrapper.copyBodyToResponse();
}
}
return apiLog;
}
/**
* 验证接口访问权限APP_CODE
*
* @param request
* @param appList
* @return
*/
private boolean checkAppCode(HttpServletRequest request,HttpServletResponse response, List<BaseApp> appList) throws Exception {
String appCode = RequestUtils.getAppCode(request);
if (appCode == null || appCode.isEmpty()) {
setErrorResponse(request,response,ErrorStatusEnum.API_UN_AUTH.getCode(),ErrorStatusEnum.API_UN_AUTH.getMassage());
return false;
}
boolean check = appList.stream().anyMatch(app -> app.getAppCode().equals(appCode));
if (!check) {
setErrorResponse(request,response,ErrorStatusEnum.API_UN_AUTH.getCode(),ErrorStatusEnum.API_UN_AUTH.getMassage());
}
return check;
}
/**
* 验证接口访问权限APP_KEY
*
* @param request
* @param appList
* @return
*/
private boolean checkAppKey(HttpServletRequest request, HttpServletResponse response, List<BaseApp> appList) throws Exception {
String appKey = RequestUtils.getAppKey(request);
if (appKey == null || appKey.isEmpty()) {
setErrorResponse(request,response,ErrorStatusEnum.APP_UN_AUTH.getCode(),ErrorStatusEnum.APP_UN_AUTH.getMassage());
return false;
}
boolean check = appList.stream().anyMatch(app -> app.getAppKey().equals(appKey));
if (!check) {
setErrorResponse(request,response,ErrorStatusEnum.API_AUTH_ERROR.getCode(),ErrorStatusEnum.API_AUTH_ERROR.getMassage());
}
return check;
}
/**
* 国密签名认证
*
* @param request
* @param appList
* @return
* @throws Exception
*/
public boolean checkSM3(HttpServletRequest request, HttpServletResponse response, List<BaseApp> appList) throws Exception {
// 1. 获取认证参数(支持X-前缀和无前缀两种header)
String sign = Optional.ofNullable(request.getHeader("X-Sign")).orElse(request.getHeader("sign"));
String timeStamp = Optional.ofNullable(request.getHeader("X-Timestamp")).orElse(request.getHeader("timestamp"));
String appKey = Optional.ofNullable(request.getHeader("X-AppKey")).orElse(request.getHeader("appkey"));
String nonce = Optional.ofNullable(request.getHeader("X-Nonce")).orElse(request.getHeader("nonce"));
// 2. 必填参数检查
if (appKey == null || sign == null || timeStamp == null) {
setErrorResponse(request,response,ErrorStatusEnum.SHA_PARAM_NOT_FOUNT.getCode(), ErrorStatusEnum.PARAM_NOT_FOUNT.getMassage());
return false;
}
// 3. 时间戳有效性验证(防止过期请求)
long authTime;
try {
authTime = Long.parseLong(timeStamp);
} catch (NumberFormatException e) {
setErrorResponse(request,response,ErrorStatusEnum.SHA_TIMESTAMP_EXPIRE.getCode(), "时间戳格式错误");
return false;
}
long nowTime = System.currentTimeMillis() - authTime;
// 检查时间戳是否在有效期内(默认10分钟)
if (nowTime > expiresTime * 60 * 1000L || nowTime < 0) {
setErrorResponse(request,response,ErrorStatusEnum.SHA_TIMESTAMP_EXPIRE.getCode(), ErrorStatusEnum.SHA_TIMESTAMP_EXPIRE.getMassage());
return false;
}
// 4. 获取应用密钥
String appSecret = appList.stream()
.filter(app -> app.getAppKey().equals(appKey))
.map(BaseApp::getAppSecret)
.findFirst()
.orElse("");
if (appSecret.isEmpty()) {
setErrorResponse(request,response,ErrorStatusEnum.API_AUTH_ERROR.getCode(),"AppKey不存在");
return false;
}
// 5. 构建签名数据并验证
String buildData = this.buildData(request, appKey, timeStamp, nonce);
boolean verify = SM3Util.verify(buildData, appSecret, sign);
if (!verify) {
log.warn("签名验证失败 - AppKey: {}, IP: {}, 签名数据: {}",
appKey, RequestUtils.getIp(request), buildData);
setErrorResponse(request,response,ErrorStatusEnum.API_AUTH_ERROR.getCode(),ErrorStatusEnum.API_AUTH_ERROR.getMassage());
}
return verify;
}
/**
* 构建签名数据
* @return
*/
private String buildData(HttpServletRequest request, String appKey, String timestamp, String nonce) {
String method = request.getMethod();
String path = request.getRequestURI();
return method + "_" + path + "_" + appKey + "_" + timestamp + "_" + nonce;
}
}
@@ -18,9 +18,6 @@ import org.springframework.web.servlet.HandlerInterceptor;
*/
public class JwtInterceptor implements HandlerInterceptor {
@Value("${crabc.token.expireTime:36000}")
private long expireTime;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 放行OPTIONS请求
@@ -39,14 +36,6 @@ public class JwtInterceptor implements HandlerInterceptor {
if (claims == null) {
throw new CustomException(ErrorStatusEnum.JWT_LOGIN_EXPIRE.getCode(), ErrorStatusEnum.JWT_UN_AUTH.getMassage());
}
// 校验token是否过期
long expireTime = Long.parseLong(claims.get("expireTime").toString());
long currentTime = System.currentTimeMillis();
if ((currentTime - expireTime) / 1000 > this.expireTime) {
throw new CustomException(ErrorStatusEnum.JWT_LOGIN_EXPIRE.getCode(), ErrorStatusEnum.JWT_LOGIN_EXPIRE.getMassage());
}
// 设置用户信息到线程上下文
UserThreadLocal.set(claims);
return true;
@@ -74,6 +74,13 @@ public interface BaseApiInfoMapper {
*/
List<ApiInfoDTO> selectApiDetail(@Param("apiId") Long apiId);
/**
* 根据apiId查询信息
* @param
* @return
*/
ApiInfoDTO getApiDetail(@Param("method") String method, @Param("apiPath") String apiPath);
/**
* 插入API
*
@@ -98,6 +105,24 @@ public interface BaseApiInfoMapper {
*/
Integer updateApiState(BaseApiInfo apiInfo);
/**
* 更新API限流配置,窗口和次数同时为空表示清空限流。
*
* @param apiInfo API限流字段
* @return 影响行数
*/
Integer updateRateLimit(BaseApiInfo apiInfo);
/**
* 更新API暂存内容
*
* @param apiId
* @param draftContent
* @param updateBy
* @return
*/
Integer updateDraftContent(@Param("apiId") Long apiId, @Param("draftContent") String draftContent, @Param("updateBy") String updateBy);
/**
* 删除API
* @param apiId
@@ -0,0 +1,99 @@
package cn.crabc.core.app.mapper;
import cn.crabc.core.app.entity.BaseApiLog;
import cn.crabc.core.app.entity.param.ApiLogParam;
import cn.crabc.core.app.entity.vo.ApiLogDailyTrendVO;
import cn.crabc.core.app.entity.vo.ApiLogGroupCountVO;
import cn.crabc.core.app.entity.vo.ApiLogSummaryVO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* API访问日志 Mapper接口
*
* @author yuqf
*/
@Mapper
public interface BaseApiLogMapper {
/**
* 添加日志
*
* @param log
* @return
*/
Integer insert(BaseApiLog log);
/**
* 批量添加日志
*
* @param logs
* @return
*/
Integer batchInsert(List<BaseApiLog> logs);
/**
* 日志列表
*
* @param param
* @return
*/
List<BaseApiLog> selectList(ApiLogParam param);
/**
* 日志详情
*
* @param logId
* @return
*/
BaseApiLog selectOne(Long logId);
/**
* 查询日志摘要统计
*
* @param param 统计筛选条件
* @return 总量、成功量、失败量和耗时摘要
*/
ApiLogSummaryVO selectLogSummary(ApiLogParam param);
/**
* 查询每日调用趋势
*
* @param param 统计筛选条件
* @return 按 request_time 日期聚合的趋势数据
*/
List<ApiLogDailyTrendVO> selectDailyTrend(ApiLogParam param);
/**
* 查询请求状态分布
*
* @param param 统计筛选条件
* @return success/fail 分组计数
*/
List<ApiLogGroupCountVO> selectStatusStats(ApiLogParam param);
/**
* 查询接口调用量Top10列表
*
* @param param 统计筛选条件
* @return 按接口聚合的调用量Top10
*/
List<ApiLogGroupCountVO> selectTopApis(ApiLogParam param);
/**
* 查询成功接口耗时Top10列表
*
* @param param 统计筛选条件,服务层会固定 request_status=success
* @return 按接口平均耗时聚合的成功调用Top10
*/
List<ApiLogGroupCountVO> selectTopCostApis(ApiLogParam param);
/**
* 查询IP访问量Top10列表
*
* @param param 统计筛选条件
* @return 按request_ip分组聚合的访问量Top10
*/
List<ApiLogGroupCountVO> selectTopIps(ApiLogParam param);
}
@@ -6,6 +6,7 @@ import cn.crabc.core.app.service.core.IBaseDataService;
import cn.crabc.core.app.util.SQLUtil;
import cn.crabc.core.datasource.constant.BaseConstant;
import cn.crabc.core.datasource.driver.DataSourceManager;
import cn.crabc.core.datasource.exception.CustomException;
import cn.crabc.core.spi.StatementMapper;
import cn.crabc.core.spi.bean.BaseDataSource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -31,13 +32,13 @@ public class BaseDataServiceImpl implements IBaseDataService {
@Override
public PreviewVO sqlPreview(String datasourceId,String datasourceType, String schema, String sql) {
StatementMapper statementMapper = dataSourceManager.getStatementMapper(datasourceId);
Map<String, Object> params = new HashMap<>();
// 数据源类型
if (datasourceType != null) {
params.put(BaseConstant.DATA_SOURCE_TYPE, datasourceType);
}
params.put(BaseConstant.BASE_API_EXEC_TYPE, "preview");
params.put(BaseConstant.PAGE_SETUP, 1);
Object result = this.execute(datasourceId, datasourceType, schema, sql, params);
PreviewVO preview = new PreviewVO();
if (result instanceof List) {
@@ -59,28 +60,52 @@ public class BaseDataServiceImpl implements IBaseDataService {
@Override
public Object execute(String datasourceId, String datasourceType, String schema, String sql, Map<String, Object> params) {
StatementMapper statementMapper = dataSourceManager.getStatementMapper(datasourceId);
if (params == null) {
params = new HashMap<>();
}
if (datasourceType != null && !params.containsKey(BaseConstant.DATA_SOURCE_TYPE)) {
params.put(BaseConstant.DATA_SOURCE_TYPE, datasourceType);
}
String sqlType = SQLUtil.getOperateType(sql).toLowerCase();
List<String> statements = SQLUtil.splitSqlStatements(sql);
if (statements.size() > 1) {
if (!params.containsKey(BaseConstant.TRANSACTION_ENABLED)) {
throw new CustomException(40011, "当前执行入口不支持多脚本SQL");
}
SQLUtil.validateBatchDmlStatements(statements);
return statementMapper.executeBatchDml(datasourceId, schema, statements, params, isTransactionEnabled(params));
}
String executeSql = statements.isEmpty() ? sql : statements.get(0);
String sqlType = SQLUtil.getOperateType(executeSql).toLowerCase();
switch (sqlType) {
case "insert":
return statementMapper.insert(datasourceId, schema, sql, params);
return statementMapper.insert(datasourceId, schema, executeSql, params);
case "update":
return statementMapper.update(datasourceId, schema, sql, params);
return statementMapper.update(datasourceId, schema, executeSql, params);
case "delete":
return statementMapper.delete(datasourceId, schema, sql, params);
return statementMapper.delete(datasourceId, schema, executeSql, params);
default:
Object pageNum = params.get(BaseConstant.PAGE_NUM);
Object pageSize = params.get(BaseConstant.PAGE_SIZE);
if (pageNum != null && pageSize != null) {
return statementMapper.selectPage(datasourceId, schema, sql, params,
return statementMapper.selectPage(datasourceId, schema, executeSql, params,
Integer.parseInt(pageNum.toString()),
Integer.parseInt(pageSize.toString()));
}
return statementMapper.selectList(datasourceId, schema, sql, params);
return statementMapper.selectList(datasourceId, schema, executeSql, params);
}
}
/**
* 执行层兼容前端1/0、测试Boolean等常见表示,只有明确为1或true时开启事务。
*/
private boolean isTransactionEnabled(Map<String, Object> params) {
Object value = params.get(BaseConstant.TRANSACTION_ENABLED);
if (value instanceof Boolean) {
return (Boolean) value;
}
return value != null && "1".equals(value.toString());
}
}
@@ -4,12 +4,15 @@ import cn.crabc.core.app.entity.BaseApiInfo;
import cn.crabc.core.app.entity.BaseAppApi;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.entity.param.ApiInfoParam;
import cn.crabc.core.app.entity.param.ApiRateLimitParam;
import cn.crabc.core.app.entity.vo.ApiComboBoxVO;
import cn.crabc.core.app.entity.vo.ApiInfoVO;
import cn.crabc.core.app.entity.vo.ApiRateLimitVO;
import cn.crabc.core.app.entity.vo.BaseApiInfoVO;
import cn.crabc.core.datasource.util.PageInfo;
import java.util.List;
import java.util.Locale;
/**
* API 基本信息 服务接口
@@ -18,7 +21,25 @@ import java.util.List;
*/
public interface IBaseApiInfoService {
void initApi();
static String normalizeApiPath(String apiPath) {
if (apiPath == null || apiPath.isBlank()) {
return "";
}
return apiPath.startsWith("/") ? apiPath.substring(1) : apiPath;
}
static String buildCacheKey(String method, String apiPath) {
String httpMethod = method == null ? "" : method.toUpperCase(Locale.ROOT);
return httpMethod + "::" + normalizeApiPath(apiPath);
}
/**
* 根据接口url获取API关联数据
*
* @param
* @return
*/
ApiInfoDTO getApiInfoCache(String method, String apiPath);
/**
* 获取API关联数据
@@ -159,4 +180,20 @@ public interface IBaseApiInfoService {
* @return
*/
Integer destroyApiInfo(Long apiId);
/**
* 查询发布接口限流配置。
*
* @param apiId 接口ID
* @return 限流配置
*/
ApiRateLimitVO getRateLimit(Long apiId);
/**
* 保存或清空发布接口限流配置。
*
* @param param 限流配置
* @return 影响行数
*/
Integer saveRateLimit(ApiRateLimitParam param);
}
@@ -0,0 +1,108 @@
package cn.crabc.core.app.service.system;
import cn.crabc.core.app.entity.BaseApiLog;
import cn.crabc.core.app.entity.param.ApiLogParam;
import cn.crabc.core.app.entity.vo.ApiLogDailyTrendVO;
import cn.crabc.core.app.entity.vo.ApiLogGroupCountVO;
import cn.crabc.core.app.entity.vo.ApiLogStatisticsVO;
import cn.crabc.core.app.entity.vo.ApiLogSummaryVO;
import cn.crabc.core.datasource.util.PageInfo;
import java.util.List;
/**
* API日志 服务接口
*
* @author yuqf
*/
public interface IBaseApiLogService {
/**
* 添加日志
*
* @param log
* @return
*/
Integer addLog(BaseApiLog log);
/**
* 批量添加日志
*
* @param logs
* @return
*/
Integer batchAddLog(List<BaseApiLog> logs);
/**
* 日志列表
*
* @param param
* @return
*/
PageInfo page(ApiLogParam param);
/**
* 日志详情
*
* @param logId
* @return
*/
BaseApiLog logDetail(Long logId);
/**
* API日志统计摘要
*
* @param param 查询条件,未传时间时默认近七天,避免统计接口扫描全量日志
* @return 监控页顶部指标卡数据
*/
ApiLogSummaryVO summary(ApiLogParam param);
/**
* API日志每日趋势
*
* @param param 查询条件,未传时间时默认近七天,避免统计接口扫描全量日志
* @return 按天聚合的调用趋势
*/
List<ApiLogDailyTrendVO> dailyTrend(ApiLogParam param);
/**
* API日志请求结果占比
*
* @param param 查询条件,未传时间时默认近七天,避免统计接口扫描全量日志
* @return success/fail等状态分组计数
*/
List<ApiLogGroupCountVO> statusPie(ApiLogParam param);
/**
* API接口调用量Top10
*
* @param param 查询条件,未传时间时默认近七天,避免统计接口扫描全量日志
* @return 按接口聚合的调用量Top10
*/
List<ApiLogGroupCountVO> topApis(ApiLogParam param);
/**
* API接口耗时Top10
*
* @param param 查询条件,服务层会固定成功状态,只统计成功调用的耗时
* @return 按接口平均耗时聚合的成功调用Top10
*/
List<ApiLogGroupCountVO> topCostApis(ApiLogParam param);
/**
* IP访问量Top10
*
* @param param 查询条件,未传时间时默认近七天,避免统计接口扫描全量日志
* @return 按request_ip分组聚合的访问量Top10
*/
List<ApiLogGroupCountVO> topIps(ApiLogParam param);
/**
* API日志统计兼容接口
*
* @param param 查询条件,未传时间时默认近七天,避免统计接口扫描全量日志
* @return 监控页统计指标和图表数据
*/
ApiLogStatisticsVO statistics(ApiLogParam param);
}
@@ -0,0 +1,136 @@
package cn.crabc.core.app.service.system.impl;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.service.system.IBaseApiInfoService;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/**
* API限流服务,使用Bucket4j根据Method+URL维度控制发布接口请求频率。
*/
@Service
public class ApiRateLimitService {
private static final String KEY_PREFIX = "rate_limit:";
private static final long MIN_CACHE_SECONDS = 300L;
private final Cache<String, BucketEntry> bucketCache;
public ApiRateLimitService() {
this.bucketCache = Caffeine.newBuilder()
.expireAfter(new Expiry<String, BucketEntry>() {
/**
* Bucket缓存生命周期跟随限流窗口,窗口越长缓存越久,同时保留最小5分钟兜底避免短窗口频繁重建。
*/
@Override
public long expireAfterCreate(String key, BucketEntry value, long currentTime) {
return TimeUnit.SECONDS.toNanos(value.cacheSeconds());
}
/**
* 配置变化后put新Bucket,重新按新窗口计算过期时间。
*/
@Override
public long expireAfterUpdate(String key, BucketEntry value, long currentTime, long currentDuration) {
return TimeUnit.SECONDS.toNanos(value.cacheSeconds());
}
/**
* 读取不延长过期时间,避免低频接口的历史Bucket长期留在本地内存。
*/
@Override
public long expireAfterRead(String key, BucketEntry value, long currentTime, long currentDuration) {
return currentDuration;
}
})
.build();
}
/**
* 消费一次请求令牌;未配置限流时直接放行,配置变化时重建Bucket。
*/
public boolean tryConsume(ApiInfoDTO apiInfo) {
if (!isRateLimitEnabled(apiInfo)) {
return true;
}
String key = buildKey(apiInfo.getApiMethod(), apiInfo.getApiPath());
BucketEntry entry = bucketCache.get(key, ignore -> buildEntry(apiInfo));
if (!entry.sameConfig(apiInfo)) {
entry = buildEntry(apiInfo);
bucketCache.put(key, entry);
}
return entry.bucket.tryConsume(1);
}
/**
* 限流配置保存或清空后删除运行时Bucket,确保下一次请求按新配置创建。
*/
public void invalidate(String method, String apiPath) {
bucketCache.invalidate(buildKey(method, apiPath));
}
/**
* 限流Key复用API缓存的Method+URL规范化规则,避免同一路径出现多个计数维度。
*/
public String buildKey(String method, String apiPath) {
return KEY_PREFIX + IBaseApiInfoService.buildCacheKey(method, apiPath);
}
/**
* 只有窗口秒数和请求次数都为正数时才启用限流。
*/
private boolean isRateLimitEnabled(ApiInfoDTO apiInfo) {
return apiInfo != null
&& apiInfo.getRateLimitWindowSeconds() != null
&& apiInfo.getRateLimitWindowSeconds() > 0
&& apiInfo.getRateLimitCount() != null
&& apiInfo.getRateLimitCount() > 0;
}
/**
* Bucket使用按窗口周期重置的补充策略,符合“1分钟5次”这类固定窗口语义。
*/
private BucketEntry buildEntry(ApiInfoDTO apiInfo) {
long capacity = apiInfo.getRateLimitCount();
Duration window = Duration.ofSeconds(apiInfo.getRateLimitWindowSeconds());
Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, window));
Bucket bucket = Bucket.builder().addLimit(limit).build();
return new BucketEntry(bucket, apiInfo.getRateLimitWindowSeconds(), apiInfo.getRateLimitCount());
}
/**
* Bucket与创建时配置绑定,配置变更时通过快照判断是否需要重建。
*/
private static class BucketEntry {
private final Bucket bucket;
private final Integer windowSeconds;
private final Integer limitCount;
private BucketEntry(Bucket bucket, Integer windowSeconds, Integer limitCount) {
this.bucket = bucket;
this.windowSeconds = windowSeconds;
this.limitCount = limitCount;
}
private boolean sameConfig(ApiInfoDTO apiInfo) {
return apiInfo != null
&& windowSeconds.equals(apiInfo.getRateLimitWindowSeconds())
&& limitCount.equals(apiInfo.getRateLimitCount());
}
/**
* 缓存过期按窗口两倍计算,低于5分钟时使用5分钟兜底,兼顾短窗口复用和长期内存回收。
*/
private long cacheSeconds() {
return Math.max(windowSeconds.longValue() * 2, MIN_CACHE_SECONDS);
}
}
}
@@ -0,0 +1,575 @@
package cn.crabc.core.app.service.system.impl;
import cn.crabc.core.app.entity.*;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.entity.param.ApiInfoParam;
import cn.crabc.core.app.entity.param.ApiRateLimitParam;
import cn.crabc.core.app.entity.vo.ApiComboBoxVO;
import cn.crabc.core.app.entity.vo.ApiInfoVO;
import cn.crabc.core.app.entity.vo.ApiRateLimitVO;
import cn.crabc.core.app.entity.vo.BaseApiInfoVO;
import cn.crabc.core.app.enums.ApiStateEnum;
import cn.crabc.core.app.mapper.BaseApiInfoMapper;
import cn.crabc.core.app.mapper.BaseApiParamMapper;
import cn.crabc.core.app.mapper.BaseAppApiMapper;
import cn.crabc.core.app.mapper.BaseAppMapper;
import cn.crabc.core.app.service.system.IBaseApiInfoService;
import cn.crabc.core.datasource.util.PageInfo;
import cn.crabc.core.app.util.UserThreadLocal;
import cn.crabc.core.datasource.enums.ErrorStatusEnum;
import cn.crabc.core.datasource.exception.CustomException;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* API基本信息 服务实现
*
* @author yuqf
*/
@Service
public class BaseApiInfoServiceImpl implements IBaseApiInfoService {
@Autowired
private BaseApiInfoMapper apiInfoMapper;
@Autowired
private BaseAppMapper baseAppMapper;
@Autowired
private BaseAppApiMapper baseAppApiMapper;
@Autowired
private BaseApiParamMapper apiParamMapper;
@Autowired
@Qualifier("apiCache")
Cache<String, ApiInfoDTO> apiInfoCache;
@Autowired
private JsonMapper jsonMapper;
@Autowired
private ApiRateLimitService apiRateLimitService;
@Override
public ApiInfoDTO getApiInfoCache(String method, String apiPath) {
ApiInfoDTO apiInfo = apiInfoMapper.getApiDetail(method, apiPath);
if (apiInfo == null) {
return null;
}
fillTransactionDefault(apiInfo);
// 应用
List<BaseApp> appApis = baseAppMapper.selectApiApp(apiInfo.getApiId());
// 请求参数
List<BaseApiParam> baseApiParams = apiParamMapper.selectReqParams(apiInfo.getApiId());
apiInfo.setAppList(appApis == null ? new ArrayList<>() : appApis);
apiInfo.setRequestParams(baseApiParams == null ? new ArrayList<>() : baseApiParams);
return apiInfo;
}
private void invalidateApiCache(String method, String apiPath) {
if (method == null || apiPath == null || apiPath.isBlank()) {
return;
}
apiInfoCache.invalidate(IBaseApiInfoService.buildCacheKey(method, apiPath));
}
private void invalidateApiCache(BaseApiInfo apiInfo) {
if (apiInfo == null) {
return;
}
invalidateApiCache(apiInfo.getApiMethod(), apiInfo.getApiPath());
}
private BaseApiInfo getApiForCacheInvalidation(Long apiId) {
if (apiId == null) {
return null;
}
return apiInfoMapper.selectApiById(apiId);
}
@Override
public List<ApiInfoDTO> getApiCache(Long apiId) {
List<ApiInfoDTO> apiInfos = apiInfoMapper.selectApiDetail(apiId);
if (apiInfos.isEmpty()) {
return apiInfos;
}
// 应用
List<BaseApp> appApis = baseAppMapper.selectApiApp(apiId);
Map<Long, List<BaseApp>> appMap = appApis.stream().collect(Collectors.groupingBy(BaseApp::getApiId));
// 请求参数
List<BaseApiParam> baseApiParams = apiParamMapper.selectReqParams(apiId);
Map<Long, List<BaseApiParam>> paramMap = baseApiParams.stream().collect(Collectors.groupingBy(BaseApiParam::getApiId));
for (ApiInfoDTO api : apiInfos) {
fillTransactionDefault(api);
Long apiIdKey = api.getApiId();
if (appMap.containsKey(apiIdKey)) {
api.setAppList(appMap.get(apiIdKey));
}
if (paramMap.containsKey(apiIdKey)) {
api.setRequestParams(paramMap.get(apiIdKey));
}
}
return apiInfos;
}
@Override
public PageInfo<BaseApiInfo> getApiPage(String apiName, String devType, int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<BaseApiInfo> list = apiInfoMapper.selectList(apiName, devType);
return new PageInfo<>(list, pageNum, pageSize);
}
@Override
public List<BaseApiInfo> getApiList(String apiName) {
return apiInfoMapper.selectList(apiName, null);
}
@Override
public List<ApiComboBoxVO> getApiListGroup(Integer groupId) {
return apiInfoMapper.selectApiGroup(groupId, null);
}
@Override
public List<ApiComboBoxVO> getApiListUser(String userId) {
return apiInfoMapper.selectApiGroup(null, userId);
}
@Override
public Boolean checkApiPath(Long apiId, String apiPath, String method) {
Integer count = apiInfoMapper.checkApiPath(apiId, apiPath, method);
return count > 0 ? true : false;
}
@Override
public ApiRateLimitVO getRateLimit(Long apiId) {
BaseApiInfo apiInfo = apiInfoMapper.selectApiById(apiId);
if (apiInfo == null) {
throw new CustomException(ErrorStatusEnum.API_NOT_FOUNT.getCode(), ErrorStatusEnum.API_NOT_FOUNT.getMassage());
}
ApiRateLimitVO result = new ApiRateLimitVO();
result.setApiId(apiInfo.getApiId());
result.setApiName(apiInfo.getApiName());
result.setApiMethod(apiInfo.getApiMethod());
result.setApiPath(apiInfo.getApiPath());
result.setLimitCount(apiInfo.getRateLimitCount());
fillWindowDisplay(apiInfo.getRateLimitWindowSeconds(), result);
return result;
}
@Override
public Integer saveRateLimit(ApiRateLimitParam param) {
if (param == null || param.getApiId() == null) {
throw new CustomException(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), "接口ID不能为空");
}
BaseApiInfo oldApi = getApiForCacheInvalidation(param.getApiId());
if (oldApi == null) {
throw new CustomException(ErrorStatusEnum.API_NOT_FOUNT.getCode(), ErrorStatusEnum.API_NOT_FOUNT.getMassage());
}
BaseApiInfo apiInfo = new BaseApiInfo();
apiInfo.setApiId(param.getApiId());
apiInfo.setUpdateTime(new Date());
apiInfo.setUpdateBy(UserThreadLocal.getUserId());
if (!isClearRateLimit(param)) {
apiInfo.setRateLimitWindowSeconds(parseWindowSeconds(param));
apiInfo.setRateLimitCount(parseLimitCount(param));
}
Integer result = apiInfoMapper.updateRateLimit(apiInfo);
invalidateApiCache(oldApi);
apiRateLimitService.invalidate(oldApi.getApiMethod(), oldApi.getApiPath());
return result;
}
@Override
public BaseApiInfoVO getApiDetail(Long apiId) {
BaseApiInfoVO apiInfo = apiInfoMapper.selectBaseApi(apiId);
fillTransactionDefault(apiInfo);
return apiInfo;
}
@Override
public ApiInfoVO getApiInfo(Long apiId) {
ApiInfoVO result = new ApiInfoVO();
BaseApiInfo baseApiInfo = apiInfoMapper.selectApiById(apiId);
if (baseApiInfo == null) {
return result;
}
fillTransactionDefault(baseApiInfo);
if (ApiStateEnum.RELEASE.getName().equals(baseApiInfo.getApiStatus()) && hasDraft(baseApiInfo)) {
result = buildApiInfoVO(readDraftContent(baseApiInfo.getDraftContent()), true);
result.getBaseInfo().setApiId(apiId);
result.getBaseInfo().setApiStatus(ApiStateEnum.RELEASE.getName());
result.getBaseInfo().setEnabled(baseApiInfo.getEnabled());
return result;
}
BaseApiSql baseApiSql = new BaseApiSql();
BeanUtils.copyProperties(baseApiInfo, baseApiSql);
result.setSqlInfo(baseApiSql);
baseApiInfo.setPageSetup(baseApiSql.getPageSetup());
result.setBaseInfo(baseApiInfo);
result.setQueryEngine("jdbc");
List<BaseApiParam> baseApiParams = apiParamMapper.selectList(apiId);
if (!baseApiParams.isEmpty()) {
Map<String, List<BaseApiParam>> map = baseApiParams.stream().collect(Collectors.groupingBy(BaseApiParam::getParamModel));
result.setRequestParam(map.get("request") == null ? new ArrayList<>() : map.get("request"));
result.setResponseParam(map.get("response") == null ? new ArrayList<>() : map.get("response"));
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long addApiInfo(ApiInfoParam params) {
Date date = new Date();
BaseApiInfo api = params.getBaseInfo();
BaseApiSql sql = params.getSqlInfo();
api.setApiStatus(ApiStateEnum.EDIT.getName());
api.setEnabled(0);
api.setApiType("SQL");
api.setDatasourceId(sql.getDatasourceId());
api.setSchemaName(sql.getSchemaName());
api.setTableName(sql.getTableName());
api.setDatasourceType(sql.getDatasourceType());
api.setSqlScript(sql.getSqlScript());
fillTransactionDefault(api);
api.setCreateTime(date);
api.setUpdateTime(date);
api.setCreateBy(UserThreadLocal.getUserId());
if (api.getGroupId() == null) {
api.setGroupId(1);
}
apiInfoMapper.insertApiInfo(api);
// 参数
this.insertApiParams(params.getRequestParam(),params.getResponseParam(),api.getApiId());
return api.getApiId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long updateApiInfo(ApiInfoParam params) {
Long apiId = params.getBaseInfo().getApiId();
Integer count = apiInfoMapper.countApi(apiId);
if (count == 0) {
throw new CustomException(ErrorStatusEnum.API_NOT_FOUNT.getCode(), ErrorStatusEnum.API_NOT_FOUNT.getMassage());
}
BaseApiInfo previousApi = getApiForCacheInvalidation(apiId);
if (ApiStateEnum.RELEASE.getName().equals(previousApi.getApiStatus())) {
saveDraftContent(apiId, params, previousApi);
return apiId;
}
BaseApiInfo api = params.getBaseInfo();
BaseApiSql sql = params.getSqlInfo();
Date updateTime = new Date();
api.setApiStatus(ApiStateEnum.EDIT.getName());
api.setEnabled(0);
api.setDatasourceId(sql.getDatasourceId());
api.setSchemaName(sql.getSchemaName());
api.setTableName(sql.getTableName());
api.setDatasourceType(sql.getDatasourceType());
api.setSqlScript(sql.getSqlScript());
fillTransactionDefault(api);
api.setUpdateTime(updateTime);
api.setUpdateBy(UserThreadLocal.getUserId());
apiInfoMapper.updateApiInfo(api);
apiParamMapper.delete(apiId);
// 参数
this.insertApiParams(params.getRequestParam(),params.getResponseParam(), api.getApiId());
invalidateApiCache(previousApi);
invalidateApiCache(api);
return apiId;
}
@Override
public Integer updateApiState(Long apiId, String status, Integer enabled) {
Integer count = apiInfoMapper.countApi(apiId);
if (count == 0) {
throw new CustomException(ErrorStatusEnum.API_NOT_FOUNT.getCode(), ErrorStatusEnum.API_NOT_FOUNT.getMassage());
}
BaseApiInfo previousApi = getApiForCacheInvalidation(apiId);
BaseApiInfo baseApiInfo = new BaseApiInfo();
baseApiInfo.setApiId(apiId);
baseApiInfo.setUpdateTime(new Date());
baseApiInfo.setUpdateBy(UserThreadLocal.getUserId());
if (status != null && !"".equals(status)) {
baseApiInfo.setApiStatus(status);
}
if (enabled != null) {
baseApiInfo.setEnabled(enabled);
}
apiInfoMapper.updateApiState(baseApiInfo);
invalidateApiCache(previousApi);
return 1;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer deleteApi(Long apiId, String userId) {
BaseApiInfo previousApi = getApiForCacheInvalidation(apiId);
Integer result = apiInfoMapper.deleteApiInfo(apiId, userId);
invalidateApiCache(previousApi);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long apiPublish(ApiInfoParam apiInfoParam) {
Long apiId = apiInfoParam.getBaseInfo().getApiId();
BaseApiInfo oldApiInfo = apiInfoMapper.selectApiById(apiId);
if (oldApiInfo == null) {
throw new CustomException(ErrorStatusEnum.API_NOT_FOUNT.getCode(), ErrorStatusEnum.API_NOT_FOUNT.getMassage());
}
BaseApiInfo previousApi = copyApiInfo(oldApiInfo);
boolean publishDraft = ApiStateEnum.RELEASE.getName().equals(oldApiInfo.getApiStatus()) && hasDraft(oldApiInfo);
ApiInfoParam publishParam = publishDraft ? readDraftContent(oldApiInfo.getDraftContent()) : apiInfoParam;
Date updateTime = new Date();
BaseApiInfo api = publishDraft ? publishParam.getBaseInfo() : oldApiInfo;
BaseApiSql sql = publishDraft ? publishParam.getSqlInfo() : null;
api.setApiId(apiId);
api.setEnabled(1);
api.setParentId(0L);
api.setReleaseTime(updateTime);
api.setUpdateTime(updateTime);
api.setUpdateBy(UserThreadLocal.getUserId());
api.setApiStatus(ApiStateEnum.RELEASE.getName());
if (sql != null) {
api.setDatasourceId(sql.getDatasourceId());
api.setSchemaName(sql.getSchemaName());
api.setTableName(sql.getTableName());
api.setDatasourceType(sql.getDatasourceType());
api.setSqlScript(sql.getSqlScript());
api.setPageSetup(sql.getPageSetup());
}
fillTransactionDefault(api);
apiInfoMapper.updateApiInfo(api);
if (publishDraft) {
apiParamMapper.delete(apiId);
this.insertApiParams(publishParam.getRequestParam(), publishParam.getResponseParam(), apiId);
apiInfoMapper.updateDraftContent(apiId, null, UserThreadLocal.getUserId());
}
invalidateApiCache(previousApi);
invalidateApiCache(api);
return apiId;
}
@Override
public PageInfo getNotChooseApi(Long appId, Integer pageNum, Integer pageSize) {
//PageHelper.startPage(pageNum, pageSize);
List<ApiComboBoxVO> allApi = apiInfoMapper.selectApiApp(null);
List<ApiComboBoxVO> appApis = apiInfoMapper.selectApiApp(appId);
allApi.removeAll(appApis);
return new PageInfo<>(allApi, pageNum, pageSize);
}
@Override
public PageInfo getChooseApi(Long appId, Integer pageNum, Integer pageSize) {
//PageHelper.startPage(pageNum, pageSize);
List<ApiComboBoxVO> list = apiInfoMapper.selectApiApp(appId);
return new PageInfo<>(list, pageNum, pageSize);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer addChooseApi(BaseAppApi appApi) {
String userId = UserThreadLocal.getUserId();
baseAppApiMapper.delete(appApi.getAppId(), userId);
if (appApi.getApiIds() == null || appApi.getApiIds().size() == 0) {
return 1;
}
Date time = new Date();
List<BaseAppApi> list = new ArrayList<>();
for (Long apiId : appApi.getApiIds()) {
BaseAppApi a = new BaseAppApi();
a.setAppId(appApi.getAppId());
a.setApiId(apiId);
a.setCreateBy(userId);
a.setCreateTime(time);
list.add(a);
}
baseAppApiMapper.insert(list);
apiInfoCache.invalidateAll();
return 1;
}
@Override
public Integer destroyApiInfo(Long apiId) {
String userId = UserThreadLocal.getUserId();
BaseApiInfo previousApi = getApiForCacheInvalidation(apiId);
BaseApiInfo baseApiInfo = new BaseApiInfo();
baseApiInfo.setUpdateTime(new Date());
baseApiInfo.setUpdateBy(userId);
baseApiInfo.setApiStatus(ApiStateEnum.DESTROY.getName());
baseApiInfo.setApiId(apiId);
apiInfoMapper.updateApiState(baseApiInfo);
invalidateApiCache(previousApi);
return 1;
}
/**
* api信息历史发布版本存档
*
* @param apiInfo
*/
public void insertHistory(BaseApiInfo apiInfo) {
Long apiId = apiInfo.getApiId();
Date updateTime = new Date();
apiInfo.setApiStatus(ApiStateEnum.HISTORY.getName());
apiInfo.setParentId(apiId);
apiInfo.setApiPath(apiInfo.getApiPath() + "/" + apiInfo.getVersion());
apiInfo.setUpdateTime(updateTime);
apiInfo.setApiId(null);
apiInfoMapper.insertApiInfo(apiInfo);
}
/**
* 新增API参数
* @param requestParams
* @param responseParams
*/
private void insertApiParams(List<BaseApiParam> requestParams, List<BaseApiParam> responseParams, Long apiId){
List<BaseApiParam> params = new ArrayList<>();
if (requestParams != null && requestParams.size() > 0) {
params.addAll(requestParams);
}
if (responseParams != null && responseParams.size() > 0) {
params.addAll(responseParams);
}
if (params.size() > 0) {
apiParamMapper.insertBatch(params,apiId);
}
}
private boolean hasDraft(BaseApiInfo apiInfo) {
return apiInfo.getDraftContent() != null && !apiInfo.getDraftContent().isBlank();
}
private void saveDraftContent(Long apiId, ApiInfoParam params, BaseApiInfo publishedApi) {
BaseApiInfo draftBaseInfo = params.getBaseInfo();
draftBaseInfo.setApiId(apiId);
draftBaseInfo.setApiStatus(ApiStateEnum.RELEASE.getName());
draftBaseInfo.setEnabled(publishedApi.getEnabled());
fillTransactionDefault(draftBaseInfo);
if (params.getSqlInfo() != null) {
params.getSqlInfo().setApiId(apiId);
}
try {
apiInfoMapper.updateDraftContent(apiId, jsonMapper.writeValueAsString(params), UserThreadLocal.getUserId());
} catch (Exception e) {
throw new CustomException(ErrorStatusEnum.SYSTEM_ERROR.getCode(), "暂存接口信息失败");
}
}
private ApiInfoParam readDraftContent(String draftContent) {
try {
return jsonMapper.readValue(draftContent, ApiInfoParam.class);
} catch (Exception e) {
throw new CustomException(ErrorStatusEnum.SYSTEM_ERROR.getCode(), "读取暂存接口信息失败");
}
}
private ApiInfoVO buildApiInfoVO(ApiInfoParam params, boolean hasDraft) {
ApiInfoVO result = new ApiInfoVO();
BaseApiInfo baseInfo = params.getBaseInfo() == null ? new BaseApiInfo() : params.getBaseInfo();
fillTransactionDefault(baseInfo);
result.setBaseInfo(baseInfo);
result.setSqlInfo(params.getSqlInfo() == null ? new BaseApiSql() : params.getSqlInfo());
result.setRequestParam(params.getRequestParam() == null ? new ArrayList<>() : params.getRequestParam());
result.setResponseParam(params.getResponseParam() == null ? new ArrayList<>() : params.getResponseParam());
result.setQueryEngine(params.getQueryEngine() == null ? "jdbc" : params.getQueryEngine());
result.setHasDraft(hasDraft);
return result;
}
/**
* 事务开关对旧数据和旧草稿保持向后兼容:缺省按关闭处理,避免空值被执行层误判。
*
* @param apiInfo API基本信息
*/
private void fillTransactionDefault(BaseApiInfo apiInfo) {
if (apiInfo != null && apiInfo.getTransactionEnabled() == null) {
apiInfo.setTransactionEnabled(0);
}
}
/**
* 限流弹框允许三个字段同时为空,表示清空当前接口限流配置。
*/
private boolean isClearRateLimit(ApiRateLimitParam param) {
return param.getWindowValue() == null
&& (param.getWindowUnit() == null || param.getWindowUnit().isBlank())
&& param.getLimitCount() == null;
}
/**
* 限流窗口统一落库为秒,避免执行链路反复处理单位换算。
*/
private Integer parseWindowSeconds(ApiRateLimitParam param) {
if (param.getWindowValue() == null || param.getWindowValue() <= 0
|| param.getWindowUnit() == null || param.getWindowUnit().isBlank()) {
throw new CustomException(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), "限流时间窗口不能为空");
}
String unit = param.getWindowUnit().toUpperCase();
return switch (unit) {
case "SECOND" -> param.getWindowValue();
case "MINUTE" -> param.getWindowValue() * 60;
case "HOUR" -> param.getWindowValue() * 3600;
default -> throw new CustomException(ErrorStatusEnum.FORBID_OPERATE.getCode(), "限流时间单位无效");
};
}
/**
* 请求次数必须为正数,窗口和次数任一缺失都不能形成有效限流规则。
*/
private Integer parseLimitCount(ApiRateLimitParam param) {
if (param.getLimitCount() == null || param.getLimitCount() <= 0) {
throw new CustomException(ErrorStatusEnum.PARAM_NOT_FOUNT.getCode(), "限流请求次数不能为空");
}
return param.getLimitCount();
}
/**
* 回显时优先使用能整除的最大常用单位,保持页面展示简洁。
*/
private void fillWindowDisplay(Integer windowSeconds, ApiRateLimitVO result) {
if (windowSeconds == null || windowSeconds <= 0) {
return;
}
if (windowSeconds % 3600 == 0) {
result.setWindowValue(windowSeconds / 3600);
result.setWindowUnit("HOUR");
} else if (windowSeconds % 60 == 0) {
result.setWindowValue(windowSeconds / 60);
result.setWindowUnit("MINUTE");
} else {
result.setWindowValue(windowSeconds);
result.setWindowUnit("SECOND");
}
}
/**
* 详情VO直接返回给前端,旧接口没有事务字段时统一回填关闭状态。
*
* @param apiInfo API详情VO
*/
private void fillTransactionDefault(BaseApiInfoVO apiInfo) {
if (apiInfo != null && apiInfo.getTransactionEnabled() == null) {
apiInfo.setTransactionEnabled(0);
}
}
private BaseApiInfo copyApiInfo(BaseApiInfo apiInfo) {
BaseApiInfo copy = new BaseApiInfo();
BeanUtils.copyProperties(apiInfo, copy);
return copy;
}
}
@@ -0,0 +1,88 @@
package cn.crabc.core.app.service.system.impl;
import cn.crabc.core.app.entity.vo.ApiParamsVO;
import cn.crabc.core.app.entity.vo.RequestParamsVO;
import cn.crabc.core.app.mapper.BaseApiParamMapper;
import cn.crabc.core.app.service.system.IBaseApiParamService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* API参数 服务实现
*
* @author yuqf
*/
@Service
public class BaseApiParamServiceImpl implements IBaseApiParamService {
@Autowired
private BaseApiParamMapper baseApiParamMapper;
@Value("${crabc.result.code:code}")
private String resultCode;
@Value("${crabc.result.msg:msg}")
private String resultMsg;
@Value("${crabc.result.data:data}")
private String resultData;
public static final String REQUEST ="request";
/**
* 响应参数
*/
public static final String RESPONSE ="response";
@Override
public ApiParamsVO getApiDetailsParams(Long apiId) {
ApiParamsVO apiParams = new ApiParamsVO();
List<RequestParamsVO> requestParams = baseApiParamMapper.selectApiParams(apiId);
Map<String, List<RequestParamsVO>> paramMap = requestParams.stream().collect(Collectors.groupingBy(RequestParamsVO::getParamModel));
apiParams.setReqParams(paramMap.get(REQUEST));
List<RequestParamsVO> responseParam = paramMap.get(RESPONSE);
apiParams.setResParams(this.setResponseParams(responseParam));
return apiParams;
}
/**
* 处理返回参数
* @param responseParams
* @return
*/
public List<RequestParamsVO> setResponseParams(List<RequestParamsVO> responseParams) {
List<RequestParamsVO> list = new ArrayList<>();
if (responseParams == null || responseParams.isEmpty()) {
return list;
}
RequestParamsVO responseCode = new RequestParamsVO();
responseCode.setParamName(resultCode);
responseCode.setParamDesc("状态码:0 成功");
responseCode.setParamType("Int");
responseCode.setExample("0");
responseCode.setRequired("Y");
list.add(responseCode);
RequestParamsVO responseMsg = new RequestParamsVO();
responseMsg.setParamName(resultMsg);
responseMsg.setParamDesc("错误信息");
responseMsg.setParamType("String");
responseMsg.setExample("");
responseMsg.setChildren(null);
responseCode.setRequired("Y");
list.add(responseMsg);
RequestParamsVO responseData = new RequestParamsVO();
responseData.setParamName(resultData);
responseData.setParamDesc("数据");
responseData.setParamType("Array");
responseData.setExample("");
responseCode.setRequired("Y");
responseData.setChildren(responseParams);
list.add(responseData);
return list;
}
}
@@ -1,14 +1,17 @@
package cn.crabc.core.app.service.system.impl;
import cn.crabc.core.app.entity.BaseApp;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.mapper.BaseAppMapper;
import cn.crabc.core.app.service.system.IBaseAppService;
import cn.crabc.core.datasource.util.PageInfo;
import cn.crabc.core.app.util.UserThreadLocal;
import cn.crabc.core.datasource.enums.ErrorStatusEnum;
import cn.crabc.core.datasource.exception.CustomException;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import java.util.Date;
@@ -24,6 +27,9 @@ public class BaseAppServiceImpl implements IBaseAppService {
@Autowired
private BaseAppMapper baseAppMapper;
@Autowired
@Qualifier("apiCache")
Cache<String, ApiInfoDTO> apiInfoCache;
@Override
public PageInfo<BaseApp> appPage(String appName, String appCode, Integer pageNum, Integer pageSize) {
@@ -55,14 +61,16 @@ public class BaseAppServiceImpl implements IBaseAppService {
}
app.setUpdateTime(new Date());
app.setUpdateBy(UserThreadLocal.getUserId());
return baseAppMapper.update(app);
baseAppMapper.update(app);
apiInfoCache.invalidateAll();
return 1;
}
@Override
public Integer deleteApp(Long appId) {
return baseAppMapper.delete(appId);
baseAppMapper.delete(appId);
apiInfoCache.invalidateAll();
return 1;
}
}
@@ -8,6 +8,7 @@ import cn.crabc.core.app.util.SQLUtil;
import cn.crabc.core.datasource.util.PageInfo;
import cn.crabc.core.app.util.UserThreadLocal;
import cn.crabc.core.datasource.driver.DataSourceManager;
import cn.crabc.core.datasource.driver.jdbc.DuckDbDataSource;
import cn.crabc.core.spi.bean.BaseDataSource;
import com.github.pagehelper.PageHelper;
import com.zaxxer.hikari.HikariDataSource;
@@ -16,7 +17,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -24,6 +24,7 @@ import javax.sql.DataSource;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Objects;
/**
* 数据源 服务实现
@@ -61,22 +62,13 @@ public class BaseDataSourceServiceImpl implements IBaseDataSourceService {
public void init() {
List<BaseDataSource> baseDataSources = this.getList();
for (BaseDataSource dataSource : baseDataSources) {
DataSource ds = DataSourceManager.DATA_SOURCE_POOL_JDBC.get(dataSource.getDatasourceId());
DataSource ds = DataSourceManager.DATA_SOURCE_POOL_JDBC.get(dataSource.getDatasourceId().toString());
try {
String jdbcUrl = null;
String username = null;
String password = null;
if (ds instanceof HikariDataSource) {
HikariDataSource hikari = (HikariDataSource) ds;
jdbcUrl = hikari.getJdbcUrl();
username = hikari.getUsername();
password = hikari.getPassword();
}
DataSourceIdentity oldIdentity = buildIdentity(ds);
byte[] decode = Base64.getDecoder().decode(dataSource.getPassword());
dataSource.setPassword(new String(decode));
// 判断数据库连接关键属性是否有修改,有修改则刷新缓存
if (ds != null && dataSource.getJdbcUrl().equals(jdbcUrl)
&& dataSource.getUsername().equals(username) && dataSource.getPassword().equals(password)) {
// 判断数据库连接关键属性是否有修改,有修改则刷新缓存;空快照表示缓存不存在或类型未知,需要重建。
if (oldIdentity.exists() && oldIdentity.sameAs(dataSource)) {
continue;
}
dataSourceManager.createDataSource(dataSource);
@@ -113,6 +105,7 @@ public class BaseDataSourceServiceImpl implements IBaseDataSourceService {
dataSource.setClassify("jdbc");
dataSource.setCreateBy(UserThreadLocal.getUserId());
dataSource.setCreateTime(new Date());
dataSource.setUpdateTime(new Date());
// 判断是否是自定义
if ("custom".equals(dataSource.getDatasourceType())){
String dataSourceType = SQLUtil.getDataSourceType(dataSource.getJdbcUrl());
@@ -172,14 +165,71 @@ public class BaseDataSourceServiceImpl implements IBaseDataSourceService {
private void parsePassword(BaseDatasource dataSource) {
String password = dataSource.getPassword();
String pwd = null;
if (password != null && !"".equals(password.trim())) {
if (password != null && !password.trim().isEmpty()) {
byte[] decode = Base64.getDecoder().decode(password);
pwd = new String(decode);
}else if (dataSource.getDatasourceId() != null){
BaseDatasource baseDatasource = dataSourceMapper.selectOne(Integer.parseInt(dataSource.getDatasourceId()));
BaseDatasource baseDatasource = dataSourceMapper.selectOne(dataSource.getDatasourceId());
byte[] decode = Base64.getDecoder().decode(baseDatasource.getPassword());
pwd = new String(decode);
}
dataSource.setPassword(pwd);
}
/**
* 判断数据源类型
*/
private DataSourceIdentity buildIdentity(DataSource dataSource) {
if (dataSource == null) {
return DataSourceIdentity.empty();
}
if (dataSource instanceof HikariDataSource hikari) {
return new DataSourceIdentity(hikari.getJdbcUrl(), hikari.getUsername(), hikari.getPassword());
}
if (dataSource instanceof DuckDbDataSource duckDb) {
return new DataSourceIdentity(duckDb.getJdbcUrl(), duckDb.getUsername(), duckDb.getPassword());
}
return DataSourceIdentity.empty();
}
/**
* 数据源连接关键属性快照,仅用于判断缓存是否需要重建。
*/
private static class DataSourceIdentity {
private final String jdbcUrl;
private final String username;
private final String password;
private DataSourceIdentity(String jdbcUrl, String username, String password) {
this.jdbcUrl = jdbcUrl;
this.username = username;
this.password = password;
}
private static DataSourceIdentity empty() {
return new DataSourceIdentity(null, null, null);
}
/**
* 只有识别到缓存中的数据源类型时才允许复用,避免未知DataSource误判为未修改。
*/
private boolean exists() {
return jdbcUrl != null;
}
/**
* 比较数据库连接关键属性,密码已在调用前解码成明文。
*/
private boolean sameAs(BaseDataSource dataSource) {
return dataSource != null
&& equals(dataSource.getJdbcUrl(), jdbcUrl)
&& equals(dataSource.getUsername(), username)
&& equals(dataSource.getPassword(), password);
}
private boolean equals(String left, String right) {
return Objects.equals(left, right);
}
}
}
@@ -0,0 +1,187 @@
package cn.crabc.core.app.service.system.impl;
import cn.crabc.core.app.entity.BaseApiLog;
import cn.crabc.core.app.entity.param.ApiLogParam;
import cn.crabc.core.app.entity.vo.ApiLogDailyTrendVO;
import cn.crabc.core.app.entity.vo.ApiLogGroupCountVO;
import cn.crabc.core.app.entity.vo.ApiLogStatisticsVO;
import cn.crabc.core.app.entity.vo.ApiLogSummaryVO;
import cn.crabc.core.app.mapper.BaseApiLogMapper;
import cn.crabc.core.app.service.system.IBaseApiLogService;
import cn.crabc.core.datasource.util.PageInfo;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
/**
* api日志 服务实现
*
* @author yuqf
*/
@Service
public class IBaseApiLogServiceImpl implements IBaseApiLogService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String SUCCESS_RESULT = "success";
@Autowired
private BaseApiLogMapper baseApiLogMapper;
@Override
public Integer addLog(BaseApiLog log) {
return baseApiLogMapper.insert(log);
}
@Override
public Integer batchAddLog(List<BaseApiLog> logs) {
if (logs == null || logs.isEmpty()) {
return 0;
}
return baseApiLogMapper.batchInsert(logs);
}
@Override
public PageInfo page(ApiLogParam param) {
PageHelper.startPage(param.getPageNum(), param.getPageSize(), false);
List<BaseApiLog> list = baseApiLogMapper.selectList(param);
return new PageInfo<>(list, param.getPageNum(), param.getPageSize());
}
@Override
public BaseApiLog logDetail(Long logId) {
return baseApiLogMapper.selectOne(logId);
}
@Override
public ApiLogSummaryVO summary(ApiLogParam param) {
ApiLogParam queryParam = param == null ? new ApiLogParam() : param;
fillDefaultTimeRange(queryParam);
return fillSummary(baseApiLogMapper.selectLogSummary(queryParam));
}
@Override
public List<ApiLogDailyTrendVO> dailyTrend(ApiLogParam param) {
ApiLogParam queryParam = param == null ? new ApiLogParam() : param;
fillDefaultTimeRange(queryParam);
return emptyIfNull(baseApiLogMapper.selectDailyTrend(queryParam));
}
@Override
public List<ApiLogGroupCountVO> statusPie(ApiLogParam param) {
ApiLogParam queryParam = param == null ? new ApiLogParam() : param;
fillDefaultTimeRange(queryParam);
return emptyIfNull(baseApiLogMapper.selectStatusStats(queryParam));
}
@Override
public List<ApiLogGroupCountVO> topApis(ApiLogParam param) {
ApiLogParam queryParam = param == null ? new ApiLogParam() : param;
fillDefaultTimeRange(queryParam);
return emptyIfNull(baseApiLogMapper.selectTopApis(queryParam));
}
@Override
public List<ApiLogGroupCountVO> topCostApis(ApiLogParam param) {
ApiLogParam queryParam = param == null ? new ApiLogParam() : copyParam(param);
fillDefaultTimeRange(queryParam);
// 耗时排行只看成功调用,失败接口可能包含异常等待或降级路径,会干扰真实接口性能判断。
queryParam.setResult(SUCCESS_RESULT);
return emptyIfNull(baseApiLogMapper.selectTopCostApis(queryParam));
}
@Override
public List<ApiLogGroupCountVO> topIps(ApiLogParam param) {
ApiLogParam queryParam = param == null ? new ApiLogParam() : param;
fillDefaultTimeRange(queryParam);
return emptyIfNull(baseApiLogMapper.selectTopIps(queryParam));
}
@Override
public ApiLogStatisticsVO statistics(ApiLogParam param) {
ApiLogStatisticsVO statistics = new ApiLogStatisticsVO();
statistics.setSummary(summary(copyParam(param)));
statistics.setDailyTrend(dailyTrend(copyParam(param)));
statistics.setStatusPie(statusPie(copyParam(param)));
statistics.setTopApis(topApis(copyParam(param)));
statistics.setTopCostApis(topCostApis(copyParam(param)));
statistics.setTopIps(topIps(copyParam(param)));
return statistics;
}
/**
* 兼容统计接口会串行调用多个独立统计方法,复制参数避免耗时排行覆盖result后影响其它统计口径。
*/
private ApiLogParam copyParam(ApiLogParam param) {
if (param == null) {
return null;
}
ApiLogParam copy = new ApiLogParam();
copy.setResult(param.getResult());
copy.setKeyword(param.getKeyword());
copy.setAppName(param.getAppName());
copy.setStartTime(param.getStartTime());
copy.setEndTime(param.getEndTime());
copy.setPageNum(param.getPageNum());
copy.setPageSize(param.getPageSize());
return copy;
}
/**
* 统计接口默认只看近七天,避免监控页首次打开时对日志表做全量聚合。
*/
private void fillDefaultTimeRange(ApiLogParam param) {
if (param.getStartTime() != null && !param.getStartTime().isBlank()
&& param.getEndTime() != null && !param.getEndTime().isBlank()) {
return;
}
LocalDate today = LocalDate.now();
param.setStartTime(today.minusDays(6).atStartOfDay().format(DATE_TIME_FORMATTER));
param.setEndTime(today.atTime(LocalTime.MAX).format(DATE_TIME_FORMATTER));
}
/**
* 摘要指标在服务层补齐空值和成功率,前端直接渲染即可。
*/
private ApiLogSummaryVO fillSummary(ApiLogSummaryVO summary) {
ApiLogSummaryVO actual = summary == null ? new ApiLogSummaryVO() : summary;
actual.setTotalCount(defaultLong(actual.getTotalCount()));
actual.setSuccessCount(defaultLong(actual.getSuccessCount()));
actual.setFailCount(defaultLong(actual.getFailCount()));
actual.setAvgCostTime(defaultDouble(actual.getAvgCostTime()));
actual.setMaxCostTime(defaultLong(actual.getMaxCostTime()));
if (actual.getTotalCount() == 0) {
actual.setSuccessRate(0D);
} else {
double rate = actual.getSuccessCount() * 100D / actual.getTotalCount();
actual.setSuccessRate(Math.round(rate * 100D) / 100D);
}
return actual;
}
/**
* Mapper无数据时统一返回空集合,避免调用方处理null分支。
*/
private <T> List<T> emptyIfNull(List<T> list) {
return list == null ? Collections.emptyList() : list;
}
/**
* 数值聚合结果可能因空表返回null,统一转为0方便前端显示。
*/
private Long defaultLong(Long value) {
return value == null ? 0L : value;
}
/**
* 平均耗时等小数聚合结果为空时按0展示。
*/
private Double defaultDouble(Double value) {
return value == null ? 0D : value;
}
}
@@ -1,6 +1,9 @@
package cn.crabc.core.app.util;
import cn.crabc.core.app.entity.dto.ApiInfoDTO;
import cn.crabc.core.app.filter.AuthInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 上下文 当前用户API信息
@@ -8,7 +11,7 @@ import cn.crabc.core.app.entity.dto.ApiInfoDTO;
* @author yuqf
*/
public class ApiThreadLocal {
private static final Logger log = LoggerFactory.getLogger(ApiThreadLocal.class);
private static ThreadLocal<ApiInfoDTO> apiInfo =new ThreadLocal<>();
/**
@@ -16,6 +19,7 @@ public class ApiThreadLocal {
* @param api
*/
public static void set(ApiInfoDTO api){
log.info("--> 设置上下文API信息,{}",api.getApiPath());
apiInfo.set(api);
}
@@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@@ -17,8 +18,9 @@ import java.util.UUID;
public class JwtUtil {
private static String header = "Authorization";
// 令牌秘钥
private static String secret = "crabcjfakdjfaldjflkadjlafjaldkjlaflkalk";
private static String secret = "KLSDJIWKD27JKZDFJALKJKD82KM90DL1NWMD";
public static final String TOKEN_PREFIX = "bearer ";
public static long expirationTime = 1000L * 60 * 60 * 8; // 8小时过期
/**
* 创建令牌
@@ -27,7 +29,6 @@ public class JwtUtil {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("userName", userName);
claims.put("expireTime", System.currentTimeMillis());
return createToken(claims);
}
@@ -36,6 +37,8 @@ public class JwtUtil {
*/
public static String createToken(Map<String, Object> claims) {
String uuid = UUID.randomUUID().toString();
Date now = new Date();
Date expiration = new Date(now.getTime() + expirationTime);
try {
return Jwts.builder()
.header()
@@ -43,7 +46,7 @@ public class JwtUtil {
.add("alg", "HS256")
.and()
.claims(claims)
// 令牌ID
.expiration(expiration)
.id(uuid)
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
@@ -0,0 +1,115 @@
package cn.crabc.core.app.util;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* 请求工具类
*
* @author yuqf
*/
public class RequestUtils {
/**
* 获取 params 参数
*
* @param req
* @return
*/
public static Map<String, Object> getParameters(HttpServletRequest req) {
Map<String, Object> params = new HashMap<>();
Map<String, String[]> parameterMap = req.getParameterMap();
if (parameterMap.isEmpty()) {
return params;
}
Set<String> keys = parameterMap.keySet();
for (String key : keys) {
String[] values = parameterMap.get(key);
if (values.length == 1) {
params.put(key, values[0]);
} else {
params.put(key, values);
}
}
return params;
}
/**
* 获取请求头和URL参数中的认证code
*
* @param req
* @return
*/
public static String getAppCode(HttpServletRequest req) {
String appCode = req.getHeader("appCode");
if (appCode == null) {
String[] codeNames = new String[]{"appCode","AppCode", "app_code"};
for(String codeName : codeNames) {
appCode = req.getParameter(codeName);
if (appCode != null) {
break;
}
}
}
return appCode;
}
/**
* 获取请求头和URL参数中的认证AppKey
*
* @param req
* @return
*/
public static String getAppKey(HttpServletRequest req) {
String appCode = req.getHeader("X-AppKey");
if (appCode == null) {
String[] codeNames = new String[]{"appKey","AppKey", "app_key","appkey"};
for(String codeName : codeNames) {
appCode = req.getParameter(codeName);
if (appCode != null) {
break;
}
}
}
return appCode;
}
/**
* 获取IP地址
*
* @param request
* @return
*/
public static String getIp(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
if (ip.indexOf(",") != -1) {
ip = ip.split(",")[0];
}
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if ("0:0:0:0:0:0:0:1".equals(ip) || "localhost".equals(ip)) {
ip ="127.0.0.1";
}
return ip == null ? "" : ip.trim();
}
}
@@ -0,0 +1,90 @@
package cn.crabc.core.app.util;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.Security;
import java.util.HexFormat;
import java.util.Locale;
/**
* 国密SM3加密工具类
*
* @author yuqf
*/
public class SM3Util {
static {
Security.addProvider(new BouncyCastleProvider());
}
private SM3Util() {
}
/**
* 字符串的SM3算法哈希值
*
* @param data 输入字符串
* @return 十六进制格式的哈希值
*/
public static String hash(String data) {
return hashHex(data);
}
/**
* 字符串的SM3算法哈希值
*
* @param data 输入字符串
* @return 小写十六进制格式的哈希值
*/
public static String hashHex(String data) {
return hash(data.getBytes(StandardCharsets.UTF_8));
}
/**
* 字节数组的SM3算法哈希值。
*
* @param bytes 输入字节数组
* @return 小写十六进制格式的哈希值
*/
public static String hash(byte[] bytes) {
SM3Digest digest = new SM3Digest();
digest.update(bytes, 0, bytes.length);
byte[] result = new byte[digest.getDigestSize()];
digest.doFinal(result, 0);
return HexFormat.of().formatHex(result);
}
/**
* 使用密钥前缀方式生成 SM3 签名,签名串由调用方负责规范化。
*
* @param data 待签名字符串
* @param secret 应用密钥
* @return 小写十六进制格式的签名
*/
public static String sign(String data, String secret) {
return hashHex(secret + data);
}
/**
* 验证带密钥的 SM3 签名,避免调用方手动拼接 secret 和签名串。
*
* @param data 待签名字符串
* @param secret 应用密钥
* @param sign 预期签名
* @return 是否验证通过
*/
public static boolean verify(String data, String secret, String sign) {
return constantTimeEquals(sign(data, secret), sign);
}
private static boolean constantTimeEquals(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(
expected.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII),
actual.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII));
}
}
@@ -43,6 +43,18 @@ public class SQLUtil {
public final static String PARAM_PATTERN = "(?<=[#|$]\\{)(.+?)(?=\\})";
/**
* 多脚本执行只开放DML,避免查询结果集和变更结果混在同一返回语义里。
*/
private static final Set<String> BATCH_DML_TYPE = new HashSet<>(Arrays.asList("insert", "update", "delete"));
/**
* 只识别项目支持的MyBatis动态标签,普通SQL小于号不参与XML深度判断。
*/
private static final Set<String> MYBATIS_XML_TAGS = new HashSet<>(Arrays.asList(
"script", "if", "foreach", "where", "set", "choose", "when", "otherwise", "trim"
));
/**
* 过滤SQL中的注释
@@ -242,6 +254,145 @@ public class SQLUtil {
return columns;
}
/**
* 按脚本级分号切分SQL,字符串、注释、MyBatis动态标签和XML实体中的分号不作为脚本边界。
*
* @param sql 原始SQL脚本
* @return 可执行SQL片段
*/
public static List<String> splitSqlStatements(String sql) {
List<String> statements = new ArrayList<>();
if (sql == null || sql.isBlank()) {
return statements;
}
StringBuilder current = new StringBuilder();
boolean singleQuote = false;
boolean doubleQuote = false;
boolean lineComment = false;
boolean blockComment = false;
int xmlDepth = 0;
for (int i = 0; i < sql.length(); i++) {
char c = sql.charAt(i);
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
if (lineComment) {
current.append(c);
if (c == '\n' || c == '\r') {
lineComment = false;
}
continue;
}
if (blockComment) {
current.append(c);
if (c == '*' && next == '/') {
current.append(next);
i++;
blockComment = false;
}
continue;
}
if (singleQuote) {
current.append(c);
if (c == '\'' && next == '\'') {
current.append(next);
i++;
} else if (c == '\'' && !isBackslashEscaped(sql, i)) {
singleQuote = false;
}
continue;
}
if (doubleQuote) {
current.append(c);
if (c == '"' && next == '"') {
current.append(next);
i++;
} else if (c == '"' && !isBackslashEscaped(sql, i)) {
doubleQuote = false;
}
continue;
}
if (c == '-' && next == '-') {
current.append(c).append(next);
i++;
lineComment = true;
continue;
}
if (c == '/' && next == '/') {
current.append(c).append(next);
i++;
lineComment = true;
continue;
}
if (c == '/' && next == '*') {
current.append(c).append(next);
i++;
blockComment = true;
continue;
}
if (c == '\'') {
current.append(c);
singleQuote = true;
continue;
}
if (c == '"') {
current.append(c);
doubleQuote = true;
continue;
}
if (sql.startsWith("<![CDATA[", i)) {
int cdataEnd = sql.indexOf("]]>", i + 9);
int end = cdataEnd < 0 ? sql.length() - 1 : cdataEnd + 2;
current.append(sql, i, end + 1);
i = end;
continue;
}
if (c == '<') {
XmlTag xmlTag = parseMyBatisTag(sql, i);
if (xmlTag != null) {
current.append(sql, i, xmlTag.endIndex + 1);
if (xmlTag.closing) {
xmlDepth = Math.max(0, xmlDepth - 1);
} else if (!xmlTag.selfClosing) {
xmlDepth++;
}
i = xmlTag.endIndex;
continue;
}
}
if (c == ';' && xmlDepth == 0 && !isXmlEntitySemicolon(sql, i)) {
addSqlStatement(statements, current);
current.setLength(0);
continue;
}
current.append(c);
}
addSqlStatement(statements, current);
return statements;
}
/**
* 校验多脚本只包含insert/update/delete,避免多结果集和纯查询脚本进入批量DML执行链路。
*
* @param statements 已切分的SQL脚本
*/
public static void validateBatchDmlStatements(List<String> statements) {
if (statements == null || statements.size() <= 1) {
return;
}
for (String statement : statements) {
String sqlType = getOperateType(statement);
if (!BATCH_DML_TYPE.contains(sqlType)) {
throw new CustomException(40011, "多脚本只支持insert、update、delete类型SQL");
}
}
}
private static final List<String> DML_TYPE = Arrays.asList("select", "insert", "update", "delete");
/**
@@ -330,10 +481,10 @@ public class SQLUtil {
if (reqParams instanceof Map) {
Map<String, Object> paramMap = (Map<String, Object>) reqParams;
for (BaseApiParam param : paramsConfig) {
if (param.getRequired() && !paramMap.containsKey(param.getParamName())) {
if ("Y".equals(param.getRequired()) && !paramMap.containsKey(param.getParamName())) {
// 必传参数没有传,则报错提示
throw new CustomException(52005, "请求参数缺少!");
} else if (!param.getRequired() && !paramMap.containsKey(param.getParamName())) {
} else if ("N".equals(param.getRequired()) && !paramMap.containsKey(param.getParamName())) {
// 非比传参数,去掉SQL中相应的条件
sql = regexSql(sql, param.getParamName());
}
@@ -374,10 +525,151 @@ public class SQLUtil {
* @return
*/
public static String getOperateType(String sql){
String[] sqls = sql.trim().split(" ");
sql = trimLeadingSqlComment(sql);
String[] sqls = sql.trim().split("\\s+");
return sqls.length > 1 ? sqls[0].toLowerCase().trim() : "select";
}
/**
* 收尾当前脚本片段,空片段来自连续分号或末尾分号,不能进入执行链路。
*/
private static void addSqlStatement(List<String> statements, StringBuilder current) {
String statement = current.toString().trim();
if (!statement.isEmpty()) {
statements.add(statement);
}
}
/**
* 判断引号是否被反斜杠转义,避免字符串里的分号提前结束脚本。
*/
private static boolean isBackslashEscaped(String sql, int index) {
int slashCount = 0;
for (int i = index - 1; i >= 0 && sql.charAt(i) == '\\'; i--) {
slashCount++;
}
return slashCount % 2 == 1;
}
/**
* XML实体自身以分号结束,不能把 &gt;、&lt;= 等动态SQL片段拆成多脚本。
*/
private static boolean isXmlEntitySemicolon(String sql, int semicolonIndex) {
int start = Math.max(0, semicolonIndex - 16);
int ampIndex = sql.lastIndexOf('&', semicolonIndex);
if (ampIndex < start) {
return false;
}
String entity = sql.substring(ampIndex, semicolonIndex + 1);
return entity.matches("&(#\\d+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);");
}
/**
* 只识别MyBatis动态SQL标签,普通SQL里的小于号不进入XML深度计算。
*/
private static XmlTag parseMyBatisTag(String sql, int startIndex) {
int endIndex = findXmlTagEnd(sql, startIndex);
if (endIndex < 0) {
return null;
}
String tagText = sql.substring(startIndex, endIndex + 1);
String tagName = getXmlTagName(tagText);
if (!MYBATIS_XML_TAGS.contains(tagName)) {
return null;
}
boolean closing = tagText.startsWith("</");
boolean selfClosing = tagText.endsWith("/>");
return new XmlTag(endIndex, closing, selfClosing);
}
/**
* 查找标签右边界时跳过属性字符串,避免 test="a &gt; b" 里的符号误判。
*/
private static int findXmlTagEnd(String sql, int startIndex) {
boolean singleQuote = false;
boolean doubleQuote = false;
for (int i = startIndex + 1; i < sql.length(); i++) {
char c = sql.charAt(i);
if (singleQuote) {
if (c == '\'' && !isBackslashEscaped(sql, i)) {
singleQuote = false;
}
continue;
}
if (doubleQuote) {
if (c == '"' && !isBackslashEscaped(sql, i)) {
doubleQuote = false;
}
continue;
}
if (c == '\'') {
singleQuote = true;
} else if (c == '"') {
doubleQuote = true;
} else if (c == '>') {
return i;
}
}
return -1;
}
/**
* 提取标签名用于白名单判断,控制脚本切分只关注项目支持的动态SQL标签。
*/
private static String getXmlTagName(String tagText) {
int index = tagText.startsWith("</") ? 2 : 1;
while (index < tagText.length() && Character.isWhitespace(tagText.charAt(index))) {
index++;
}
int start = index;
while (index < tagText.length() && Character.isLetter(tagText.charAt(index))) {
index++;
}
return tagText.substring(start, index).toLowerCase();
}
/**
* 判断SQL类型前先移除头部注释,避免注释开头的DML被识别成查询。
*/
private static String trimLeadingSqlComment(String sql) {
if (sql == null) {
return "";
}
String result = sql.trim();
boolean removed;
do {
removed = false;
if (result.startsWith("--") || result.startsWith("//")) {
int lineEnd = result.indexOf('\n');
result = lineEnd < 0 ? "" : result.substring(lineEnd + 1).trim();
removed = true;
} else if (result.startsWith("/*")) {
int blockEnd = result.indexOf("*/");
result = blockEnd < 0 ? "" : result.substring(blockEnd + 2).trim();
removed = true;
}
} while (removed);
return result;
}
/**
* MyBatis标签扫描结果,只保存切分所需的右边界和标签类型。
*/
private static class XmlTag {
private final int endIndex;
private final boolean closing;
private final boolean selfClosing;
/**
* 标签对象只在切分过程中使用,避免把XML解析状态泄漏到执行层。
*/
private XmlTag(int endIndex, boolean closing, boolean selfClosing) {
this.endIndex = endIndex;
this.closing = closing;
this.selfClosing = selfClosing;
}
}
/**
* 解析jdbc url里面的类型
* @param jdbcUrl
@@ -463,4 +755,4 @@ public class SQLUtil {
column.setItemIndex(0);
return column;
}
}
}
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more