diff --git a/jeecg-boot/db/jeecgboot-mysql-5.7.sql b/jeecg-boot/db/jeecgboot-mysql-5.7.sql
index 65110b5b7e6..2ba1fa54503 100644
--- a/jeecg-boot/db/jeecgboot-mysql-5.7.sql
+++ b/jeecg-boot/db/jeecgboot-mysql-5.7.sql
@@ -4937,6 +4937,12 @@ CREATE TABLE `open_api` (
`update_time` datetime NULL DEFAULT NULL COMMENT '修改时间',
`headers_json` json NULL COMMENT '请求头json',
`params_json` json NULL COMMENT '请求参数json',
+ `list_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WHITELIST' COMMENT '访问清单模式:WHITELIST/BLACKLIST' AFTER `request_url`,
+ `allowed_list` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单,多IP/CIDR/域名,逗号或换行分隔' AFTER `black_list`,
+ `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单备注' AFTER `allowed_list`,
+ `dns_cache_ttl_seconds` int(11) NULL DEFAULT NULL COMMENT 'DNS缓存TTL秒' AFTER `comment`,
+ `ip_version` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'Dual' COMMENT 'IPv4/IPv6/Dual' AFTER `dns_cache_ttl_seconds`,
+ `enable_strict` tinyint(1) NOT NULL DEFAULT 0 COMMENT '严格模式开关' AFTER `ip_version`,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '接口表' ROW_FORMAT = DYNAMIC;
@@ -4944,7 +4950,14 @@ CREATE TABLE `open_api` (
-- Records of open_api
-- ----------------------------
INSERT INTO `open_api` VALUES ('1922132683346649090', '根据部门查询用户', 'GET', 'TEwcXBlr', NULL, NULL, '/sys/user/queryUserByDepId', 1, 0, 'admin', '2025-05-13 11:31:58', 'admin', '2025-05-15 10:10:01', '[]', '[{\"id\": \"row_24\", \"note\": \"\", \"paramKey\": \"id\", \"required\": \"1\", \"defaultValue\": \"\"}]');
-
+-- 数据迁移:老记录list_mode统一置为WHITELIST
+UPDATE `open_api`
+SET `list_mode` = 'WHITELIST'
+WHERE `list_mode` IS NULL OR `list_mode` = '';
+
+-- 索引:最近变更排序
+ALTER TABLE `open_api`
+ ADD INDEX `idx_open_api_update_time` (`update_time`);
-- ----------------------------
-- Table structure for open_api_auth
-- ----------------------------
diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml
index 2a01534f8be..930fa52de04 100644
--- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml
+++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml
@@ -75,4 +75,81 @@
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ **/*Test.java
+ **/*Tests.java
+
+
+ test
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.11
+
+
+ prepare-agent
+ initialize
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+ check
+ test
+
+ check
+
+
+
+
+ BUNDLE
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ 0.70
+
+
+
+
+
+
+
+
+
+
+
diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java
index 05b4fe7202b..35b64e4dd64 100644
--- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java
+++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java
@@ -96,6 +96,36 @@ public class OpenApi implements Serializable {
* 更新时间
*/
private Date updateTime;
+
+ /**
+ * 访问模式:WHITELIST/BLACKLIST
+ */
+ private String listMode;
+
+ /**
+ * 允许/拒绝清单,支持IP、CIDR、域名
+ */
+ private String allowedList;
+
+ /**
+ * 清单备注
+ */
+ private String comment;
+
+ /**
+ * 严格模式开关
+ */
+ private Boolean enableStrict;
+
+ /**
+ * DNS缓存TTL(秒)
+ */
+ private Integer dnsCacheTtlSeconds;
+
+ /**
+ * IP版本:IPv4/IPv6/Dual
+ */
+ private String ipVersion;
/**
* 历史已选接口
*/
diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java
index a301d13b6c7..5b2687b017e 100644
--- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java
+++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java
@@ -17,9 +17,12 @@
import java.io.IOException;
import java.security.MessageDigest;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
+import java.util.stream.Collectors;
/**
* @date 2024/12/19 16:55
@@ -46,8 +49,8 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
OpenApi openApi = findOpenApi(request);
- // IP 黑名单核验
- checkBlackList(openApi, ip);
+ // 访问清单核验
+ checkAccessList(openApi, ip);
// 签名核验
checkSignValid(appkey, signature, timestamp);
@@ -81,21 +84,123 @@ public void init(FilterConfig filterConfig) throws ServletException {
}
/**
- * IP 黑名单核验
+ * 访问清单核验
* @param openApi
* @param ip
*/
- protected void checkBlackList(OpenApi openApi, String ip) {
- if (!StringUtils.hasText(openApi.getBlackList())) {
- return;
+ protected void checkAccessList(OpenApi openApi, String ip) {
+ // 获取访问模式,默认白名单
+ String listMode = StringUtils.hasText(openApi.getListMode()) ? openApi.getListMode() : "WHITELIST";
+ // 检查列表模式是否有效,无效模式默认为白名单
+ if (!"WHITELIST".equals(listMode) && !"BLACKLIST".equals(listMode)) {
+ listMode = "WHITELIST";
}
+ String allowedList = openApi.getAllowedList();
- List blackList = Arrays.asList(openApi.getBlackList().split(","));
- if (blackList.contains(ip)) {
- throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问");
+ // 如果清单为空,根据模式处理
+ if (!StringUtils.hasText(allowedList)) {
+ if ("WHITELIST".equals(listMode)) {
+ // 白名单模式下清单为空,拒绝访问
+ throw new JeecgBootException("目标接口白名单为空,拒绝访问");
+ } else {
+ // 黑名单模式下清单为空,无屏蔽项,放行
+ return;
+ }
+ }
+
+ // 解析清单
+ List listItems = Arrays.asList(allowedList.split("[,\\n]")).stream()
+ .map(String::trim)
+ .filter(StringUtils::hasText)
+ .collect(Collectors.toList());
+
+ // 匹配结果
+ boolean isMatch = false;
+ for (String item : listItems) {
+ if (isIpMatch(ip, item) || isDomainMatch(ip, item)) {
+ isMatch = true;
+ break;
+ }
+ }
+
+ // 根据模式判断是否放行
+ if ("WHITELIST".equals(listMode)) {
+ if (!isMatch) {
+ throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问");
+ }
+ } else {
+ if (isMatch) {
+ throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问");
+ }
+ }
+ }
+
+ /**
+ * IP/CIDR匹配
+ * @param ip
+ * @param item
+ * @return
+ */
+ private boolean isIpMatch(String ip, String item) {
+ // 简单实现IP和CIDR匹配,实际项目中建议使用成熟的IP库
+ if (item.contains("/")) {
+ // CIDR匹配
+ String[] parts = item.split("/");
+ if (parts.length != 2) {
+ return false;
+ }
+ String cidrIp = parts[0];
+ int prefixLength = Integer.parseInt(parts[1]);
+
+ // 这里只实现IPv4的CIDR匹配,IPv6需要更复杂的处理
+ if (ip.contains(".") && cidrIp.contains(".")) {
+ long ipLong = ipToLong(ip);
+ long cidrLong = ipToLong(cidrIp);
+ long mask = prefixLength == 0 ? 0 : (-1L << (32 - prefixLength));
+ return (ipLong & mask) == (cidrLong & mask);
+ }
+ return false;
+ } else {
+ // 精确IP匹配
+ return ip.equals(item);
}
}
+ /**
+ * 域名匹配
+ * @param ip
+ * @param domain
+ * @return
+ */
+ private boolean isDomainMatch(String ip, String domain) {
+ // 简单实现域名匹配,实际项目中需要考虑DNS缓存和IPv6
+ try {
+ InetAddress[] addresses = InetAddress.getAllByName(domain);
+ for (InetAddress addr : addresses) {
+ if (addr.getHostAddress().equals(ip)) {
+ return true;
+ }
+ }
+ } catch (UnknownHostException e) {
+ log.warn("DNS解析失败: {}", domain);
+ }
+ return false;
+ }
+
+ /**
+ * IPv4转long
+ * @param ip
+ * @return
+ */
+ private long ipToLong(String ip) {
+ String[] parts = ip.split("\\.");
+ long result = 0;
+ for (int i = 0; i < 4; i++) {
+ result |= Long.parseLong(parts[i]) << (24 - (8 * i));
+ }
+ return result;
+ }
+
/**
* 签名验证
* @param appkey
diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/test/java/org/jeecg/modules/openapi/filter/ApiAuthFilterTest.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/test/java/org/jeecg/modules/openapi/filter/ApiAuthFilterTest.java
new file mode 100644
index 00000000000..fa55a88e502
--- /dev/null
+++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/test/java/org/jeecg/modules/openapi/filter/ApiAuthFilterTest.java
@@ -0,0 +1,918 @@
+package org.jeecg.modules.openapi.filter;
+
+import org.jeecg.common.exception.JeecgBootException;
+import org.jeecg.modules.openapi.entity.OpenApi;
+import org.jeecg.modules.openapi.entity.OpenApiAuth;
+import org.jeecg.modules.openapi.entity.OpenApiPermission;
+import org.jeecg.modules.openapi.service.OpenApiAuthService;
+import org.jeecg.modules.openapi.service.OpenApiPermissionService;
+import org.jeecg.modules.openapi.service.OpenApiService;
+import org.jeecg.modules.openapi.service.OpenApiLogService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.context.ActiveProfiles;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * ApiAuthFilter单元测试
+ * 测试OpenAPI的黑名单和白名单校验功能
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("API认证过滤器测试")
+public class ApiAuthFilterTest {
+
+ /**
+ * 测试配置类
+ */
+ @Configuration
+ static class TestConfig {
+
+ @Bean
+ public ApiAuthFilter apiAuthFilter() {
+ return new ApiAuthFilter();
+ }
+
+ @Bean
+ public TestableApiAuthFilter testableApiAuthFilter() {
+ return new TestableApiAuthFilter();
+ }
+
+ @Bean
+ public OpenApiService openApiService() {
+ return mock(OpenApiService.class);
+ }
+
+ @Bean
+ public OpenApiAuthService openApiAuthService() {
+ return mock(OpenApiAuthService.class);
+ }
+
+ @Bean
+ public OpenApiPermissionService openApiPermissionService() {
+ return mock(OpenApiPermissionService.class);
+ }
+
+ @Bean
+ public OpenApiLogService openApiLogService() {
+ return mock(OpenApiLogService.class);
+ }
+ }
+
+ /**
+ * 子类暴露protected方法以便测试
+ */
+ static class TestableApiAuthFilter extends ApiAuthFilter {
+ public void invokeCheckAccessList(OpenApi openApi, String ip) {
+ super.checkAccessList(openApi, ip);
+ }
+
+ public void invokeCheckSignValid(String appkey, String signature, String timestamp) {
+ super.checkSignValid(appkey, signature, timestamp);
+ }
+
+ public void invokeCheckSignature(String appKey, String signature, String timestamp, OpenApiAuth openApiAuth) {
+ super.checkSignature(appKey, signature, timestamp, openApiAuth);
+ }
+
+ public void invokeCheckPermission(OpenApi openApi, OpenApiAuth openApiAuth) {
+ super.checkPermission(openApi, openApiAuth);
+ }
+
+ public OpenApi invokeFindOpenApi(HttpServletRequest request) {
+ return super.findOpenApi(request);
+ }
+
+ public String invokeMd5(String sourceStr) {
+ return super.md5(sourceStr);
+ }
+ }
+
+ @InjectMocks
+ private TestableApiAuthFilter filter;
+
+ @Mock
+ private OpenApiService openApiService;
+
+ @Mock
+ private OpenApiAuthService openApiAuthService;
+
+ @Mock
+ private OpenApiPermissionService openApiPermissionService;
+
+ @Mock
+ private OpenApiLogService openApiLogService;
+
+ @BeforeEach
+ void setUp() {
+ assertNotNull(filter, "ApiAuthFilter bean should be injected");
+ assertNotNull(openApiService, "OpenApiService bean should be injected");
+ assertNotNull(openApiAuthService, "OpenApiAuthService bean should be injected");
+ assertNotNull(openApiPermissionService, "OpenApiPermissionService bean should be injected");
+ assertNotNull(openApiLogService, "OpenApiLogService bean should be injected");
+ }
+
+ private OpenApi createOpenApi(String listMode, String allowedList) {
+ OpenApi openApi = new OpenApi();
+ openApi.setListMode(listMode.toUpperCase());
+ openApi.setAllowedList(allowedList);
+ return openApi;
+ }
+
+ // ==================== 白名单测试用例 ====================
+
+ @Test
+ @DisplayName("白名单模式 - 允许IP在白名单中")
+ void testWhitelist_AllowedIpInList() {
+ OpenApi openApi = createOpenApi("whitelist", "192.168.1.100,10.0.0.1");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+ }
+
+ @Test
+ @DisplayName("白名单模式 - 拒绝IP不在白名单中")
+ void testWhitelist_DeniedIpNotInList() {
+ OpenApi openApi = createOpenApi("whitelist", "192.168.1.100,10.0.0.1");
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.200");
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ @Test
+ @DisplayName("白名单模式 - 允许IP段匹配")
+ void testWhitelist_AllowedIpRangeMatch() {
+ OpenApi openApi = createOpenApi("whitelist", "192.168.1.0/24,10.0.0.0/16");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.150");
+ });
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "10.0.5.100");
+ });
+ }
+
+ @Test
+ @DisplayName("白名单模式 - 拒绝IP段不匹配")
+ void testWhitelist_DeniedIpRangeNotMatch() {
+ OpenApi openApi = createOpenApi("whitelist", "192.168.1.0/24");
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.2.100");
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ // ==================== 黑名单测试用例 ====================
+
+ @Test
+ @DisplayName("黑名单模式 - 允许IP不在黑名单中")
+ void testBlacklist_AllowedIpNotInList() {
+ OpenApi openApi = createOpenApi("blacklist", "192.168.1.100,10.0.0.1");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.200");
+ });
+ }
+
+ @Test
+ @DisplayName("黑名单模式 - 拒绝IP在黑名单中")
+ void testBlacklist_DeniedIpInList() {
+ OpenApi openApi = createOpenApi("blacklist", "192.168.1.100,10.0.0.1");
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ @Test
+ @DisplayName("黑名单模式 - 拒绝IP段匹配")
+ void testBlacklist_DeniedIpRangeMatch() {
+ OpenApi openApi = createOpenApi("blacklist", "192.168.1.0/24,10.0.0.0/16");
+
+ JeecgBootException exception1 = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.150");
+ });
+ assertTrue(exception1.getMessage().contains("目标接口限制IP"));
+
+ JeecgBootException exception2 = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "10.0.5.100");
+ });
+ assertTrue(exception2.getMessage().contains("目标接口限制IP"));
+ }
+
+ @Test
+ @DisplayName("黑名单模式 - 允许IP段不匹配")
+ void testBlacklist_AllowedIpRangeNotMatch() {
+ OpenApi openApi = createOpenApi("blacklist", "192.168.1.0/24");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.2.100");
+ });
+ }
+
+ // ==================== 边界条件测试用例 ====================
+
+ @Test
+ @DisplayName("白名单模式 - 空访问列表拒绝访问")
+ void testWhitelist_EmptyAccessList() {
+ OpenApi openApi = createOpenApi("whitelist", "");
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口白名单为空"));
+ }
+
+ @Test
+ @DisplayName("黑名单模式 - 空访问列表允许访问")
+ void testBlacklist_EmptyAccessList() {
+ OpenApi openApi = createOpenApi("blacklist", "");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+ }
+
+ @Test
+ @DisplayName("白名单模式 - null访问列表拒绝访问")
+ void testWhitelist_NullAccessList() {
+ OpenApi openApi = createOpenApi("whitelist", null);
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口白名单为空"));
+ }
+
+ @Test
+ @DisplayName("黑名单模式 - null访问列表允许访问")
+ void testBlacklist_NullAccessList() {
+ OpenApi openApi = createOpenApi("blacklist", null);
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+ }
+
+ @Test
+ @DisplayName("无效列表模式 - 默认白名单行为")
+ void testInvalidListMode_DefaultWhitelistBehavior() {
+ OpenApi openApi = createOpenApi("invalid_mode", "192.168.1.100");
+
+ // 无效模式应该默认为白名单行为
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.200");
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ // ==================== 复杂场景测试用例 ====================
+
+ @Test
+ @DisplayName("白名单模式 - 混合IP和CIDR格式")
+ void testWhitelist_MixedIpAndCidrFormat() {
+ OpenApi openApi = createOpenApi("whitelist", "192.168.1.100,192.168.2.0/24,10.0.0.1");
+
+ // 测试精确IP匹配
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ // 测试CIDR网段匹配
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.2.150");
+ });
+
+ // 测试另一个精确IP匹配
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "10.0.0.1");
+ });
+
+ // 测试不在列表中的IP
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.200");
+ });
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ @Test
+ @DisplayName("黑名单模式 - 混合IP和CIDR格式")
+ void testBlacklist_MixedIpAndCidrFormat() {
+ OpenApi openApi = createOpenApi("blacklist", "192.168.1.100,192.168.2.0/24,10.0.0.1");
+
+ // 测试被精确IP拒绝
+ JeecgBootException exception1 = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+ assertTrue(exception1.getMessage().contains("目标接口限制IP"));
+
+ // 测试被CIDR网段拒绝
+ JeecgBootException exception2 = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.2.150");
+ });
+ assertTrue(exception2.getMessage().contains("目标接口限制IP"));
+
+ // 测试被另一个精确IP拒绝
+ JeecgBootException exception3 = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "10.0.0.1");
+ });
+ assertTrue(exception3.getMessage().contains("目标接口限制IP"));
+
+ // 测试不在黑名单中的IP允许访问
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.200");
+ });
+ }
+
+ @Test
+ @DisplayName("IP列表包含空格和换行符处理")
+ void testIpListWithSpacesAndNewlines() {
+ OpenApi openApi = createOpenApi("whitelist", " 192.168.1.100 , 10.0.0.1\n, 172.16.0.1 ");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "10.0.0.1");
+ });
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "172.16.0.1");
+ });
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.200");
+ });
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ @Test
+ @DisplayName("极端CIDR网段测试")
+ void testExtremeCidrRanges() {
+ OpenApi openApi = createOpenApi("whitelist", "0.0.0.0/0");
+
+ // 0.0.0.0/0 应该匹配所有IP
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "10.0.0.1");
+ });
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "255.255.255.255");
+ });
+ }
+
+ @Test
+ @DisplayName("本地回环地址测试")
+ void testLoopbackAddressTest() {
+ OpenApi openApi = createOpenApi("whitelist", "127.0.0.1");
+
+ assertDoesNotThrow(() -> {
+ filter.invokeCheckAccessList(openApi, "127.0.0.1");
+ });
+
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.invokeCheckAccessList(openApi, "192.168.1.100");
+ });
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+ }
+
+ // ==================== doFilter 集成测试用例 ====================
+
+ @Test
+ @DisplayName("doFilter - 正常流程测试")
+ void testDoFilter_NormalFlow() throws Exception {
+ // 准备测试数据
+ String appKey = "test-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String signature = filter.invokeMd5(appKey + secretKey + timestamp);
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn(signature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 创建OpenApiAuth对象
+ OpenApiAuth openApiAuth = new OpenApiAuth();
+ openApiAuth.setId("test-auth-id");
+ openApiAuth.setAk(appKey);
+ openApiAuth.setSk(secretKey);
+ when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth);
+
+ // 创建权限对象
+ OpenApiPermission permission = new OpenApiPermission();
+ permission.setApiId("test-api-id");
+ permission.setApiAuthId("test-auth-id");
+ when(openApiPermissionService.findByAuthId("test-auth-id")).thenReturn(Arrays.asList(permission));
+
+ // 执行doFilter
+ assertDoesNotThrow(() -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ // 验证filterChain被调用
+ verify(filterChain, times(1)).doFilter(request, response);
+
+ // 验证日志被保存
+ verify(openApiLogService, times(1)).save(any());
+ }
+
+ @Test
+ @DisplayName("doFilter - IP白名单验证失败")
+ void testDoFilter_WhitelistValidationFailed() throws Exception {
+ // 准备测试数据
+ String appKey = "test-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String signature = filter.invokeMd5(appKey + secretKey + timestamp);
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - IP不在白名单中
+ when(request.getRemoteAddr()).thenReturn("192.168.1.200");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn(signature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象 - 白名单只包含192.168.1.100
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 签名验证失败 - appkey为空")
+ void testDoFilter_SignValidationFailed_AppKeyEmpty() throws Exception {
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - appkey为空
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(null);
+ when(request.getHeader("signature")).thenReturn("test-signature");
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(System.currentTimeMillis()));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("appkey为空"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 签名验证失败 - signature为空")
+ void testDoFilter_SignValidationFailed_SignatureEmpty() throws Exception {
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - signature为空
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn("test-app-key");
+ when(request.getHeader("signature")).thenReturn(null);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(System.currentTimeMillis()));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("signature为空"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 签名验证失败 - timestamp为空")
+ void testDoFilter_SignValidationFailed_TimestampEmpty() throws Exception {
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - timestamp为空
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn("test-app-key");
+ when(request.getHeader("signature")).thenReturn("test-signature");
+ when(request.getHeader("timestamp")).thenReturn(null);
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("timastamp时间戳为空"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 签名验证失败 - timestamp过期")
+ void testDoFilter_SignValidationFailed_TimestampExpired() throws Exception {
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - timestamp过期(超过5分钟)
+ long expiredTimestamp = System.currentTimeMillis() - 6 * 60 * 1000; // 6分钟前
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn("test-app-key");
+ when(request.getHeader("signature")).thenReturn("test-signature");
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(expiredTimestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("signature签名已过期"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 认证信息验证失败 - appkey不存在")
+ void testDoFilter_AuthValidationFailed_AppKeyNotFound() throws Exception {
+ // 准备测试数据
+ String appKey = "non-existent-app-key";
+ long timestamp = System.currentTimeMillis();
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn("test-signature");
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 模拟appkey不存在
+ when(openApiAuthService.getByAppkey(appKey)).thenReturn(null);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("不存在认证信息"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 认证信息验证失败 - appkey错误")
+ void testDoFilter_AuthValidationFailed_AppKeyMismatch() throws Exception {
+ // 准备测试数据
+ String requestAppKey = "test-app-key";
+ String storedAppKey = "different-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String signature = filter.invokeMd5(requestAppKey + secretKey + timestamp);
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(requestAppKey);
+ when(request.getHeader("signature")).thenReturn(signature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 创建OpenApiAuth对象 - 存储的appkey与请求的不匹配
+ OpenApiAuth openApiAuth = new OpenApiAuth();
+ openApiAuth.setId("test-auth-id");
+ openApiAuth.setAk(storedAppKey); // 不同的appkey
+ openApiAuth.setSk(secretKey);
+ when(openApiAuthService.getByAppkey(requestAppKey)).thenReturn(openApiAuth);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("appkey错误"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 认证信息验证失败 - 签名错误")
+ void testDoFilter_AuthValidationFailed_SignatureMismatch() throws Exception {
+ // 准备测试数据
+ String appKey = "test-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String wrongSignature = "wrong-signature";
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - 错误的签名
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn(wrongSignature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 创建OpenApiAuth对象
+ OpenApiAuth openApiAuth = new OpenApiAuth();
+ openApiAuth.setId("test-auth-id");
+ openApiAuth.setAk(appKey);
+ openApiAuth.setSk(secretKey);
+ when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("signature签名错误"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 权限验证失败 - 无权限访问接口")
+ void testDoFilter_PermissionValidationFailed_NoPermission() throws Exception {
+ // 准备测试数据
+ String appKey = "test-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String signature = filter.invokeMd5(appKey + secretKey + timestamp);
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn(signature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("WHITELIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 创建OpenApiAuth对象
+ OpenApiAuth openApiAuth = new OpenApiAuth();
+ openApiAuth.setId("test-auth-id");
+ openApiAuth.setAk(appKey);
+ openApiAuth.setSk(secretKey);
+ when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth);
+
+ // 模拟无权限 - 返回空列表或不包含当前API权限的列表
+ OpenApiPermission differentPermission = new OpenApiPermission();
+ differentPermission.setApiId("different-api-id");
+ differentPermission.setApiAuthId("test-auth-id");
+ when(openApiPermissionService.findByAuthId("test-auth-id")).thenReturn(Arrays.asList(differentPermission));
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("该appKey未授权当前接口"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 黑名单模式 - IP在黑名单中被拒绝")
+ void testDoFilter_Blacklist_IpInBlacklist() throws Exception {
+ // 准备测试数据
+ String appKey = "test-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String signature = filter.invokeMd5(appKey + secretKey + timestamp);
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - IP在黑名单中
+ when(request.getRemoteAddr()).thenReturn("192.168.1.100");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn(signature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象 - 黑名单模式,包含该IP
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("BLACKLIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 执行doFilter,应该抛出异常
+ JeecgBootException exception = assertThrows(JeecgBootException.class, () -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ assertTrue(exception.getMessage().contains("目标接口限制IP"));
+
+ // 验证filterChain未被调用
+ verify(filterChain, never()).doFilter(request, response);
+ }
+
+ @Test
+ @DisplayName("doFilter - 黑名单模式 - IP不在黑名单中允许访问")
+ void testDoFilter_Blacklist_IpNotInBlacklist() throws Exception {
+ // 准备测试数据
+ String appKey = "test-app-key";
+ String secretKey = "test-secret-key";
+ long timestamp = System.currentTimeMillis();
+ String signature = filter.invokeMd5(appKey + secretKey + timestamp);
+
+ // 创建模拟对象
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ ServletResponse response = mock(ServletResponse.class);
+ FilterChain filterChain = mock(FilterChain.class);
+
+ // 设置请求参数 - IP不在黑名单中
+ when(request.getRemoteAddr()).thenReturn("192.168.1.200");
+ when(request.getHeader("appkey")).thenReturn(appKey);
+ when(request.getHeader("signature")).thenReturn(signature);
+ when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp));
+ when(request.getRequestURI()).thenReturn("/api/test");
+
+ // 创建OpenApi对象 - 黑名单模式,不包含该IP
+ OpenApi openApi = new OpenApi();
+ openApi.setId("test-api-id");
+ openApi.setListMode("BLACKLIST");
+ openApi.setAllowedList("192.168.1.100");
+ when(openApiService.findByPath("test")).thenReturn(openApi);
+
+ // 创建OpenApiAuth对象
+ OpenApiAuth openApiAuth = new OpenApiAuth();
+ openApiAuth.setId("test-auth-id");
+ openApiAuth.setAk(appKey);
+ openApiAuth.setSk(secretKey);
+ when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth);
+
+ // 创建权限对象
+ OpenApiPermission permission = new OpenApiPermission();
+ permission.setApiId("test-api-id");
+ permission.setApiAuthId("test-auth-id");
+ when(openApiPermissionService.findByAuthId("test-auth-id")).thenReturn(Arrays.asList(permission));
+
+ // 执行doFilter
+ assertDoesNotThrow(() -> {
+ filter.doFilter(request, response, filterChain);
+ });
+
+ // 验证filterChain被调用
+ verify(filterChain, times(1)).doFilter(request, response);
+
+ // 验证日志被保存
+ verify(openApiLogService, times(1)).save(any());
+ }
+}
\ No newline at end of file
diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
index 09e1b3487c9..d25385c2cbd 100644
--- a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
+++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
@@ -151,7 +151,7 @@ spring:
master:
url: jdbc:mysql://127.0.0.1:3306/jeecg-boot?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
- password: root
+ password: bd198127
driver-class-name: com.mysql.cj.jdbc.Driver
# # shardingjdbc数据源
# sharding-db:
diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.8.3_1__openapi_accesslist.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.8.3_1__openapi_accesslist.sql
new file mode 100644
index 00000000000..33226351e33
--- /dev/null
+++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.8.3_1__openapi_accesslist.sql
@@ -0,0 +1,38 @@
+/*
+ OpenApi 访问清单字段增量升级
+ - 新增列:list_mode, allowed_list, comment, dns_cache_ttl_seconds, ip_version, enable_strict
+ - 索引:idx_open_api_update_time
+ - 数据迁移:老记录 list_mode 置 WHITELIST
+*/
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- 添加新字段
+ALTER TABLE `open_api`
+ ADD COLUMN `list_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WHITELIST' COMMENT '访问清单模式:WHITELIST/BLACKLIST' AFTER `request_url`,
+ ADD COLUMN `allowed_list` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单,多IP/CIDR/域名,逗号或换行分隔' AFTER `black_list`,
+ ADD COLUMN `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单备注' AFTER `allowed_list`,
+ ADD COLUMN `dns_cache_ttl_seconds` int(11) NULL DEFAULT NULL COMMENT 'DNS缓存TTL秒' AFTER `comment`,
+ ADD COLUMN `ip_version` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'Dual' COMMENT 'IPv4/IPv6/Dual' AFTER `dns_cache_ttl_seconds`,
+ ADD COLUMN `enable_strict` tinyint(1) NOT NULL DEFAULT 0 COMMENT '严格模式开关' AFTER `ip_version`;
+
+-- 数据迁移:老记录list_mode统一置为WHITELIST
+UPDATE `open_api`
+SET `list_mode` = 'WHITELIST'
+WHERE `list_mode` IS NULL OR `list_mode` = '';
+
+-- 索引:最近变更排序
+ALTER TABLE `open_api`
+ ADD INDEX `idx_open_api_update_time` (`update_time`);
+
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- 回滚要点(如需回滚,手工执行以下SQL)
+-- ALTER TABLE `open_api`
+-- DROP COLUMN `list_mode`,
+-- DROP COLUMN `allowed_list`,
+-- DROP COLUMN `comment`,
+-- DROP COLUMN `dns_cache_ttl_seconds`,
+-- DROP COLUMN `ip_version`,
+-- DROP COLUMN `enable_strict`;
+-- DROP INDEX `idx_open_api_update_time` ON `open_api`;
\ No newline at end of file
diff --git a/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts b/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts
index a94b4da0c93..67c52b96f29 100644
--- a/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts
+++ b/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts
@@ -22,15 +22,32 @@ export const columns: BasicColumn[] = [
dataIndex: 'requestUrl'
},
{
- title: 'IP 黑名单',
+ title: '访问模式',
align:"center",
- dataIndex: 'blackList'
+ dataIndex: 'listMode',
+ customRender: ({ text }) => {
+ return text === 'WHITELIST' ? '白名单' : '黑名单';
+ }
+ },
+ {
+ title: '清单项数量',
+ align:"center",
+ dataIndex: 'allowedList',
+ customRender: ({ text }) => {
+ if (!text) return 0;
+ return text.split(/[,\n]/).filter(item => item.trim()).length;
+ }
+ },
+ {
+ title: '最后修改人',
+ align:"center",
+ dataIndex: 'updateBy'
+ },
+ {
+ title: '最后修改时间',
+ align:"center",
+ dataIndex: 'updateTime'
},
- // {
- // title: '状态',
- // align:"center",
- // dataIndex: 'status'
- // },
{
title: '创建人',
align:"center",
@@ -49,6 +66,17 @@ export const searchFormSchema: FormSchema[] = [
field: "name",
component: 'JInput',
},
+ {
+ label: "访问模式",
+ field: "listMode",
+ component: 'Select',
+ componentProps: {
+ options: [
+ { label: '白名单', value: 'WHITELIST' },
+ { label: '黑名单', value: 'BLACKLIST' },
+ ],
+ },
+ },
{
label: "创建人",
field: "createBy",
@@ -123,9 +151,110 @@ export const formSchema: FormSchema[] = [
dynamicDisabled:true
},
{
- label: 'IP 黑名单',
- field: 'blackList',
- component: 'Input',
+ label: '访问模式',
+ field: 'listMode',
+ component: 'RadioGroup',
+ defaultValue: 'WHITELIST',
+ componentProps: {
+ options: [
+ { label: '白名单', value: 'WHITELIST' },
+ { label: '黑名单', value: 'BLACKLIST' },
+ ],
+ },
+ },
+ {
+ label: '访问清单',
+ field: 'allowedList',
+ component: 'InputTextArea',
+ slot: 'allowedListSlot',
+ componentProps: {
+ rows: 6,
+ placeholder: '支持IP、CIDR、域名;支持10.2.3.*与10.2.3.[1-234],每行一个或逗号分隔',
+ },
+ dynamicRules: ({ model, schema }) => {
+ return [
+ {
+ validator: (rule, value) => {
+ if (!value) return Promise.resolve();
+ const items = value.split(/[,\n]/).filter((item) => item.trim());
+ const ipv4Seg = '(?:25[0-5]|2[0-4][0-9]|[01]?\\d\\d?)';
+ const ipRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}$`);
+ const cidrRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}\\/(?:[0-9]|[1-2][0-9]|3[0-2])$`);
+ const domainRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$/;
+ // 10.2.3.* 支持最后一段通配符
+ const wildcardLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\*$`);
+ // 10.2.3.[1-234] 支持最后一段范围
+ const rangeLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\[(\\d{1,3})-(\\d{1,3})\\]$`);
+
+ for (const raw of items) {
+ const item = raw.trim();
+ // 基础合法:IP / CIDR / 域名
+ if (ipRegex.test(item) || cidrRegex.test(item) || domainRegex.test(item)) {
+ continue;
+ }
+ // 最后一段通配符:10.2.3.*
+ if (wildcardLastOctetRegex.test(item)) {
+ continue;
+ }
+ // 最后一段范围:10.2.3.[1-234]
+ const m = item.match(rangeLastOctetRegex);
+ if (m) {
+ const start = Number(m[1]);
+ const end = Number(m[2]);
+ if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start && end <= 255) {
+ continue;
+ }
+ }
+ return Promise.reject(new Error(`"${item}" 不是有效的IP/CIDR/域名,或不支持的模式(仅支持10.2.3.*与10.2.3.[start-end])`));
+ }
+ return Promise.resolve();
+ },
+ message: '请输入有效的IP、CIDR、域名或通配/范围模式',
+ },
+ ];
+ },
+ },
+ {
+ label: '备注',
+ field: 'comment',
+ component: 'InputTextArea',
+ componentProps: {
+ rows: 3,
+ },
+ },
+ {
+ label: '高级设置',
+ field: 'advancedSettings',
+ component: 'Divider',
+ },
+ {
+ label: '严格模式',
+ field: 'enableStrict',
+ component: 'Switch',
+ defaultValue: false,
+ },
+ {
+ label: 'DNS缓存TTL(秒)',
+ field: 'dnsCacheTtlSeconds',
+ component: 'InputNumber',
+ defaultValue: 300,
+ componentProps: {
+ min: 0,
+ max: 86400,
+ },
+ },
+ {
+ label: 'IP版本',
+ field: 'ipVersion',
+ component: 'Select',
+ defaultValue: 'Dual',
+ componentProps: {
+ options: [
+ { label: 'IPv4', value: 'IPv4' },
+ { label: 'IPv6', value: 'IPv6' },
+ { label: '双栈', value: 'Dual' },
+ ],
+ },
},
{
label: '请求体内容',
diff --git a/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue b/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue
index e59fcab853b..25a5919d330 100644
--- a/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue
+++ b/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue
@@ -2,7 +2,27 @@
-
+
+
+
+
+
+
@@ -118,6 +138,68 @@
refKeys
);
+ // 校验规则复用:IPv4段、IP、CIDR、域名、通配符、范围
+ const ipv4Seg = '(?:25[0-5]|2[0-4][0-9]|[01]?\\d\\d?)';
+ const ipRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}$`);
+ const cidrRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}\\/(?:[0-9]|[1-2][0-9]|3[0-2])$`);
+ const domainRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$/;
+ const wildcardLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\*$`);
+ const rangeLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\[(\\d{1,3})-(\\d{1,3})\\]$`);
+
+ function isValidAllowedItem(raw: string): boolean {
+ const item = (raw || '').trim();
+ if (!item) return false;
+ if (ipRegex.test(item) || cidrRegex.test(item) || domainRegex.test(item)) return true;
+ if (wildcardLastOctetRegex.test(item)) return true;
+ const m = item.match(rangeLastOctetRegex);
+ if (m) {
+ const start = Number(m[1]);
+ const end = Number(m[2]);
+ if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start && end <= 255) return true;
+ }
+ return false;
+ }
+
+ function splitAllowedList(val?: string): string[] {
+ if (!val) return [];
+ return val.split(/[,\n]/).map(s => s.trim()).filter(Boolean);
+ }
+
+ function organizeAllowedList(model: Record, field: string) {
+ const items = splitAllowedList(model[field]);
+
+ // 去重(域名大小写不敏感;其他保持原样)
+ const seen = new Set();
+ const normalizedPair: [string, string][] = items.map((x) => {
+ const isDomain = domainRegex.test(x);
+ return [isDomain ? x.toLowerCase() : x, x];
+ });
+ const deduped: string[] = [];
+ for (const [key, original] of normalizedPair) {
+ if (!seen.has(key)) {
+ seen.add(key);
+ deduped.push(original);
+ }
+ }
+
+ // 类型排序:IP < CIDR < 通配符 < 范围 < 域名,然后字典序
+ function typeRank(s: string): number {
+ if (ipRegex.test(s)) return 1;
+ if (cidrRegex.test(s)) return 2;
+ if (wildcardLastOctetRegex.test(s)) return 3;
+ if (rangeLastOctetRegex.test(s)) return 4;
+ if (domainRegex.test(s)) return 5;
+ return 9;
+ }
+ deduped.sort((a, b) => {
+ const ra = typeRank(a), rb = typeRank(b);
+ if (ra !== rb) return ra - rb;
+ return a.localeCompare(b);
+ });
+
+ model[field] = deduped.join('\n');
+ }
+
//设置标题
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(formDisabled) ? '编辑' : '详情'));
@@ -138,7 +220,7 @@
//表单提交事件
async function requestAddOrEdit(values) {
let headersJson = !!values.headersJson?JSON.stringify(values.headersJson):null;
- let paramsJson = !!values.headersJson?JSON.stringify(values.paramsJson):null;
+ let paramsJson = !!values.paramsJson?JSON.stringify(values.paramsJson):null;
try {
if (!!values.body){
try {
@@ -151,6 +233,13 @@
return;
}
}
+ // 处理访问清单,将逗号分隔转换为换行分隔
+ if (values.allowedList) {
+ values.allowedList = values.allowedList
+ .split(/[,\s]+/)
+ .filter(item => item.trim())
+ .join('\n');
+ }
setModalProps({ confirmLoading: true });
values.headersJson = headersJson
values.paramsJson = paramsJson
@@ -175,4 +264,19 @@
:deep(.ant-calendar-picker) {
width: 100%;
}
+
+ .allowed-tags {
+ margin-top: 8px;
+ min-height: 28px;
+ }
+ .allowed-tag {
+ background-color: #f6ffed; /* 绿色浅底 */
+ color: #389e0d;
+ border: 1px solid #b7eb8f;
+ border-radius: 14px;
+ padding: 2px 10px;
+ margin: 2px 6px 6px 0;
+ font-size: 12px;
+ line-height: 20px;
+ }