Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
864435347e | ||
|
|
202b1c430e | ||
|
|
251bbc856c | ||
|
|
6acdca8c4f | ||
|
|
87610820e4 | ||
|
|
1ebe2278f8 | ||
|
|
b61c1c1949 | ||
|
|
6bf63c01c2 | ||
|
|
398d4a1557 | ||
|
|
44c7d0e427 | ||
|
|
0a16b23895 | ||
|
|
8635a512b8 | ||
|
|
cf54f933ec | ||
|
|
852535a8ed | ||
|
|
f2df37eefd | ||
|
|
a4b592bc22 | ||
|
|
da781f1851 | ||
|
|
90d1c5c20f | ||
|
|
1900cff52b | ||
|
|
3642f5658f | ||
|
|
714506a7b3 | ||
|
|
ab5a7aa01d | ||
|
|
905298e34d | ||
|
|
65cd42dac3 | ||
|
|
cbaac891df | ||
|
|
333ebd0d98 | ||
|
|
f79a5fa909 | ||
|
|
02840ad077 | ||
|
|
9f8f37e938 | ||
|
|
90defd5e1d | ||
|
|
b714a734bc | ||
|
|
f93710ad2c | ||
|
|
b2dc24cab5 | ||
|
|
2f81e1ec2a | ||
|
|
fd6f689643 | ||
|
|
c2afdeee08 | ||
|
|
01234a740c | ||
|
|
cad82d44f7 | ||
|
|
6acd9315da | ||
|
|
c3c10e4f9a | ||
|
|
ce889dbfb0 | ||
|
|
ce0cafd294 | ||
|
|
61bd7b93d5 | ||
|
|
e0813f0b34 | ||
|
|
26d07462c6 | ||
|
|
3c5c76b92e | ||
|
|
d9c5bd4047 | ||
|
|
f25b5c6d75 | ||
|
|
f255e2355e | ||
|
|
7789d4dc9c | ||
|
|
127d7dfce9 | ||
|
|
736b500ce5 | ||
|
|
f94c5e5fbb | ||
|
|
25cabceca2 | ||
|
|
65b9622666 | ||
|
|
7cc3a9fd20 | ||
|
|
b80d8b293b | ||
|
|
79c1ce8e11 | ||
|
|
2423ffd7ae | ||
|
|
6cf3c47d09 | ||
|
|
6e2a0e2cb3 | ||
|
|
6d83e16b1c | ||
|
|
25b991c13a | ||
|
|
32b1c1045b | ||
|
|
458266297c | ||
|
|
4139d5bd7b | ||
|
|
8b09976db3 | ||
|
|
8e61b43c3f | ||
|
|
b2f2772a17 | ||
|
|
4314d3410f | ||
|
|
a2a07a2549 | ||
|
|
25c6b49775 | ||
|
|
ee9e51947e | ||
|
|
49e05081f9 | ||
|
|
9420a17bcc | ||
|
|
3b6a95c731 | ||
|
|
c59192c144 | ||
|
|
299bec29e0 | ||
|
|
86bb9c2f2a | ||
|
|
121b6653ab | ||
|
|
9fc3990411 | ||
|
|
7a53390946 | ||
|
|
2e8a930b16 | ||
|
|
444c288079 | ||
|
|
669c8261f5 | ||
|
|
2b752f56e1 | ||
|
|
9b8858999c | ||
|
|
548919485c | ||
|
|
464ee5c738 | ||
|
|
623599998f | ||
|
|
37ba418f87 | ||
|
|
affcfb9234 | ||
|
|
add6362fb8 | ||
|
|
ea85ff64e1 | ||
|
|
f5ff5e4f54 | ||
|
|
2183f2d2f6 | ||
|
|
4e477d86c3 | ||
|
|
284eb0394d | ||
|
|
8e56270cf5 | ||
|
|
6b0c4bea49 | ||
|
|
9edd1c3e00 | ||
|
|
3f0cf9af78 | ||
|
|
ad8636ced0 | ||
|
|
03486448f1 | ||
|
|
5925263443 | ||
|
|
dc7cc0bd79 | ||
|
|
a805e90911 | ||
|
|
9225973cf8 | ||
|
|
3243a4b4e3 | ||
|
|
fd45c67416 | ||
|
|
75025818f5 | ||
|
|
0e954b2d1d |
No files matched your search
+7
-4
@@ -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
@@ -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"
|
||||
|
||||
@@ -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数据服务,
|
||||
助力于企业数据资产价值升级,成为企业数字化转型的重要推动力。
|
||||
|
||||

|
||||
## 功能
|
||||
## 社区版功能
|
||||
- **工作台**:可视化编写SQL快速的对外发布成API接口,支持多SQL同时执行、Mybatis标签语法,实现动态SQL和动态标签
|
||||
- **数据源管理**:动态加载数据库,支持多种SQL/NoSQL数据库。
|
||||
- **应用列表**:Code认证、签名认证的应用创建管理以及对API调用在线授权。
|
||||
- **接口列表**:查看开发中和已发布的API接口,可进行上下线管理,编辑升级等
|
||||
- **数据源**:数据源列表和数据查询,支持多种SQL/NoSQL数据库添加。
|
||||
- **应用凭证**:AppKey认证、签名认证的应用创建管理以及对API调用在线授权。
|
||||
- **接口列表**:查看开发中和已发布的API接口,可进行上下线管理,限流控制等
|
||||
- **接口文档**:生成在线接口文档,支持在线测试。
|
||||
- **指标统计**:展示场景的接口调用指标统计
|
||||
- **接口日志**:查看发布的接口被调用日志列表和请求详情。
|
||||
|
||||
## 接口开发流程
|
||||

|
||||
## 模块
|
||||
~~~
|
||||
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黑白名单 | 不支持 | 支持 |
|
||||
| 监控报表 | 支持 | 支持 |
|
||||
| 集群模式 | 不支持 | 支持 |
|
||||
## 效果截图
|
||||
### 接口开发
|
||||

|
||||

|
||||
#### 接口属性
|
||||

|
||||
#### 请求参数
|
||||
#### 返回参数
|
||||

|
||||
#### 在线预览
|
||||

|
||||
#### 接口列表
|
||||

|
||||
#### 数据库类型
|
||||
#### 接口列表
|
||||

|
||||
#### 接口限流
|
||||

|
||||
#### 指标统计
|
||||

|
||||
#### 日志管理
|
||||

|
||||
#### 数据源列表
|
||||

|
||||
### 限流熔断
|
||||

|
||||
|
||||
## 产品使用登记
|
||||
以下为部分接入crabc-api的用户 ,<a href="https://gitee.com/linebyte/crabc/issues/IA98CA" target="_blank"> 点击进行登记</a>
|
||||
|
||||
| 公司机构 |
|
||||
|:-------------|
|
||||
| 西部通信研究院 |
|
||||
| 青岛巴士科技有限公司 |
|
||||
| 阳光智维科技股份有限公司 |
|
||||
| 一步数据科技有限公司 |
|
||||
| 像素云图科技有限公司 |
|
||||
| 前景科技有限公司 |
|
||||
| 方寸图信息有限公司 |
|
||||
| 鹭栖技术信息公司 |
|
||||
| 循脉医疗科技有限公司 |
|
||||
| 云上贵州大数据有限公司 |
|
||||
#### 数据查询
|
||||

|
||||
#### 数据源类型
|
||||

|
||||
|
||||
## 交流群
|
||||
**进群前请先点【Star】谢谢**
|
||||
|
||||
QQ群:748993036
|
||||
QQ群(请先Star):748993036
|
||||
|
||||
商用咨询加微信 (请备注crabc)
|
||||
商用咨询加微信 (请备注apigo)
|
||||
|
||||

|
||||

|
||||
|
||||
## 产品使用登记
|
||||
以下为部分接入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>
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -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 # 日志是否保存响应结果
|
||||
File renamed without changes.
+6
@@ -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>
|
||||
+250
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -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;
|
||||
}
|
||||
+4
-1
@@ -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<>());
|
||||
// 设置默认数据源
|
||||
+4
-2
@@ -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
-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;
|
||||
+24
-3
@@ -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
|
||||
*
|
||||
+112
@@ -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));
|
||||
}
|
||||
}
|
||||
+195
@@ -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));
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
-3
@@ -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);
|
||||
}
|
||||
File renamed without changes.
+2
-1
@@ -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);
|
||||
+16
-2
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
+26
-6
@@ -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;
|
||||
|
||||
}
|
||||
File renamed without changes.
+2
-2
@@ -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
|
||||
+3
-1
@@ -33,13 +33,15 @@ public class BaseApiSql extends BaseEntity {
|
||||
/**
|
||||
* 数据源Id
|
||||
*/
|
||||
private String datasourceId;
|
||||
private Integer datasourceId;
|
||||
|
||||
/**
|
||||
* schema
|
||||
*/
|
||||
private String schemaName;
|
||||
|
||||
private String tableName;
|
||||
|
||||
/**
|
||||
* 数据源类型
|
||||
*/
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+5
@@ -39,4 +39,9 @@ public class ApiInfoVO {
|
||||
*/
|
||||
private List<BaseApiParam> responseParam = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 是否存在已发布接口暂存内容
|
||||
*/
|
||||
private Boolean hasDraft = false;
|
||||
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
File renamed without changes.
@@ -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;
|
||||
}
|
||||
+5
-2
@@ -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;
|
||||
/**
|
||||
* 发布时间
|
||||
*/
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
@@ -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;
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
@@ -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;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
-11
@@ -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;
|
||||
+25
@@ -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);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+33
-8
@@ -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());
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -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);
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
crabc-api/crabc-core/src/main/java/cn/crabc/core/app/service/system/impl/BaseApiInfoServiceImpl.java
Executable
+575
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+12
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
+66
-16
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+187
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
+6
-3
@@ -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();
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+115
@@ -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();
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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));
|
||||
}
|
||||
}
|
||||
+296
-4
@@ -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实体自身以分号结束,不能把 >、<= 等动态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 > 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
Reference in new issue
Block a user