diff --git a/README.md b/README.md index efdb44c7fc..c0ecdf6f49 100644 --- a/README.md +++ b/README.md @@ -308,26 +308,26 @@ | 框架 | 说明 | 版本 | 学习指南 | |---------------------------------------------------------------------------------------------|------------------|----------------|----------------------------------------------------------------| -| [Spring Boot](https://spring.io/projects/spring-boot) | 应用开发框架 | 3.4.5 | [文档](https://github.com/YunaiV/SpringBoot-Labs) | +| [Spring Boot](https://spring.io/projects/spring-boot) | 应用开发框架 | 3.5.5 | [文档](https://github.com/YunaiV/SpringBoot-Labs) | | [MySQL](https://www.mysql.com/cn/) | 数据库服务器 | 5.7 / 8.0+ | | -| [Druid](https://github.com/alibaba/druid) | JDBC 连接池、监控组件 | 1.2.23 | [文档](http://www.iocoder.cn/Spring-Boot/datasource-pool/?yudao) | -| [MyBatis Plus](https://mp.baomidou.com/) | MyBatis 增强工具包 | 3.5.7 | [文档](http://www.iocoder.cn/Spring-Boot/MyBatis/?yudao) | +| [Druid](https://github.com/alibaba/druid) | JDBC 连接池、监控组件 | 1.2.27 | [文档](http://www.iocoder.cn/Spring-Boot/datasource-pool/?yudao) | +| [MyBatis Plus](https://mp.baomidou.com/) | MyBatis 增强工具包 | 3.5.12 | [文档](http://www.iocoder.cn/Spring-Boot/MyBatis/?yudao) | | [Dynamic Datasource](https://dynamic-datasource.com/) | 动态数据源 | 4.3.1 | [文档](http://www.iocoder.cn/Spring-Boot/datasource-pool/?yudao) | | [Redis](https://redis.io/) | key-value 数据库 | 5.0 / 6.0 /7.0 | | -| [Redisson](https://github.com/redisson/redisson) | Redis 客户端 | 3.32.0 | [文档](http://www.iocoder.cn/Spring-Boot/Redis/?yudao) | -| [Spring MVC](https://github.com/spring-projects/spring-framework/tree/master/spring-webmvc) | MVC 框架 | 6.1.10 | [文档](http://www.iocoder.cn/SpringMVC/MVC/?yudao) | -| [Spring Security](https://github.com/spring-projects/spring-security) | Spring 安全框架 | 6.3.1 | [文档](http://www.iocoder.cn/Spring-Boot/Spring-Security/?yudao) | -| [Hibernate Validator](https://github.com/hibernate/hibernate-validator) | 参数校验组件 | 8.0.1 | [文档](http://www.iocoder.cn/Spring-Boot/Validation/?yudao) | +| [Redisson](https://github.com/redisson/redisson) | Redis 客户端 | 3.35.0 | [文档](http://www.iocoder.cn/Spring-Boot/Redis/?yudao) | +| [Spring MVC](https://github.com/spring-projects/spring-framework/tree/master/spring-webmvc) | MVC 框架 | 6.2.9 | [文档](http://www.iocoder.cn/SpringMVC/MVC/?yudao) | +| [Spring Security](https://github.com/spring-projects/spring-security) | Spring 安全框架 | 6.5.2 | [文档](http://www.iocoder.cn/Spring-Boot/Spring-Security/?yudao) | +| [Hibernate Validator](https://github.com/hibernate/hibernate-validator) | 参数校验组件 | 8.0.2 | [文档](http://www.iocoder.cn/Spring-Boot/Validation/?yudao) | | [Flowable](https://github.com/flowable/flowable-engine) | 工作流引擎 | 7.0.0 | [文档](https://doc.iocoder.cn/bpm/) | -| [Quartz](https://github.com/quartz-scheduler) | 任务调度组件 | 2.3.2 | [文档](http://www.iocoder.cn/Spring-Boot/Job/?yudao) | -| [Springdoc](https://springdoc.org/) | Swagger 文档 | 2.3.0 | [文档](http://www.iocoder.cn/Spring-Boot/Swagger/?yudao) | -| [SkyWalking](https://skywalking.apache.org/) | 分布式应用追踪系统 | 9.0.0 | [文档](http://www.iocoder.cn/Spring-Boot/SkyWalking/?yudao) | -| [Spring Boot Admin](https://github.com/codecentric/spring-boot-admin) | Spring Boot 监控平台 | 3.3.2 | [文档](http://www.iocoder.cn/Spring-Boot/Admin/?yudao) | -| [Jackson](https://github.com/FasterXML/jackson) | JSON 工具库 | 2.17.1 | | +| [Quartz](https://github.com/quartz-scheduler) | 任务调度组件 | 2.5.0 | [文档](http://www.iocoder.cn/Spring-Boot/Job/?yudao) | +| [Springdoc](https://springdoc.org/) | Swagger 文档 | 2.8.9 | [文档](http://www.iocoder.cn/Spring-Boot/Swagger/?yudao) | +| [SkyWalking](https://skywalking.apache.org/) | 分布式应用追踪系统 | 9.5.0 | [文档](http://www.iocoder.cn/Spring-Boot/SkyWalking/?yudao) | +| [Spring Boot Admin](https://github.com/codecentric/spring-boot-admin) | Spring Boot 监控平台 | 3.5.2 | [文档](http://www.iocoder.cn/Spring-Boot/Admin/?yudao) | +| [Jackson](https://github.com/FasterXML/jackson) | JSON 工具库 | 2.30.14 | | | [MapStruct](https://mapstruct.org/) | Java Bean 转换 | 1.6.3 | [文档](http://www.iocoder.cn/Spring-Boot/MapStruct/?yudao) | -| [Lombok](https://projectlombok.org/) | 消除冗长的 Java 代码 | 1.18.34 | [文档](http://www.iocoder.cn/Spring-Boot/Lombok/?yudao) | -| [JUnit](https://junit.org/junit5/) | Java 单元测试框架 | 5.10.1 | - | -| [Mockito](https://github.com/mockito/mockito) | Java Mock 框架 | 5.7.0 | - | +| [Lombok](https://projectlombok.org/) | 消除冗长的 Java 代码 | 1.18.38 | [文档](http://www.iocoder.cn/Spring-Boot/Lombok/?yudao) | +| [JUnit](https://junit.org/junit5/) | Java 单元测试框架 | 5.12.2 | - | +| [Mockito](https://github.com/mockito/mockito) | Java Mock 框架 | 5.17.0 | - | ## 🐷 演示图 diff --git a/pom.xml b/pom.xml index e4770100e7..e1317e1240 100644 --- a/pom.xml +++ b/pom.xml @@ -37,12 +37,12 @@ 17 ${java.version} ${java.version} - 3.2.2 + 3.5.3 3.14.0 - 1.6.0 + 1.7.2 1.18.38 - 3.4.5 + 3.5.5 1.6.3 UTF-8 diff --git a/sql/dm/ruoyi-vue-pro-dm8.sql b/sql/dm/ruoyi-vue-pro-dm8.sql index 1f7a24a12f..3e0dcda2aa 100644 --- a/sql/dm/ruoyi-vue-pro-dm8.sql +++ b/sql/dm/ruoyi-vue-pro-dm8.sql @@ -4303,7 +4303,7 @@ CREATE TABLE system_tenant contact_name varchar(30) NOT NULL, contact_mobile varchar(500) DEFAULT NULL NULL, status smallint DEFAULT 0 NOT NULL, - website varchar(256) DEFAULT '' NULL, + websites varchar(256) DEFAULT '' NULL, package_id bigint NOT NULL, expire_time datetime NOT NULL, account_count int NOT NULL, @@ -4320,7 +4320,7 @@ COMMENT ON COLUMN system_tenant.contact_user_id IS '联系人的用户编号'; COMMENT ON COLUMN system_tenant.contact_name IS '联系人'; COMMENT ON COLUMN system_tenant.contact_mobile IS '联系手机'; COMMENT ON COLUMN system_tenant.status IS '租户状态(0正常 1停用)'; -COMMENT ON COLUMN system_tenant.website IS '绑定域名'; +COMMENT ON COLUMN system_tenant.websites IS '绑定域名数组'; COMMENT ON COLUMN system_tenant.package_id IS '租户套餐编号'; COMMENT ON COLUMN system_tenant.expire_time IS '过期时间'; COMMENT ON COLUMN system_tenant.account_count IS '账号数量'; @@ -4336,9 +4336,9 @@ COMMENT ON TABLE system_tenant IS '租户表'; -- ---------------------------- -- @formatter:off SET IDENTITY_INSERT system_tenant ON; -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); COMMIT; SET IDENTITY_INSERT system_tenant OFF; -- @formatter:on diff --git a/sql/kingbase/ruoyi-vue-pro.sql b/sql/kingbase/ruoyi-vue-pro.sql index 2d28ba75ab..33c41c1c54 100644 --- a/sql/kingbase/ruoyi-vue-pro.sql +++ b/sql/kingbase/ruoyi-vue-pro.sql @@ -4608,7 +4608,7 @@ CREATE TABLE system_tenant contact_name varchar(30) NOT NULL, contact_mobile varchar(500) NULL DEFAULT NULL, status int2 NOT NULL DEFAULT 0, - website varchar(256) NULL DEFAULT '', + websites varchar(256) NULL DEFAULT '', package_id int8 NOT NULL, expire_time timestamp NOT NULL, account_count int4 NOT NULL, @@ -4628,7 +4628,7 @@ COMMENT ON COLUMN system_tenant.contact_user_id IS '联系人的用户编号'; COMMENT ON COLUMN system_tenant.contact_name IS '联系人'; COMMENT ON COLUMN system_tenant.contact_mobile IS '联系手机'; COMMENT ON COLUMN system_tenant.status IS '租户状态(0正常 1停用)'; -COMMENT ON COLUMN system_tenant.website IS '绑定域名'; +COMMENT ON COLUMN system_tenant.websites IS '绑定域名数组'; COMMENT ON COLUMN system_tenant.package_id IS '租户套餐编号'; COMMENT ON COLUMN system_tenant.expire_time IS '过期时间'; COMMENT ON COLUMN system_tenant.account_count IS '账号数量'; @@ -4644,9 +4644,9 @@ COMMENT ON TABLE system_tenant IS '租户表'; -- ---------------------------- -- @formatter:off BEGIN; -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); COMMIT; -- @formatter:on diff --git a/sql/mysql/ruoyi-vue-pro.sql b/sql/mysql/ruoyi-vue-pro.sql index cdbaa0d064..43078079db 100644 --- a/sql/mysql/ruoyi-vue-pro.sql +++ b/sql/mysql/ruoyi-vue-pro.sql @@ -1259,14 +1259,16 @@ CREATE TABLE `system_mail_log` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', `user_id` bigint NULL DEFAULT NULL COMMENT '用户编号', `user_type` tinyint NULL DEFAULT NULL COMMENT '用户类型', - `to_mail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收邮箱地址', + `to_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收邮箱地址', + `cc_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '抄送邮箱地址', + `bcc_mails` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密送邮箱地址', `account_id` bigint NOT NULL COMMENT '邮箱账号编号', `from_mail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送邮箱地址', `template_id` bigint NOT NULL COMMENT '模板编号', `template_code` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板编码', `template_nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '模版发送人名称', `template_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件标题', - `template_content` varchar(10240) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件内容', + `template_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件内容', `template_params` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮件参数', `send_status` tinyint NOT NULL DEFAULT 0 COMMENT '发送状态', `send_time` datetime NULL DEFAULT NULL COMMENT '发送时间', @@ -3743,7 +3745,7 @@ CREATE TABLE `system_tenant` ( `contact_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人', `contact_mobile` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '联系手机', `status` tinyint NOT NULL DEFAULT 0 COMMENT '租户状态(0正常 1停用)', - `website` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '绑定域名', + `websites` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '绑定域名数组', `package_id` bigint NOT NULL COMMENT '租户套餐编号', `expire_time` datetime NOT NULL COMMENT '过期时间', `account_count` int NOT NULL COMMENT '账号数量', @@ -3759,9 +3761,9 @@ CREATE TABLE `system_tenant` ( -- Records of system_tenant -- ---------------------------- BEGIN; -INSERT INTO `system_tenant` (`id`, `name`, `contact_user_id`, `contact_name`, `contact_mobile`, `status`, `website`, `package_id`, `expire_time`, `account_count`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', b'0'); -INSERT INTO `system_tenant` (`id`, `name`, `contact_user_id`, `contact_name`, `contact_mobile`, `status`, `website`, `package_id`, `expire_time`, `account_count`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', b'0'); -INSERT INTO `system_tenant` (`id`, `name`, `contact_user_id`, `contact_name`, `contact_mobile`, `status`, `website`, `package_id`, `expire_time`, `account_count`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', b'0'); +INSERT INTO `system_tenant` (`id`, `name`, `contact_user_id`, `contact_name`, `contact_mobile`, `status`, `websites`, `package_id`, `expire_time`, `account_count`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', b'0'); +INSERT INTO `system_tenant` (`id`, `name`, `contact_user_id`, `contact_name`, `contact_mobile`, `status`, `websites`, `package_id`, `expire_time`, `account_count`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', b'0'); +INSERT INTO `system_tenant` (`id`, `name`, `contact_user_id`, `contact_name`, `contact_mobile`, `status`, `websites`, `package_id`, `expire_time`, `account_count`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', b'0'); COMMIT; -- ---------------------------- diff --git a/sql/opengauss/ruoyi-vue-pro.sql b/sql/opengauss/ruoyi-vue-pro.sql index ffc587f79b..33849b5c68 100644 --- a/sql/opengauss/ruoyi-vue-pro.sql +++ b/sql/opengauss/ruoyi-vue-pro.sql @@ -4608,7 +4608,7 @@ CREATE TABLE system_tenant contact_name varchar(30) NOT NULL, contact_mobile varchar(500) NULL DEFAULT NULL, status int2 NOT NULL DEFAULT 0, - website varchar(256) NULL DEFAULT '', + websites varchar(256) NULL DEFAULT '', package_id int8 NOT NULL, expire_time timestamp NOT NULL, account_count int4 NOT NULL, @@ -4628,7 +4628,7 @@ COMMENT ON COLUMN system_tenant.contact_user_id IS '联系人的用户编号'; COMMENT ON COLUMN system_tenant.contact_name IS '联系人'; COMMENT ON COLUMN system_tenant.contact_mobile IS '联系手机'; COMMENT ON COLUMN system_tenant.status IS '租户状态(0正常 1停用)'; -COMMENT ON COLUMN system_tenant.website IS '绑定域名'; +COMMENT ON COLUMN system_tenant.websites IS '绑定域名数组'; COMMENT ON COLUMN system_tenant.package_id IS '租户套餐编号'; COMMENT ON COLUMN system_tenant.expire_time IS '过期时间'; COMMENT ON COLUMN system_tenant.account_count IS '账号数量'; @@ -4644,9 +4644,9 @@ COMMENT ON TABLE system_tenant IS '租户表'; -- ---------------------------- -- @formatter:off BEGIN; -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); COMMIT; -- @formatter:on diff --git a/sql/oracle/ruoyi-vue-pro.sql b/sql/oracle/ruoyi-vue-pro.sql index 75fde5cae4..4c844b1460 100644 --- a/sql/oracle/ruoyi-vue-pro.sql +++ b/sql/oracle/ruoyi-vue-pro.sql @@ -4495,7 +4495,7 @@ CREATE TABLE system_tenant contact_name varchar2(30) NULL, contact_mobile varchar2(500) DEFAULT NULL NULL, status smallint DEFAULT 0 NOT NULL, - website varchar2(256) DEFAULT '' NULL, + websites varchar2(256) DEFAULT '' NULL, package_id number NOT NULL, expire_time date NOT NULL, account_count number NOT NULL, @@ -4515,7 +4515,7 @@ COMMENT ON COLUMN system_tenant.contact_user_id IS '联系人的用户编号'; COMMENT ON COLUMN system_tenant.contact_name IS '联系人'; COMMENT ON COLUMN system_tenant.contact_mobile IS '联系手机'; COMMENT ON COLUMN system_tenant.status IS '租户状态(0正常 1停用)'; -COMMENT ON COLUMN system_tenant.website IS '绑定域名'; +COMMENT ON COLUMN system_tenant.websites IS '绑定域名数组'; COMMENT ON COLUMN system_tenant.package_id IS '租户套餐编号'; COMMENT ON COLUMN system_tenant.expire_time IS '过期时间'; COMMENT ON COLUMN system_tenant.account_count IS '账号数量'; @@ -4530,9 +4530,9 @@ COMMENT ON TABLE system_tenant IS '租户表'; -- Records of system_tenant -- ---------------------------- -- @formatter:off -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, to_date('2099-02-19 17:14:16', 'SYYYY-MM-DD HH24:MI:SS'), 9999, '1', to_date('2021-01-05 17:03:47', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2023-11-06 11:41:41', 'SYYYY-MM-DD HH24:MI:SS'), '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, to_date('2026-07-10 00:00:00', 'SYYYY-MM-DD HH24:MI:SS'), 30, '1', to_date('2022-02-22 00:56:14', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2025-04-03 21:33:01', 'SYYYY-MM-DD HH24:MI:SS'), '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, to_date('2022-04-29 00:00:00', 'SYYYY-MM-DD HH24:MI:SS'), 50, '1', to_date('2022-03-07 21:37:58', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2024-09-22 12:10:50', 'SYYYY-MM-DD HH24:MI:SS'), '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, to_date('2099-02-19 17:14:16', 'SYYYY-MM-DD HH24:MI:SS'), 9999, '1', to_date('2021-01-05 17:03:47', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2023-11-06 11:41:41', 'SYYYY-MM-DD HH24:MI:SS'), '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, to_date('2026-07-10 00:00:00', 'SYYYY-MM-DD HH24:MI:SS'), 30, '1', to_date('2022-02-22 00:56:14', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2025-04-03 21:33:01', 'SYYYY-MM-DD HH24:MI:SS'), '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, to_date('2022-04-29 00:00:00', 'SYYYY-MM-DD HH24:MI:SS'), 50, '1', to_date('2022-03-07 21:37:58', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2024-09-22 12:10:50', 'SYYYY-MM-DD HH24:MI:SS'), '0'); COMMIT; -- @formatter:on diff --git a/sql/postgresql/ruoyi-vue-pro.sql b/sql/postgresql/ruoyi-vue-pro.sql index 6b7e4740cf..f85884776e 100644 --- a/sql/postgresql/ruoyi-vue-pro.sql +++ b/sql/postgresql/ruoyi-vue-pro.sql @@ -4608,7 +4608,7 @@ CREATE TABLE system_tenant contact_name varchar(30) NOT NULL, contact_mobile varchar(500) NULL DEFAULT NULL, status int2 NOT NULL DEFAULT 0, - website varchar(256) NULL DEFAULT '', + websites varchar(256) NULL DEFAULT '', package_id int8 NOT NULL, expire_time timestamp NOT NULL, account_count int4 NOT NULL, @@ -4628,7 +4628,7 @@ COMMENT ON COLUMN system_tenant.contact_user_id IS '联系人的用户编号'; COMMENT ON COLUMN system_tenant.contact_name IS '联系人'; COMMENT ON COLUMN system_tenant.contact_mobile IS '联系手机'; COMMENT ON COLUMN system_tenant.status IS '租户状态(0正常 1停用)'; -COMMENT ON COLUMN system_tenant.website IS '绑定域名'; +COMMENT ON COLUMN system_tenant.websites IS '绑定域名数组'; COMMENT ON COLUMN system_tenant.package_id IS '租户套餐编号'; COMMENT ON COLUMN system_tenant.expire_time IS '过期时间'; COMMENT ON COLUMN system_tenant.account_count IS '账号数量'; @@ -4644,9 +4644,9 @@ COMMENT ON TABLE system_tenant IS '租户表'; -- ---------------------------- -- @formatter:off BEGIN; -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, '芋道源码', NULL, '芋艿', '17321315478', 0, 'www.iocoder.cn', 0, '2099-02-19 17:14:16', 9999, '1', '2021-01-05 17:03:47', '1', '2023-11-06 11:41:41', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, '小租户', 110, '小王2', '15601691300', 0, 'zsxq.iocoder.cn', 111, '2026-07-10 00:00:00', 30, '1', '2022-02-22 00:56:14', '1', '2025-04-03 21:33:01', '0'); +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, '测试租户', 113, '芋道', '15601691300', 0, 'test.iocoder.cn', 111, '2022-04-29 00:00:00', 50, '1', '2022-03-07 21:37:58', '1', '2024-09-22 12:10:50', '0'); COMMIT; -- @formatter:on diff --git a/sql/sqlserver/ruoyi-vue-pro.sql b/sql/sqlserver/ruoyi-vue-pro.sql index e753d0df34..abf63c4e34 100644 --- a/sql/sqlserver/ruoyi-vue-pro.sql +++ b/sql/sqlserver/ruoyi-vue-pro.sql @@ -10834,7 +10834,7 @@ CREATE TABLE system_tenant contact_name nvarchar(30) NOT NULL, contact_mobile nvarchar(500) DEFAULT NULL NULL, status tinyint DEFAULT 0 NOT NULL, - website nvarchar(256) DEFAULT '' NULL, + websites nvarchar(256) DEFAULT '' NULL, package_id bigint NOT NULL, expire_time datetime2 NOT NULL, account_count int NOT NULL, @@ -10965,11 +10965,11 @@ BEGIN TRANSACTION GO SET IDENTITY_INSERT system_tenant ON GO -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, N'芋道源码', NULL, N'芋艿', N'17321315478', 0, N'www.iocoder.cn', 0, N'2099-02-19 17:14:16', 9999, N'1', N'2021-01-05 17:03:47', N'1', N'2023-11-06 11:41:41', N'0') +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (1, N'芋道源码', NULL, N'芋艿', N'17321315478', 0, N'www.iocoder.cn', 0, N'2099-02-19 17:14:16', 9999, N'1', N'2021-01-05 17:03:47', N'1', N'2023-11-06 11:41:41', N'0') GO -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, N'小租户', 110, N'小王2', N'15601691300', 0, N'zsxq.iocoder.cn', 111, N'2026-07-10 00:00:00', 30, N'1', N'2022-02-22 00:56:14', N'1', N'2025-04-03 21:33:01', N'0') +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (121, N'小租户', 110, N'小王2', N'15601691300', 0, N'zsxq.iocoder.cn', 111, N'2026-07-10 00:00:00', 30, N'1', N'2022-02-22 00:56:14', N'1', N'2025-04-03 21:33:01', N'0') GO -INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, website, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, N'测试租户', 113, N'芋道', N'15601691300', 0, N'test.iocoder.cn', 111, N'2022-04-29 00:00:00', 50, N'1', N'2022-03-07 21:37:58', N'1', N'2024-09-22 12:10:50', N'0') +INSERT INTO system_tenant (id, name, contact_user_id, contact_name, contact_mobile, status, websites, package_id, expire_time, account_count, creator, create_time, updater, update_time, deleted) VALUES (122, N'测试租户', 113, N'芋道', N'15601691300', 0, N'test.iocoder.cn', 111, N'2022-04-29 00:00:00', 50, N'1', N'2022-03-07 21:37:58', N'1', N'2024-09-22 12:10:50', N'0') GO SET IDENTITY_INSERT system_tenant OFF GO diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index e069afa486..fb23763fb8 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -15,56 +15,57 @@ 2025.08-SNAPSHOT - 1.6.0 + 1.7.2 - 3.4.5 + 3.5.5 - 2.8.3 - 4.6.0 + 2.8.11 + 4.5.0 - 1.2.24 + 1.2.27 3.5.19 3.5.12 1.5.4 4.3.1 3.0.6 - 3.41.0 + 3.51.0 8.1.3.140 8.6.0 5.1.0 - 3.3.3 + 3.7.3 - 2.3.2 + 2.3.4 2.2.7 - 9.0.0 - 3.4.5 + 9.5.0 + 3.5.2 0.33.0 8.0.2.RELEASE - 1.1.8 + 1.1.11 5.2.0 7.0.1 1.4.0 - 1.18.3 + 1.21.2 1.18.38 1.6.3 - 5.8.35 - 6.0.0-M19 - 1.2.0 + 5.8.40 + 6.0.0-M22 + 1.3.0 2.4.1 1.2.83 33.4.8-jre 2.14.5 3.11.1 + 3.18.0 0.1.55 - 3.1.0 + 3.2.2 2.7.0 3.0.6 - 4.1.118.Final + 4.2.4.Final 1.2.5 0.9.0 4.5.13 @@ -72,9 +73,9 @@ 2.30.14 1.16.7 1.4.0 - 2.0.0 - 1.9.5 - 4.7.5.B + 2.1.1 + 2.1.0 + 4.7.7-20250808.182223 @@ -151,13 +152,19 @@ - com.github.xingfudeshi + com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter ${knife4j.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + org.springdoc - springdoc-openapi-starter-webmvc-api + springdoc-openapi-starter-webmvc-ui ${springdoc.version} @@ -510,13 +517,18 @@ commons-net ${commons-net.version} - com.jcraft jsch ${jsch.version} + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + com.anji-plus captcha-spring-boot-starter @@ -591,6 +603,10 @@ com.github.jsqlparser jsqlparser + + cn.hutool + hutool-core + diff --git a/yudao-framework/yudao-common/pom.xml b/yudao-framework/yudao-common/pom.xml index b28da08fe2..4ede9eff2d 100644 --- a/yudao-framework/yudao-common/pom.xml +++ b/yudao-framework/yudao-common/pom.xml @@ -60,7 +60,7 @@ org.springdoc - springdoc-openapi-starter-webmvc-api + springdoc-openapi-starter-webmvc-ui provided diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java index c7b49f64fd..cccfe52cce 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/oauth2/dto/OAuth2AccessTokenRespDTO.java @@ -1,7 +1,6 @@ package cn.iocoder.yudao.framework.common.biz.system.oauth2.dto; import lombok.Data; -import lombok.experimental.Accessors; import java.io.Serializable; import java.time.LocalDateTime; @@ -12,7 +11,6 @@ import java.time.LocalDateTime; * @author 芋道源码 */ @Data -@Accessors(chain = true) public class OAuth2AccessTokenRespDTO implements Serializable { /** diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/DateIntervalEnum.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/DateIntervalEnum.java index 8d6a791784..d266eadc6a 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/DateIntervalEnum.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/DateIntervalEnum.java @@ -16,6 +16,7 @@ import java.util.Arrays; @AllArgsConstructor public enum DateIntervalEnum implements ArrayValuable { + HOUR(0, "小时"), // 特殊:字典里,暂时不会有这个枚举!!!因为大多数情况下,用不到这个间隔 DAY(1, "天"), WEEK(2, "周"), MONTH(3, "月"), diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/WebFilterOrderEnum.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/WebFilterOrderEnum.java index 11a5ee0782..497a213a2f 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/WebFilterOrderEnum.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/enums/WebFilterOrderEnum.java @@ -15,6 +15,8 @@ public interface WebFilterOrderEnum { int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500; + int API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1; + // OrderedRequestContextFilter 默认为 -105,用于国际化上下文等等 int TENANT_CONTEXT_FILTER = - 104; // 需要保证在 ApiAccessLogFilter 前面 diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/CommonResult.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/CommonResult.java index ac74103154..afb9cd3063 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/CommonResult.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/CommonResult.java @@ -25,16 +25,16 @@ public class CommonResult implements Serializable { * @see ErrorCode#getCode() */ private Integer code; - /** - * 返回数据 - */ - private T data; /** * 错误提示,用户可阅读 * * @see ErrorCode#getMsg() () */ private String msg; + /** + * 返回数据 + */ + private T data; /** * 将传入的 result 对象,转换成另外一个泛型结果的对象 diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/PageResult.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/PageResult.java index ff9087a81f..47c59d1d9e 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/PageResult.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/PageResult.java @@ -11,12 +11,12 @@ import java.util.List; @Data public final class PageResult implements Serializable { - @Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED) - private List list; - @Schema(description = "总量", requiredMode = Schema.RequiredMode.REQUIRED) private Long total; + @Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED) + private List list; + public PageResult() { } diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java index 26b3961685..4cbd4b6183 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java @@ -8,6 +8,7 @@ import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.enums.DateIntervalEnum; +import java.sql.Timestamp; import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -16,8 +17,7 @@ import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; -import static cn.hutool.core.date.DatePattern.UTC_MS_WITH_XXX_OFFSET_PATTERN; -import static cn.hutool.core.date.DatePattern.createFormatter; +import static cn.hutool.core.date.DatePattern.*; /** * 时间工具类,用于 {@link LocalDateTime} @@ -82,6 +82,21 @@ public class LocalDateTimeUtils { return new LocalDateTime[]{buildTime(year1, month1, day1), buildTime(year2, month2, day2)}; } + /** + * 判指定断时间,是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param time 指定时间 + * @return 是否 + */ + public static boolean isBetween(LocalDateTime startTime, LocalDateTime endTime, Timestamp time) { + if (startTime == null || endTime == null || time == null) { + return false; + } + return LocalDateTimeUtil.isIn(LocalDateTimeUtil.of(time), startTime, endTime); + } + /** * 判指定断时间,是否在该时间范围内 * @@ -234,6 +249,11 @@ public class LocalDateTimeUtils { // 2. 循环,生成时间范围 List timeRanges = new ArrayList<>(); switch (intervalEnum) { + case HOUR: + while (startTime.isBefore(endTime)) { + timeRanges.add(new LocalDateTime[]{startTime, startTime.plusHours(1).minusNanos(1)}); + startTime = startTime.plusHours(1); + } case DAY: while (startTime.isBefore(endTime)) { timeRanges.add(new LocalDateTime[]{startTime, startTime.plusDays(1).minusNanos(1)}); @@ -297,6 +317,8 @@ public class LocalDateTimeUtils { // 2. 循环,生成时间范围 switch (intervalEnum) { + case HOUR: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATETIME_MINUTE_PATTERN); case DAY: return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATE_PATTERN); case WEEK: diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java index e1e0ac08e7..85a644f1f1 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java @@ -47,8 +47,15 @@ public class HttpUtils { return builder.build(); } - private String append(String base, Map query, boolean fragment) { - return append(base, query, null, fragment); + public static String removeUrlQuery(String url) { + if (!StrUtil.contains(url, '?')) { + return url; + } + UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset()); + // 移除 query、fragment + builder.setQuery(null); + builder.setFragment(null); + return builder.build(); } /** diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java index 8bb8765917..e35cd9b437 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java @@ -3,18 +3,23 @@ package cn.iocoder.yudao.framework.common.util.json; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer; +import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeSerializer; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.Getter; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.lang.reflect.Type; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -26,13 +31,18 @@ import java.util.List; @Slf4j public class JsonUtils { + @Getter private static ObjectMapper objectMapper = new ObjectMapper(); static { objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 忽略 null 值 - objectMapper.registerModules(new JavaTimeModule()); // 解决 LocalDateTime 的序列化 + // 解决 LocalDateTime 的序列化 + SimpleModule simpleModule = new JavaTimeModule() + .addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE) + .addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + objectMapper.registerModules(simpleModule); } /** @@ -99,6 +109,18 @@ public class JsonUtils { } } + public static T parseObject(byte[] text, Type type) { + if (ArrayUtil.isEmpty(text)) { + return null; + } + try { + return objectMapper.readValue(text, objectMapper.getTypeFactory().constructType(type)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + /** * 将字符串解析成指定类型的对象 * 使用 {@link #parseObject(String, Class)} 时,在@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 的场景下, diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/ObjectUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/ObjectUtils.java index c08316dc2e..a26c7c12eb 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/ObjectUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/ObjectUtils.java @@ -60,4 +60,8 @@ public class ObjectUtils { return Arrays.asList(array).contains(obj); } + public static boolean isNotAllEmpty(Object... objs) { + return !ObjectUtil.isAllEmpty(objs); + } + } diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantAutoConfiguration.java index 0d9783ef9e..b79e09203d 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/config/YudaoTenantAutoConfiguration.java @@ -44,8 +44,7 @@ import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.util.pattern.PathPattern; -import java.util.Map; -import java.util.Objects; +import java.util.*; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; @@ -84,41 +83,13 @@ public class YudaoTenantAutoConfiguration { // ========== WEB ========== @Bean - public FilterRegistrationBean tenantContextWebFilter(TenantProperties tenantProperties) { + public FilterRegistrationBean tenantContextWebFilter() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new TenantContextWebFilter()); registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER); - addIgnoreUrls(tenantProperties); return registrationBean; } - /** - * 如果 Controller 接口上,有 {@link TenantIgnore} 注解,那么添加到忽略的 URL 中 - * - * @param tenantProperties 租户配置 - */ - private void addIgnoreUrls(TenantProperties tenantProperties) { - // 获得接口对应的 HandlerMethod 集合 - RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) - applicationContext.getBean("requestMappingHandlerMapping"); - Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); - // 获得有 @TenantIgnore 注解的接口 - for (Map.Entry entry : handlerMethodMap.entrySet()) { - HandlerMethod handlerMethod = entry.getValue(); - if (!handlerMethod.hasMethodAnnotation(TenantIgnore.class)) { - continue; - } - // 添加到忽略的 URL 中 - if (entry.getKey().getPatternsCondition() != null) { - tenantProperties.getIgnoreUrls().addAll(entry.getKey().getPatternsCondition().getPatterns()); - } - if (entry.getKey().getPathPatternsCondition() != null) { - tenantProperties.getIgnoreUrls().addAll( - convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); - } - } - } - @Bean public TenantVisitContextInterceptor tenantVisitContextInterceptor(TenantProperties tenantProperties, SecurityFrameworkService securityFrameworkService) { @@ -146,12 +117,42 @@ public class YudaoTenantAutoConfiguration { GlobalExceptionHandler globalExceptionHandler, TenantFrameworkService tenantFrameworkService) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new TenantSecurityWebFilter(tenantProperties, webProperties, + registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(), globalExceptionHandler, tenantFrameworkService)); registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER); return registrationBean; } + /** + * 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中 + * + * @return 忽略租户的 URL 集合 + */ + private Set getTenantIgnoreUrls() { + Set ignoreUrls = new HashSet<>(); + // 获得接口对应的 HandlerMethod 集合 + RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) + applicationContext.getBean("requestMappingHandlerMapping"); + Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); + // 获得有 @TenantIgnore 注解的接口 + for (Map.Entry entry : handlerMethodMap.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!handlerMethod.hasMethodAnnotation(TenantIgnore.class) // 方法级 + && !handlerMethod.getBeanType().isAnnotationPresent(TenantIgnore.class)) { // 接口级 + continue; + } + // 添加到忽略的 URL 中 + if (entry.getKey().getPatternsCondition() != null) { + ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns()); + } + if (entry.getKey().getPathPatternsCondition() != null) { + ignoreUrls.addAll( + convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); + } + } + return ignoreUrls; + } + // ========== MQ ========== @Bean diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java index 47e5df004c..11f0a4b4c3 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java @@ -75,7 +75,7 @@ public class TenantDatabaseInterceptor implements TenantLineHandler { if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) { return false; } - // 如果添加了 @TenantIgnore 注解,显然也不忽略租户 + // 如果添加了 @TenantIgnore 注解,则忽略租户 TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class); return tenantIgnore != null; } diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java index b856ce9542..a8079a6bfa 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.config.BeanPostProcessor; public class TenantRabbitMQInitializer implements BeanPostProcessor { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof RabbitTemplate) { RabbitTemplate rabbitTemplate = (RabbitTemplate) bean; @@ -20,4 +21,4 @@ public class TenantRabbitMQInitializer implements BeanPostProcessor { return bean; } -} \ No newline at end of file +} diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java index 7f12ac5205..3f6badc61b 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java @@ -17,6 +17,7 @@ import org.springframework.beans.factory.config.BeanPostProcessor; public class TenantRocketMQInitializer implements BeanPostProcessor { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof DefaultRocketMQListenerContainer) { DefaultRocketMQListenerContainer container = (DefaultRocketMQListenerContainer) bean; @@ -50,4 +51,4 @@ public class TenantRocketMQInitializer implements BeanPostProcessor { consumerImpl.registerConsumeMessageHook(new TenantRocketMQConsumeMessageHook()); } -} \ No newline at end of file +} diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java index c6d4d5e1b7..5858ec73a6 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java @@ -12,15 +12,16 @@ import cn.iocoder.yudao.framework.tenant.core.service.TenantFrameworkService; import cn.iocoder.yudao.framework.web.config.WebProperties; import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter; import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler; -import lombok.extern.slf4j.Slf4j; -import org.springframework.util.AntPathMatcher; - import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; + import java.io.IOException; import java.util.Objects; +import java.util.Set; /** * 多租户 Security Web 过滤器 @@ -35,17 +36,26 @@ public class TenantSecurityWebFilter extends ApiRequestFilter { private final TenantProperties tenantProperties; + /** + * 允许忽略租户的 URL 列表 + * + * 目的:解决 修改配置会导致 @TenantIgnore Controller 接口过滤失效 + */ + private final Set ignoreUrls; + private final AntPathMatcher pathMatcher; private final GlobalExceptionHandler globalExceptionHandler; private final TenantFrameworkService tenantFrameworkService; - public TenantSecurityWebFilter(TenantProperties tenantProperties, - WebProperties webProperties, + public TenantSecurityWebFilter(WebProperties webProperties, + TenantProperties tenantProperties, + Set ignoreUrls, GlobalExceptionHandler globalExceptionHandler, TenantFrameworkService tenantFrameworkService) { super(webProperties); this.tenantProperties = tenantProperties; + this.ignoreUrls = ignoreUrls; this.pathMatcher = new AntPathMatcher(); this.globalExceptionHandler = globalExceptionHandler; this.tenantFrameworkService = tenantFrameworkService; @@ -101,13 +111,20 @@ public class TenantSecurityWebFilter extends ApiRequestFilter { } private boolean isIgnoreUrl(HttpServletRequest request) { + String apiUri = request.getRequestURI().substring(request.getContextPath().length()); // 快速匹配,保证性能 - if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) { + if (CollUtil.contains(tenantProperties.getIgnoreUrls(), apiUri) + || CollUtil.contains(ignoreUrls, apiUri)) { return true; } // 逐个 Ant 路径匹配 for (String url : tenantProperties.getIgnoreUrls()) { - if (pathMatcher.match(url, request.getRequestURI())) { + if (pathMatcher.match(url, apiUri)) { + return true; + } + } + for (String url : ignoreUrls) { + if (pathMatcher.match(url, apiUri)) { return true; } } diff --git a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java index 4b08210971..ca088d35d7 100644 --- a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java @@ -21,6 +21,7 @@ public class YudaoAsyncAutoConfiguration { return new BeanPostProcessor() { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { // 处理 ThreadPoolTaskExecutor if (bean instanceof ThreadPoolTaskExecutor) { diff --git a/yudao-framework/yudao-spring-boot-starter-monitor/src/main/java/cn/iocoder/yudao/framework/tracer/config/YudaoTracerAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-monitor/src/main/java/cn/iocoder/yudao/framework/tracer/config/YudaoTracerAutoConfiguration.java index 7876742620..596b516757 100644 --- a/yudao-framework/yudao-spring-boot-starter-monitor/src/main/java/cn/iocoder/yudao/framework/tracer/config/YudaoTracerAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-monitor/src/main/java/cn/iocoder/yudao/framework/tracer/config/YudaoTracerAutoConfiguration.java @@ -1,11 +1,7 @@ package cn.iocoder.yudao.framework.tracer.config; import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum; -import cn.iocoder.yudao.framework.tracer.core.aop.BizTraceAspect; import cn.iocoder.yudao.framework.tracer.core.filter.TraceFilter; -import io.opentracing.Tracer; -import io.opentracing.util.GlobalTracer; -import org.apache.skywalking.apm.toolkit.opentracing.SkywalkingTracer; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -20,31 +16,28 @@ import org.springframework.context.annotation.Bean; */ @AutoConfiguration @ConditionalOnClass(name = { - "org.apache.skywalking.apm.toolkit.opentracing.SkywalkingTracer", - "io.opentracing.Tracer" + "org.apache.skywalking.apm.toolkit.opentracing.SkywalkingTracer", // 来自 apm-toolkit-opentracing.jar +// "io.opentracing.Tracer", // 来自 opentracing-api.jar + "jakarta.servlet.Filter" }) @EnableConfigurationProperties(TracerProperties.class) @ConditionalOnProperty(prefix = "yudao.tracer", value = "enable", matchIfMissing = true) public class YudaoTracerAutoConfiguration { - @Bean - public TracerProperties bizTracerProperties() { - return new TracerProperties(); - } - - @Bean - public BizTraceAspect bizTracingAop() { - return new BizTraceAspect(tracer()); - } - - @Bean - public Tracer tracer() { - // 创建 SkywalkingTracer 对象 - SkywalkingTracer tracer = new SkywalkingTracer(); - // 设置为 GlobalTracer 的追踪器 - GlobalTracer.registerIfAbsent(tracer); - return tracer; - } + // TODO @芋艿:skywalking 不兼容最新的 opentracing 版本。同时,opentracing 也停止了维护,尬住了!后续换 opentelemetry 即可! +// @Bean +// public BizTraceAspect bizTracingAop() { +// return new BizTraceAspect(tracer()); +// } +// +// @Bean +// public Tracer tracer() { +// // 创建 SkywalkingTracer 对象 +// SkywalkingTracer tracer = new SkywalkingTracer(); +// // 设置为 GlobalTracer 的追踪器 +// GlobalTracer.registerIfAbsent(tracer); +// return tracer; +// } /** * 创建 TraceFilter 过滤器,响应 header 设置 traceId diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/config/YudaoRedisMQConsumerAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/config/YudaoRedisMQConsumerAutoConfiguration.java index c9ab3e5415..b80244456d 100644 --- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/config/YudaoRedisMQConsumerAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/config/YudaoRedisMQConsumerAutoConfiguration.java @@ -69,9 +69,8 @@ public class YudaoRedisMQConsumerAutoConfiguration { @ConditionalOnBean(AbstractRedisStreamMessageListener.class) // 只有 AbstractStreamMessageListener 存在的时候,才需要注册 Redis pubsub 监听 public RedisPendingMessageResendJob redisPendingMessageResendJob(List> listeners, RedisMQTemplate redisTemplate, - @Value("${spring.application.name}") String groupName, RedissonClient redissonClient) { - return new RedisPendingMessageResendJob(listeners, redisTemplate, groupName, redissonClient); + return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient); } /** @@ -141,14 +140,14 @@ public class YudaoRedisMQConsumerAutoConfiguration { * * @return 消费者名字 */ - private static String buildConsumerName() { + public static String buildConsumerName() { return String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); } /** * 校验 Redis 版本号,是否满足最低的版本号要求! */ - private static void checkRedisVersion(RedisTemplate redisTemplate) { + public static void checkRedisVersion(RedisTemplate redisTemplate) { // 获得 Redis 版本 Properties info = redisTemplate.execute((RedisCallback) RedisServerCommands::info); String version = MapUtil.getStr(info, "redis_version"); diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java index cb4e3991f1..bb16be0eeb 100644 --- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java +++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/job/RedisPendingMessageResendJob.java @@ -35,7 +35,6 @@ public class RedisPendingMessageResendJob { private final List> listeners; private final RedisMQTemplate redisTemplate; - private final String groupName; private final RedissonClient redissonClient; /** @@ -64,13 +63,13 @@ public class RedisPendingMessageResendJob { private void execute() { StreamOperations ops = redisTemplate.getRedisTemplate().opsForStream(); listeners.forEach(listener -> { - PendingMessagesSummary pendingMessagesSummary = Objects.requireNonNull(ops.pending(listener.getStreamKey(), groupName)); + PendingMessagesSummary pendingMessagesSummary = Objects.requireNonNull(ops.pending(listener.getStreamKey(), listener.getGroup())); // 每个消费者的 pending 队列消息数量 Map pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount); // 每个消费者的 pending消息的详情信息 - PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(groupName, consumerName), Range.unbounded(), pendingMessageCount); + PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(listener.getGroup(), consumerName), Range.unbounded(), pendingMessageCount); if (pendingMessages.isEmpty()) { return; } @@ -91,7 +90,7 @@ public class RedisPendingMessageResendJob { .ofObject(records.get(0).getValue()) // 设置内容 .withStreamKey(listener.getStreamKey())); // ack 消息消费完成 - redisTemplate.getRedisTemplate().opsForStream().acknowledge(groupName, records.get(0)); + redisTemplate.getRedisTemplate().opsForStream().acknowledge(listener.getGroup(), records.get(0)); log.info("[processPendingMessage][消息({})重新投递成功]", records.get(0).getId()); }); }); diff --git a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java index 3e656af3f0..ba1aa96977 100644 --- a/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java +++ b/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java @@ -53,6 +53,12 @@ public abstract class AbstractRedisStreamMessageListener message) { // 消费消息 diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java index ab2992184f..4cbb91c2cc 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java @@ -1,16 +1,20 @@ package cn.iocoder.yudao.framework.mybatis.config; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.extension.incrementer.*; import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal; import com.baomidou.mybatisplus.extension.parser.cache.JdkSerialCaffeineJsqlParseCache; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.annotations.Mapper; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -18,6 +22,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.core.env.ConfigurableEnvironment; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -42,6 +47,8 @@ public class YudaoMybatisAutoConfiguration { public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 + // ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓ + // mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句 return mybatisPlusInterceptor; } @@ -73,4 +80,15 @@ public class YudaoMybatisAutoConfiguration { throw new IllegalArgumentException(StrUtil.format("DbType{} 找不到合适的 IKeyGenerator 实现类", dbType)); } + @Bean + public JacksonTypeHandler jacksonTypeHandler(List objectMappers) { + // 特殊:设置 JacksonTypeHandler 的 ObjectMapper! + ObjectMapper objectMapper = CollUtil.getFirst(objectMappers); + if (objectMapper == null) { + objectMapper = JsonUtils.getObjectMapper(); + } + JacksonTypeHandler.setObjectMapper(objectMapper); + return new JacksonTypeHandler(Object.class); + } + } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java index 3c35999109..b03f278a52 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java @@ -18,6 +18,7 @@ import java.util.Objects; public class DefaultDBFieldHandler implements MetaObjectHandler { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public void insertFill(MetaObject metaObject) { if (Objects.nonNull(metaObject) && metaObject.getOriginalObject() instanceof BaseDO) { BaseDO baseDO = (BaseDO) metaObject.getOriginalObject(); diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java index ac33ba8eff..56f51d91df 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java @@ -9,6 +9,7 @@ import cn.iocoder.yudao.framework.common.pojo.SortingField; import cn.iocoder.yudao.framework.mybatis.core.enums.DbTypeEnum; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.core.toolkit.StringPool; @@ -47,16 +48,36 @@ public class MyBatisUtils { return page; } + @SuppressWarnings("PatternVariableCanBeUsed") public static void addOrder(Wrapper wrapper, Collection sortingFields) { if (CollUtil.isEmpty(sortingFields)) { return; } - QueryWrapper query = (QueryWrapper) wrapper; - for (SortingField sortingField : sortingFields) { - query.orderBy(true, - SortingField.ORDER_ASC.equals(sortingField.getOrder()), - StrUtil.toUnderlineCase(sortingField.getField())); + if (wrapper instanceof QueryWrapper) { + QueryWrapper query = (QueryWrapper) wrapper; + for (SortingField sortingField : sortingFields) { + query.orderBy(true, + SortingField.ORDER_ASC.equals(sortingField.getOrder()), + StrUtil.toUnderlineCase(sortingField.getField())); + } + } else if (wrapper instanceof LambdaQueryWrapper) { + // LambdaQueryWrapper 不直接支持字符串字段排序,使用 last 方法拼接 ORDER BY + LambdaQueryWrapper lambdaQuery = (LambdaQueryWrapper) wrapper; + StringBuilder orderBy = new StringBuilder(); + for (SortingField sortingField : sortingFields) { + if (StrUtil.isNotEmpty(orderBy)) { + orderBy.append(", "); + } + orderBy.append(StrUtil.toUnderlineCase(sortingField.getField())) + .append(" ") + .append(SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? "ASC" : "DESC"); + } + lambdaQuery.last("ORDER BY " + orderBy); + // 另外个思路:https://blog.csdn.net/m0_59084856/article/details/138450913 + } else { + throw new IllegalArgumentException("Unsupported wrapper type: " + wrapper.getClass().getName()); } + } /** diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/aop/RateLimiterAspect.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/aop/RateLimiterAspect.java index 6ede62bea0..085a0242b8 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/aop/RateLimiterAspect.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/aop/RateLimiterAspect.java @@ -39,7 +39,7 @@ public class RateLimiterAspect { @Before("@annotation(rateLimiter)") public void beforePointCut(JoinPoint joinPoint, RateLimiter rateLimiter) { - // 获得 IdempotentKeyResolver 对象 + // 获得 RateLimiterKeyResolver 对象 RateLimiterKeyResolver keyResolver = keyResolvers.get(rateLimiter.keyResolver()); Assert.notNull(keyResolver, "找不到对应的 RateLimiterKeyResolver"); // 解析 Key diff --git a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoWebSecurityConfigurerAdapter.java b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoWebSecurityConfigurerAdapter.java index 8c2d93b22f..0c7b31521a 100644 --- a/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoWebSecurityConfigurerAdapter.java +++ b/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoWebSecurityConfigurerAdapter.java @@ -165,7 +165,8 @@ public class YudaoWebSecurityConfigurerAdapter { // 获得有 @PermitAll 注解的接口 for (Map.Entry entry : handlerMethodMap.entrySet()) { HandlerMethod handlerMethod = entry.getValue(); - if (!handlerMethod.hasMethodAnnotation(PermitAll.class)) { + if (!handlerMethod.hasMethodAnnotation(PermitAll.class) // 方法级 + && !handlerMethod.getBeanType().isAnnotationPresent(PermitAll.class)) { // 接口级 continue; } Set urls = new HashSet<>(); diff --git a/yudao-framework/yudao-spring-boot-starter-web/pom.xml b/yudao-framework/yudao-spring-boot-starter-web/pom.xml index d04db57c47..d909688741 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/pom.xml +++ b/yudao-framework/yudao-spring-boot-starter-web/pom.xml @@ -39,12 +39,12 @@ - com.github.xingfudeshi + com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter org.springdoc - springdoc-openapi-starter-webmvc-api + springdoc-openapi-starter-webmvc-ui @@ -53,7 +53,13 @@ provided - + + + com.google.guava + guava + provided + + org.jsoup jsoup diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/ApiEncryptProperties.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/ApiEncryptProperties.java new file mode 100644 index 0000000000..135eb85bb0 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/ApiEncryptProperties.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.framework.encrypt.config; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * HTTP API 加解密配置 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "yudao.api-encrypt") +@Validated +@Data +public class ApiEncryptProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enable; + + /** + * 请求头(响应头)名称 + * + * 1. 如果该请求头非空,则表示请求参数已被「前端」加密,「后端」需要解密 + * 2. 如果该响应头非空,则表示响应结果已被「后端」加密,「前端」需要解密 + */ + @NotEmpty(message = "请求头(响应头)名称不能为空") + private String header = "X-Api-Encrypt"; + + /** + * 对称加密算法,用于请求/响应的加解密 + * + * 目前支持 + * 【对称加密】: + * 1. {@link cn.hutool.crypto.symmetric.SymmetricAlgorithm#AES} + * 2. {@link cn.hutool.crypto.symmetric.SM4#ALGORITHM_NAME} (需要自己二次开发,成本低) + * 【非对称加密】 + * 1. {@link cn.hutool.crypto.asymmetric.AsymmetricAlgorithm#RSA} + * 2. {@link cn.hutool.crypto.asymmetric.SM2} (需要自己二次开发,成本低) + * + * @see 什么是公钥和私钥? + */ + @NotEmpty(message = "对称加密算法不能为空") + private String algorithm; + + /** + * 请求的解密密钥 + * + * 注意: + * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。 + * 2. 如果是【非对称加密】时,它「后端」对应的是“私钥”。对应的,「前端」对应的是“公钥”。(重要!!!) + */ + @NotEmpty(message = "请求的解密密钥不能为空") + private String requestKey; + + /** + * 响应的加密密钥 + * + * 注意: + * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。 + * 2. 如果是【非对称加密】时,它「后端」对应的是“公钥”。对应的,「前端」对应的是“私钥”。(重要!!!) + */ + @NotEmpty(message = "响应的加密密钥不能为空") + private String responseKey; + +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/YudaoApiEncryptAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/YudaoApiEncryptAutoConfiguration.java new file mode 100644 index 0000000000..03d0f1ac12 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/config/YudaoApiEncryptAutoConfiguration.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.framework.encrypt.config; + +import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum; +import cn.iocoder.yudao.framework.encrypt.core.filter.ApiEncryptFilter; +import cn.iocoder.yudao.framework.web.config.WebProperties; +import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import static cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration.createFilterBean; + +@AutoConfiguration +@Slf4j +@EnableConfigurationProperties(ApiEncryptProperties.class) +@ConditionalOnProperty(prefix = "yudao.api-encrypt", name = "enable", havingValue = "true") +public class YudaoApiEncryptAutoConfiguration { + + @Bean + public FilterRegistrationBean apiEncryptFilter(WebProperties webProperties, + ApiEncryptProperties apiEncryptProperties, + RequestMappingHandlerMapping requestMappingHandlerMapping, + GlobalExceptionHandler globalExceptionHandler) { + ApiEncryptFilter filter = new ApiEncryptFilter(webProperties, apiEncryptProperties, + requestMappingHandlerMapping, globalExceptionHandler); + return createFilterBean(filter, WebFilterOrderEnum.API_ENCRYPT_FILTER); + + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/annotation/ApiEncrypt.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/annotation/ApiEncrypt.java new file mode 100644 index 0000000000..7405111038 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/annotation/ApiEncrypt.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.framework.encrypt.core.annotation; + +import java.lang.annotation.*; + +/** + * HTTP API 加解密注解 + */ +@Documented +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiEncrypt { + + /** + * 是否对请求参数进行解密,默认 true + */ + boolean request() default true; + + /** + * 是否对响应结果进行加密,默认 true + */ + boolean response() default true; + +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java new file mode 100644 index 0000000000..b9f015a7ec --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.framework.encrypt.core.filter; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.asymmetric.AsymmetricDecryptor; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.symmetric.SymmetricDecryptor; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; + +/** + * 解密请求 {@link HttpServletRequestWrapper} 实现类 + * + * @author 芋道源码 + */ +public class ApiDecryptRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public ApiDecryptRequestWrapper(HttpServletRequest request, + SymmetricDecryptor symmetricDecryptor, + AsymmetricDecryptor asymmetricDecryptor) throws IOException { + super(request); + // 读取 body,允许 HEX、BASE64 传输 + String requestBody = StrUtil.utf8Str( + IoUtil.readBytes(request.getInputStream(), false)); + + // 解密 body + body = symmetricDecryptor != null ? symmetricDecryptor.decrypt(requestBody) + : asymmetricDecryptor.decrypt(requestBody, KeyType.PrivateKey); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(this.getInputStream())); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream stream = new ByteArrayInputStream(body); + return new ServletInputStream() { + + @Override + public int read() { + return stream.read(); + } + + @Override + public int available() { + return body.length; + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + }; + } +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptFilter.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptFilter.java new file mode 100644 index 0000000000..126a76a01f --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptFilter.java @@ -0,0 +1,161 @@ +package cn.iocoder.yudao.framework.encrypt.core.filter; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.asymmetric.AsymmetricDecryptor; +import cn.hutool.crypto.asymmetric.AsymmetricEncryptor; +import cn.hutool.crypto.symmetric.SymmetricDecryptor; +import cn.hutool.crypto.symmetric.SymmetricEncryptor; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; +import cn.iocoder.yudao.framework.encrypt.config.ApiEncryptProperties; +import cn.iocoder.yudao.framework.encrypt.core.annotation.ApiEncrypt; +import cn.iocoder.yudao.framework.web.config.WebProperties; +import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter; +import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.ServletRequestPathUtils; + +import java.io.IOException; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * API 加密过滤器,处理 {@link ApiEncrypt} 注解。 + * + * 1. 解密请求参数 + * 2. 加密响应结果 + * + * 疑问:为什么不使用 SpringMVC 的 RequestBodyAdvice 或 ResponseBodyAdvice 机制呢? + * 回答:考虑到项目中会记录访问日志、异常日志,以及 HTTP API 签名等场景,最好是全局级、且提前做解析!!! + * + * @author 芋道源码 + */ +@Slf4j +public class ApiEncryptFilter extends ApiRequestFilter { + + private final ApiEncryptProperties apiEncryptProperties; + + private final RequestMappingHandlerMapping requestMappingHandlerMapping; + + private final GlobalExceptionHandler globalExceptionHandler; + + private final SymmetricDecryptor requestSymmetricDecryptor; + private final AsymmetricDecryptor requestAsymmetricDecryptor; + + private final SymmetricEncryptor responseSymmetricEncryptor; + private final AsymmetricEncryptor responseAsymmetricEncryptor; + + public ApiEncryptFilter(WebProperties webProperties, + ApiEncryptProperties apiEncryptProperties, + RequestMappingHandlerMapping requestMappingHandlerMapping, + GlobalExceptionHandler globalExceptionHandler) { + super(webProperties); + this.apiEncryptProperties = apiEncryptProperties; + this.requestMappingHandlerMapping = requestMappingHandlerMapping; + this.globalExceptionHandler = globalExceptionHandler; + if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "AES")) { + this.requestSymmetricDecryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getRequestKey())); + this.requestAsymmetricDecryptor = null; + this.responseSymmetricEncryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getResponseKey())); + this.responseAsymmetricEncryptor = null; + } else if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "RSA")) { + this.requestSymmetricDecryptor = null; + this.requestAsymmetricDecryptor = SecureUtil.rsa(apiEncryptProperties.getRequestKey(), null); + this.responseSymmetricEncryptor = null; + this.responseAsymmetricEncryptor = SecureUtil.rsa(null, apiEncryptProperties.getResponseKey()); + } else { + // 补充说明:如果要支持 SM2、SM4 等算法,可在此处增加对应实例的创建,并添加相应的 Maven 依赖即可。 + throw new IllegalArgumentException("不支持的加密算法:" + apiEncryptProperties.getAlgorithm()); + } + } + + @Override + @SuppressWarnings("NullableProblems") + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 获取 @ApiEncrypt 注解 + ApiEncrypt apiEncrypt = getApiEncrypt(request); + boolean requestEnable = apiEncrypt != null && apiEncrypt.request(); + boolean responseEnable = apiEncrypt != null && apiEncrypt.response(); + String encryptHeader = request.getHeader(apiEncryptProperties.getHeader()); + if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) { + chain.doFilter(request, response); + return; + } + + // 1. 解密请求 + if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()), + HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) { + try { + if (StrUtil.isNotBlank(encryptHeader)) { + request = new ApiDecryptRequestWrapper(request, + requestSymmetricDecryptor, requestAsymmetricDecryptor); + } else if (requestEnable) { + throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头"); + } + } catch (Exception ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } + + // 2. 执行过滤器链 + if (responseEnable) { + // 特殊:仅包装,最后执行。目的:Response 内容可以被重复读取!!! + response = new ApiEncryptResponseWrapper(response); + } + chain.doFilter(request, response); + + // 3. 加密响应(真正执行) + if (responseEnable) { + ((ApiEncryptResponseWrapper) response).encrypt(apiEncryptProperties, + responseSymmetricEncryptor, responseAsymmetricEncryptor); + } + } + + /** + * 获取 @ApiEncrypt 注解 + * + * @param request 请求 + */ + @SuppressWarnings("PatternVariableCanBeUsed") + private ApiEncrypt getApiEncrypt(HttpServletRequest request) { + try { + // 特殊:兼容 SpringBoot 2.X 版本会报错的问题 https://t.zsxq.com/kqyiB + if (!ServletRequestPathUtils.hasParsedRequestPath(request)) { + ServletRequestPathUtils.parseAndCache(request); + } + + // 解析 @ApiEncrypt 注解 + HandlerExecutionChain mappingHandler = requestMappingHandlerMapping.getHandler(request); + if (mappingHandler == null) { + return null; + } + Object handler = mappingHandler.getHandler(); + if (handler instanceof HandlerMethod) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + ApiEncrypt annotation = handlerMethod.getMethodAnnotation(ApiEncrypt.class); + if (annotation == null) { + annotation = handlerMethod.getBeanType().getAnnotation(ApiEncrypt.class); + } + return annotation; + } + } catch (Exception e) { + log.error("[getApiEncrypt][url({}/{}) 获取 @ApiEncrypt 注解失败]", + request.getRequestURI(), request.getMethod(), e); + } + return null; + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java new file mode 100644 index 0000000000..fed38917b9 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/core/filter/ApiEncryptResponseWrapper.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.framework.encrypt.core.filter; + +import cn.hutool.crypto.asymmetric.AsymmetricEncryptor; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.symmetric.SymmetricEncryptor; +import cn.iocoder.yudao.framework.encrypt.config.ApiEncryptProperties; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; + +/** + * 加密响应 {@link HttpServletResponseWrapper} 实现类 + * + * @author 芋道源码 + */ +public class ApiEncryptResponseWrapper extends HttpServletResponseWrapper { + + private final ByteArrayOutputStream byteArrayOutputStream; + private final ServletOutputStream servletOutputStream; + private final PrintWriter printWriter; + + public ApiEncryptResponseWrapper(HttpServletResponse response) { + super(response); + this.byteArrayOutputStream = new ByteArrayOutputStream(); + this.servletOutputStream = this.getOutputStream(); + this.printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream)); + } + + public void encrypt(ApiEncryptProperties properties, + SymmetricEncryptor symmetricEncryptor, + AsymmetricEncryptor asymmetricEncryptor) throws IOException { + // 1.1 清空 body + HttpServletResponse response = (HttpServletResponse) this.getResponse(); + response.resetBuffer(); + // 1.2 获取 body + this.flushBuffer(); + byte[] body = byteArrayOutputStream.toByteArray(); + + // 2. 加密 body + String encryptedBody = symmetricEncryptor != null ? symmetricEncryptor.encryptBase64(body) + : asymmetricEncryptor.encryptBase64(body, KeyType.PublicKey); + response.getWriter().write(encryptedBody); + + // 3. 添加加密 header 标识 + this.addHeader(properties.getHeader(), "true"); + // 特殊:特殊:https://juejin.cn/post/6867327674675625992 + this.addHeader("Access-Control-Expose-Headers", properties.getHeader()); + } + + @Override + public PrintWriter getWriter() { + return printWriter; + } + + @Override + public void flushBuffer() throws IOException { + if (servletOutputStream != null) { + servletOutputStream.flush(); + } + if (printWriter != null) { + printWriter.flush(); + } + } + + @Override + public void reset() { + byteArrayOutputStream.reset(); + } + + @Override + public ServletOutputStream getOutputStream() { + return new ServletOutputStream() { + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + } + + @Override + public void write(int b) { + byteArrayOutputStream.write(b); + } + + @Override + @SuppressWarnings("NullableProblems") + public void write(byte[] b) throws IOException { + byteArrayOutputStream.write(b); + } + + @Override + @SuppressWarnings("NullableProblems") + public void write(byte[] b, int off, int len) { + byteArrayOutputStream.write(b, off, len); + } + + }; + } + +} \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/package-info.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/package-info.java new file mode 100644 index 0000000000..ca08197125 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/encrypt/package-info.java @@ -0,0 +1,4 @@ +/** + * HTTP API 加密组件:支持 Request 和 Response 的加密、解密 + */ +package cn.iocoder.yudao.framework.encrypt; \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/jackson/config/YudaoJacksonAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/jackson/config/YudaoJacksonAutoConfiguration.java index c62f0a0300..280f8da349 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/jackson/config/YudaoJacksonAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/jackson/config/YudaoJacksonAutoConfiguration.java @@ -1,10 +1,10 @@ package cn.iocoder.yudao.framework.jackson.config; -import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.json.databind.NumberSerializer; import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer; import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeSerializer; +import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; @@ -13,39 +13,65 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.context.annotation.Bean; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; -import java.util.List; -@AutoConfiguration +@AutoConfiguration(after = JacksonAutoConfiguration.class) @Slf4j public class YudaoJacksonAutoConfiguration { + /** + * 从 Builder 源头定制(关键:使用 *ByType,避免 handledType 要求) + */ + @Bean + public Jackson2ObjectMapperBuilderCustomizer ldtEpochMillisCustomizer() { + return builder -> builder + // Long -> Number + .serializerByType(Long.class, NumberSerializer.INSTANCE) + .serializerByType(Long.TYPE, NumberSerializer.INSTANCE) + // LocalDate / LocalTime + .serializerByType(LocalDate.class, LocalDateSerializer.INSTANCE) + .deserializerByType(LocalDate.class, LocalDateDeserializer.INSTANCE) + .serializerByType(LocalTime.class, LocalTimeSerializer.INSTANCE) + .deserializerByType(LocalTime.class, LocalTimeDeserializer.INSTANCE) + // LocalDateTime < - > EpochMillis + .serializerByType(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE) + .deserializerByType(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + } + + /** + * 以 Bean 形式暴露 Module(Boot 会自动注册到所有 ObjectMapper) + */ + @Bean + public Module timestampSupportModuleBean() { + SimpleModule m = new SimpleModule("TimestampSupportModule"); + // Long -> Number,避免前端精度丢失 + m.addSerializer(Long.class, NumberSerializer.INSTANCE); + m.addSerializer(Long.TYPE, NumberSerializer.INSTANCE); + // LocalDate / LocalTime + m.addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE); + m.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE); + m.addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE); + m.addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE); + // LocalDateTime < - > EpochMillis + m.addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE); + m.addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + return m; + } + + /** + * 初始化全局 JsonUtils,直接使用主 ObjectMapper + */ @Bean @SuppressWarnings("InstantiationOfUtilityClass") - public JsonUtils jsonUtils(List objectMappers) { - // 1.1 创建 SimpleModule 对象 - SimpleModule simpleModule = new SimpleModule(); - simpleModule - // 新增 Long 类型序列化规则,数值超过 2^53-1,在 JS 会出现精度丢失问题,因此 Long 自动序列化为字符串类型 - .addSerializer(Long.class, NumberSerializer.INSTANCE) - .addSerializer(Long.TYPE, NumberSerializer.INSTANCE) - .addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE) - .addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE) - .addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE) - .addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE) - // 新增 LocalDateTime 序列化、反序列化规则,使用 Long 时间戳 - .addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE) - .addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); - // 1.2 注册到 objectMapper - objectMappers.forEach(objectMapper -> objectMapper.registerModule(simpleModule)); - - // 2. 设置 objectMapper 到 JsonUtils - JsonUtils.init(CollUtil.getFirst(objectMappers)); - log.info("[init][初始化 JsonUtils 成功]"); + public JsonUtils jsonUtils(ObjectMapper objectMapper) { + JsonUtils.init(objectMapper); + log.debug("[init][初始化 JsonUtils 成功]"); return new JsonUtils(); } diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/Knife4jOpenApiCustomizer.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/Knife4jOpenApiCustomizer.java new file mode 100644 index 0000000000..f8996f75be --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/Knife4jOpenApiCustomizer.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.framework.swagger.config; + +import com.github.xiaoymin.knife4j.annotations.ApiSupport; +import com.github.xiaoymin.knife4j.core.conf.ExtensionsConstants; +import com.github.xiaoymin.knife4j.core.conf.GlobalConstants; +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jProperties; +import com.github.xiaoymin.knife4j.spring.configuration.Knife4jSetting; +import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.models.OpenAPI; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springdoc.core.properties.SpringDocConfigProperties; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.Annotation; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 增强扩展属性支持 + * + * 参考 Spring Boot 3.4 以上版本 /v3/api-docs 解决接口报错,依赖修复 + * + * @since 4.1.0 + * @author xiaoymin@foxmail.com + * 2022/12/11 22:40 + */ +@Primary +@Configuration +@Slf4j +public class Knife4jOpenApiCustomizer extends com.github.xiaoymin.knife4j.spring.extension.Knife4jOpenApiCustomizer + implements GlobalOpenApiCustomizer { + + final Knife4jProperties knife4jProperties; + final SpringDocConfigProperties properties; + + public Knife4jOpenApiCustomizer(Knife4jProperties knife4jProperties, SpringDocConfigProperties properties) { + super(knife4jProperties,properties); + this.knife4jProperties = knife4jProperties; + this.properties = properties; + } + + @Override + public void customise(OpenAPI openApi) { + if (knife4jProperties.isEnable()) { + Knife4jSetting setting = knife4jProperties.getSetting(); + OpenApiExtensionResolver openApiExtensionResolver = new OpenApiExtensionResolver(setting, knife4jProperties.getDocuments()); + // 解析初始化 + openApiExtensionResolver.start(); + Map objectMap = new HashMap<>(); + objectMap.put(GlobalConstants.EXTENSION_OPEN_SETTING_NAME, setting); + objectMap.put(GlobalConstants.EXTENSION_OPEN_MARKDOWN_NAME, openApiExtensionResolver.getMarkdownFiles()); + openApi.addExtension(GlobalConstants.EXTENSION_OPEN_API_NAME, objectMap); + addOrderExtension(openApi); + } + } + + /** + * 往 OpenAPI 内 tags 字段添加 x-order 属性 + * + * @param openApi openApi + */ + private void addOrderExtension(OpenAPI openApi) { + if (CollectionUtils.isEmpty(properties.getGroupConfigs())) { + return; + } + // 获取包扫描路径 + Set packagesToScan = + properties.getGroupConfigs().stream() + .map(SpringDocConfigProperties.GroupConfig::getPackagesToScan) + .filter(toScan -> !CollectionUtils.isEmpty(toScan)) + .flatMap(List::stream) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(packagesToScan)) { + return; + } + // 扫描包下被 ApiSupport 注解的 RestController Class + Set> classes = packagesToScan.stream() + .map(packageToScan -> scanPackageByAnnotation(packageToScan, RestController.class)) + .flatMap(Set::stream) + .filter(clazz -> clazz.isAnnotationPresent(ApiSupport.class)) + .collect(Collectors.toSet()); + if (!CollectionUtils.isEmpty(classes)) { + // ApiSupport oder 值存入 tagSortMap + Map tagOrderMap = new HashMap<>(); + classes.forEach(clazz -> { + Tag tag = getTag(clazz); + if (Objects.nonNull(tag)) { + ApiSupport apiSupport = clazz.getAnnotation(ApiSupport.class); + tagOrderMap.putIfAbsent(tag.name(), apiSupport.order()); + } + }); + // 往 openApi tags 字段添加 x-order 增强属性 + if (openApi.getTags() != null) { + openApi.getTags().forEach(tag -> { + if (tagOrderMap.containsKey(tag.getName())) { + tag.addExtension(ExtensionsConstants.EXTENSION_ORDER, tagOrderMap.get(tag.getName())); + } + }); + } + } + } + + private Tag getTag(Class clazz) { + // 从类上获取 + Tag tag = clazz.getAnnotation(Tag.class); + if (Objects.isNull(tag)) { + // 从接口上获取 + Class[] interfaces = clazz.getInterfaces(); + if (ArrayUtils.isNotEmpty(interfaces)) { + for (Class interfaceClazz : interfaces) { + Tag anno = interfaceClazz.getAnnotation(Tag.class); + if (Objects.nonNull(anno)) { + tag = anno; + break; + } + } + } + } + return tag; + } + + private Set> scanPackageByAnnotation(String packageName, final Class annotationClass) { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(annotationClass)); + Set> classes = new HashSet<>(); + for (BeanDefinition beanDefinition : scanner.findCandidateComponents(packageName)) { + try { + Class clazz = Class.forName(beanDefinition.getBeanClassName()); + classes.add(clazz); + } catch (ClassNotFoundException ignore) { + } + } + return classes; + } + +} \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/YudaoSwaggerAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/YudaoSwaggerAutoConfiguration.java index 295e3238ff..dec79d8eb6 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/YudaoSwaggerAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/swagger/config/YudaoSwaggerAutoConfiguration.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; import org.springframework.http.HttpHeaders; @@ -46,6 +47,7 @@ import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_ @ConditionalOnClass({OpenAPI.class}) @EnableConfigurationProperties(SwaggerProperties.class) @ConditionalOnProperty(prefix = "springdoc.api-docs", name = "enabled", havingValue = "true", matchIfMissing = true) // 设置为 false 时,禁用 +@Import(Knife4jOpenApiCustomizer.class) public class YudaoSwaggerAutoConfiguration { // ========== 全局 OpenAPI 配置 ========== diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java index 8e80fa591f..b5f38d96fd 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java @@ -1,14 +1,13 @@ package cn.iocoder.yudao.framework.web.core.filter; import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; - import jakarta.servlet.ReadListener; import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequestWrapper; + import java.io.BufferedReader; import java.io.ByteArrayInputStream; -import java.io.IOException; import java.io.InputStreamReader; /** @@ -29,12 +28,22 @@ public class CacheRequestBodyWrapper extends HttpServletRequestWrapper { } @Override - public BufferedReader getReader() throws IOException { + public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(this.getInputStream())); } @Override - public ServletInputStream getInputStream() throws IOException { + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + + @Override + public ServletInputStream getInputStream() { final ByteArrayInputStream inputStream = new ByteArrayInputStream(body); // 返回 ServletInputStream return new ServletInputStream() { diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java index 627a5ea784..b99925e651 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java @@ -5,7 +5,6 @@ import cn.hutool.core.exceptions.ExceptionUtil; import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.extra.servlet.JakartaServletUtil; import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi; import cn.iocoder.yudao.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO; import cn.iocoder.yudao.framework.common.exception.ServiceException; @@ -17,6 +16,7 @@ import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils; import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.google.common.util.concurrent.UncheckedExecutionException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -36,6 +36,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -93,6 +94,9 @@ public class GlobalExceptionHandler { if (ex instanceof ValidationException) { return validationException((ValidationException) ex); } + if (ex instanceof MaxUploadSizeExceededException) { + return maxUploadSizeExceededExceptionHandler((MaxUploadSizeExceededException) ex); + } if (ex instanceof NoHandlerFoundException) { return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex); } @@ -178,6 +182,7 @@ public class GlobalExceptionHandler { * 例如说,接口上设置了 @RequestBody 实体中 xx 属性类型为 Integer,结果传递 xx 参数类型为 String */ @ExceptionHandler(HttpMessageNotReadableException.class) + @SuppressWarnings("PatternVariableCanBeUsed") public CommonResult methodArgumentTypeInvalidFormatExceptionHandler(HttpMessageNotReadableException ex) { log.warn("[methodArgumentTypeInvalidFormatExceptionHandler]", ex); if (ex.getCause() instanceof InvalidFormatException) { @@ -210,6 +215,14 @@ public class GlobalExceptionHandler { return CommonResult.error(BAD_REQUEST); } + /** + * 处理上传文件过大异常 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public CommonResult maxUploadSizeExceededExceptionHandler(MaxUploadSizeExceededException ex) { + return CommonResult.error(BAD_REQUEST.getCode(), "上传文件过大,请调整后重试"); + } + /** * 处理 SpringMVC 请求地址不存在 * @@ -266,6 +279,16 @@ public class GlobalExceptionHandler { return CommonResult.error(FORBIDDEN); } + /** + * 处理 Guava UncheckedExecutionException + * + * 例如说,缓存加载报错,可见 https://t.zsxq.com/UszdH + */ + @ExceptionHandler(value = UncheckedExecutionException.class) + public CommonResult uncheckedExecutionExceptionHandler(HttpServletRequest req, UncheckedExecutionException ex) { + return allExceptionHandler(req, ex.getCause()); + } + /** * 处理业务异常 ServiceException * @@ -296,6 +319,12 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(value = Exception.class) public CommonResult defaultExceptionHandler(HttpServletRequest req, Throwable ex) { + // 特殊:如果是 ServiceException 的异常,则直接返回 + // 例如说:https://gitee.com/zhijiantianya/yudao-cloud/issues/ICSSRM、https://gitee.com/zhijiantianya/yudao-cloud/issues/ICT6FM + if (ex.getCause() != null && ex.getCause() instanceof ServiceException) { + return serviceExceptionHandler((ServiceException) ex.getCause()); + } + // 情况一:处理表不存在的异常 CommonResult tableNotExistsResult = handleTableNotExists(ex); if (tableNotExistsResult != null) { @@ -344,12 +373,12 @@ public class GlobalExceptionHandler { errorLog.setApplicationName(applicationName); errorLog.setRequestUrl(request.getRequestURI()); Map requestParams = MapUtil.builder() - .put("query", JakartaServletUtil.getParamMap(request)) - .put("body", JakartaServletUtil.getBody(request)).build(); + .put("query", ServletUtils.getParamMap(request)) + .put("body", ServletUtils.getBody(request)).build(); errorLog.setRequestParams(JsonUtils.toJsonString(requestParams)); errorLog.setRequestMethod(request.getMethod()); errorLog.setUserAgent(ServletUtils.getUserAgent(request)); - errorLog.setUserIp(JakartaServletUtil.getClientIP(request)); + errorLog.setUserIp(ServletUtils.getClientIP(request)); errorLog.setExceptionTime(LocalDateTime.now()); } diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java index b4aa301676..248e8a4155 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java @@ -144,6 +144,7 @@ public class WebFrameworkUtils { return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); } + @SuppressWarnings("PatternVariableCanBeUsed") public static HttpServletRequest getRequest() { RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (!(requestAttributes instanceof ServletRequestAttributes)) { diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-framework/yudao-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 9cdcd09c4e..07a7955c34 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,4 +3,5 @@ cn.iocoder.yudao.framework.jackson.config.YudaoJacksonAutoConfiguration cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration cn.iocoder.yudao.framework.xss.config.YudaoXssAutoConfiguration -cn.iocoder.yudao.framework.banner.config.YudaoBannerAutoConfiguration \ No newline at end of file +cn.iocoder.yudao.framework.banner.config.YudaoBannerAutoConfiguration +cn.iocoder.yudao.framework.encrypt.config.YudaoApiEncryptAutoConfiguration \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java b/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java new file mode 100644 index 0000000000..12d406e5f5 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.framework.encrypt; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.asymmetric.AsymmetricAlgorithm; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.asymmetric.RSA; +import cn.hutool.crypto.symmetric.SymmetricAlgorithm; +import org.junit.jupiter.api.Test; + +import java.util.Objects; + +/** + * 各种 API 加解密的测试类:不是单测,而是方便大家生成密钥、加密、解密等操作。 + * + * @author 芋道源码 + */ +@SuppressWarnings("ConstantValue") +public class ApiEncryptTest { + + @Test + public void testGenerateAsymmetric() { + String asymmetricAlgorithm = AsymmetricAlgorithm.RSA.getValue(); +// String asymmetricAlgorithm = "SM2"; +// String asymmetricAlgorithm = SM4.ALGORITHM_NAME; +// String asymmetricAlgorithm = SymmetricAlgorithm.AES.getValue(); + String requestClientKey = null; + String requestServerKey = null; + String responseClientKey = null; + String responseServerKey = null; + if (Objects.equals(asymmetricAlgorithm, AsymmetricAlgorithm.RSA.getValue())) { + // 请求的密钥 + RSA requestRsa = SecureUtil.rsa(); + requestClientKey = requestRsa.getPublicKeyBase64(); + requestServerKey = requestRsa.getPrivateKeyBase64(); + // 响应的密钥 + RSA responseRsa = new RSA(); + responseClientKey = responseRsa.getPrivateKeyBase64(); + responseServerKey = responseRsa.getPublicKeyBase64(); + } else if (Objects.equals(asymmetricAlgorithm, SymmetricAlgorithm.AES.getValue())) { + // AES 密钥可选 32、24、16 位 + // 请求的密钥(前后端密钥一致) + requestClientKey = RandomUtil.randomNumbers(32); + requestServerKey = requestClientKey; + // 响应的密钥(前后端密钥一致) + responseClientKey = RandomUtil.randomNumbers(32); + responseServerKey = responseClientKey; + } + + // 打印结果 + System.out.println("requestClientKey = " + requestClientKey); + System.out.println("requestServerKey = " + requestServerKey); + System.out.println("responseClientKey = " + responseClientKey); + System.out.println("responseServerKey = " + responseServerKey); + } + + @Test + public void testEncrypt_aes() { + String key = "52549111389893486934626385991395"; + String body = "{\n" + + " \"username\": \"admin\",\n" + + " \"password\": \"admin123\",\n" + + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" + + " \"code\": \"1024\"\n" + + "}"; + String encrypt = SecureUtil.aes(StrUtil.utf8Bytes(key)) + .encryptBase64(body); + System.out.println("encrypt = " + encrypt); + } + + @Test + public void testEncrypt_rsa() { + String key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB"; + String body = "{\n" + + " \"username\": \"admin\",\n" + + " \"password\": \"admin123\",\n" + + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" + + " \"code\": \"1024\"\n" + + "}"; + String encrypt = SecureUtil.rsa(null, key) + .encryptBase64(body, KeyType.PublicKey); + System.out.println("encrypt = " + encrypt); + } + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java new file mode 100644 index 0000000000..378a0af1fb --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import reactor.core.publisher.Flux; + +/** + * 谷歌 Gemini {@link ChatModel} 实现类,基于 Google AI Studio 提供的 OpenAI 兼容方案 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class GeminiChatModel implements ChatModel { + + public static final String BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; + public static final String COMPLETE_PATH = "/chat/completions"; + + public static final String MODEL_DEFAULT = "gemini-2.5-flash"; + + /** + * 兼容 OpenAI 接口,进行复用 + */ + private final OpenAiChatModel openAiChatModel; + + @Override + public ChatResponse call(Prompt prompt) { + return openAiChatModel.call(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return openAiChatModel.stream(prompt); + } + + @Override + public ChatOptions getDefaultOptions() { + return openAiChatModel.getDefaultOptions(); + } + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java new file mode 100644 index 0000000000..9fbff556c1 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch; + +/** + * 网络搜索客户端接口 + * + * @author 芋道源码 + */ +public interface AiWebSearchClient { + + /** + * 网页搜索 + * + * @param request 搜索请求 + * @return 搜索结果 + */ + AiWebSearchResponse search(AiWebSearchRequest request); + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java new file mode 100644 index 0000000000..9bd2cfef32 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class AiWebSearchRequest { + + /** + * 用户的搜索词 + */ + @NotEmpty(message = "搜索词不能为空") + private String query; + + /** + * 是否显示文本摘要 + * + * true - 显示 + * false - 不显示(默认) + */ + private Boolean summary; + + /** + * 返回结果的条数 + */ + @NotNull(message = "返回结果条数不能为空") + @Min(message = "返回结果条数最小为 1", value = 1) + @Max(message = "返回结果条数最大为 50", value = 50) + private Integer count; + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java new file mode 100644 index 0000000000..8755b32ed0 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch; + +import lombok.Data; + +import java.util.List; + +@Data +public class AiWebSearchResponse { + + /** + * 总数(总共匹配的网页数) + */ + private Long total; + + /** + * 数据列表 + */ + private List lists; + + /** + * 网页对象 + */ + @Data + public static class WebPage { + + /** + * 名称 + * + * 例如说:搜狐网 + */ + private String name; + /** + * 图标 + */ + private String icon; + + /** + * 标题 + * + * 例如说:186页|阿里巴巴:2024年环境、社会和治理(ESG)报告 + */ + private String title; + /** + * URL + * + * 例如说:https://m.sohu.com/a/815036254_121819701/?pvid=000115_3w_a + */ + @SuppressWarnings("JavadocLinkAsPlainText") + private String url; + + /** + * 内容的简短描述 + */ + private String snippet; + /** + * 内容的文本摘要 + */ + private String summary; + + } + +} \ No newline at end of file diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java new file mode 100644 index 0000000000..7395fe645a --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 博查 {@link AiWebSearchClient} 实现类 + * + * @see 博查 AI 开放平台 + * + * @author 芋道源码 + */ +@Slf4j +public class AiBoChaWebSearchClient implements AiWebSearchClient { + + public static final String BASE_URL = "https://api.bochaai.com"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final WebClient webClient; + + private final Predicate STATUS_PREDICATE = status -> !status.is2xxSuccessful(); + + private final Function>> EXCEPTION_FUNCTION = + reqParam -> response -> response.bodyToMono(String.class).handle((responseBody, sink) -> { + log.error("[AiBoChaWebSearchClient] 调用失败!请求参数:[{}],响应数据: [{}]", reqParam, responseBody); + sink.error(new IllegalStateException("[AiBoChaWebSearchClient] 调用失败!")); + }); + + public AiBoChaWebSearchClient(String apiKey) { + this.webClient = WebClient.builder() + .baseUrl(BASE_URL) + .defaultHeaders((headers) -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey); + }) + .build(); + } + + @Override + public AiWebSearchResponse search(AiWebSearchRequest request) { + // 转换请求参数 + WebSearchRequest webSearchRequest = new WebSearchRequest( + request.getQuery(), + request.getSummary(), + request.getCount() + ); + // 调用博查 API + CommonResult response = this.webClient.post() + .uri("/v1/web-search") + .bodyValue(webSearchRequest) + .retrieve() + .onStatus(STATUS_PREDICATE, EXCEPTION_FUNCTION.apply(webSearchRequest)) + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + if (response == null) { + throw new IllegalStateException("[search][搜索结果为空]"); + } + if (response.getData() == null) { + throw new IllegalStateException(String.format("[search][搜索失败,code = %s, msg = %s]", + response.getCode(), response.getMsg())); + } + WebSearchResponse data = response.getData(); + + // 转换结果 + AiWebSearchResponse result = new AiWebSearchResponse(); + if (data.webPages() == null || CollUtil.isEmpty(data.webPages().value())) { + return result.setTotal(0L).setLists(List.of()); + } + return result.setTotal(data.webPages().totalEstimatedMatches()) + .setLists(convertList(data.webPages().value(), page -> new AiWebSearchResponse.WebPage() + .setName(page.siteName()).setIcon(page.siteIcon()) + .setTitle(page.name()).setUrl(page.url()) + .setSnippet(page.snippet()).setSummary(page.summary()))); + } + + /** + * 网页搜索请求参数 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebSearchRequest( + String query, + Boolean summary, + Integer count + ) { + public WebSearchRequest { + Assert.notBlank(query, "query 不能为空"); + } + } + + /** + * 网页搜索响应 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebSearchResponse( + WebSearchWebPages webPages + ) { + } + + /** + * 网页搜索结果 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebSearchWebPages( + String webSearchUrl, + Long totalEstimatedMatches, + List value, + Boolean someResultsRemoved + ) { + + /** + * 网页结果值 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebPageValue( + String id, + String name, + String url, + String displayUrl, + String snippet, + String summary, + String siteName, + String siteIcon, + String datePublished, + String dateLastCrawled, + String cachedPageUrl, + String language, + Boolean isFamilyFriendly, + Boolean isNavigational + ) { + } + + } + +} diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/security/core/package-info.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/security/core/package-info.java new file mode 100644 index 0000000000..87969449d8 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/security/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位 + */ +package cn.iocoder.yudao.module.ai.framework.security.core; diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java new file mode 100644 index 0000000000..0b59656352 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java @@ -0,0 +1,4 @@ +/** + * 参考 Tool Calling —— Methods as Tools + */ +package cn.iocoder.yudao.module.ai.tool.function; \ No newline at end of file diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java new file mode 100644 index 0000000000..66bab5a7fc --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.ai.tool.method; + +/** + * 来自 Spring AI 官方文档 + * + * Represents a person with basic information. + * This is an immutable record. + */ +public record Person( + int id, + String firstName, + String lastName, + String email, + String sex, + String ipAddress, + String jobTitle, + int age +) { +} \ No newline at end of file diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java new file mode 100644 index 0000000000..52c8954945 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.ai.tool.method; + +import java.util.List; +import java.util.Optional; + +/** + * 来自 Spring AI 官方文档 + * + * Service interface for managing Person data. + * Defines the contract for CRUD operations and search/filter functionalities. + */ +public interface PersonService { + + /** + * Creates a new Person record. + * Assigns a unique ID to the person and stores it. + * + * @param personData The data for the new person (ID field is ignored). Must not be null. + * @return The created Person record, including the generated ID. + */ + Person createPerson(Person personData); + + /** + * Retrieves a Person by their unique ID. + * + * @param id The ID of the person to retrieve. + * @return An Optional containing the found Person, or an empty Optional if not found. + */ + Optional getPersonById(int id); + + /** + * Retrieves all Person records currently stored. + * + * @return An unmodifiable List containing all Persons. Returns an empty list if none exist. + */ + List getAllPersons(); + + /** + * Updates an existing Person record identified by ID. + * Replaces the existing data with the provided data, keeping the original ID. + * + * @param id The ID of the person to update. + * @param updatedPersonData The new data for the person (ID field is ignored). Must not be null. + * @return true if the person was found and updated, false otherwise. + */ + boolean updatePerson(int id, Person updatedPersonData); + + /** + * Deletes a Person record identified by ID. + * + * @param id The ID of the person to delete. + * @return true if the person was found and deleted, false otherwise. + */ + boolean deletePerson(int id); + + /** + * Searches for Persons whose job title contains the given query string (case-insensitive). + * + * @param jobTitleQuery The string to search for within job titles. Can be null or blank. + * @return An unmodifiable List of matching Persons. Returns an empty list if no matches or query is invalid. + */ + List searchByJobTitle(String jobTitleQuery); + + /** + * Filters Persons by their exact sex (case-insensitive). + * + * @param sex The sex to filter by (e.g., "Male", "Female"). Can be null or blank. + * @return An unmodifiable List of matching Persons. Returns an empty list if no matches or filter is invalid. + */ + List filterBySex(String sex); + + /** + * Filters Persons by their exact age. + * + * @param age The age to filter by. + * @return An unmodifiable List of matching Persons. Returns an empty list if no matches. + */ + List filterByAge(int age); + +} \ No newline at end of file diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java new file mode 100644 index 0000000000..3b8c31b420 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java @@ -0,0 +1,336 @@ +package cn.iocoder.yudao.module.ai.tool.method; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 来自 Spring AI 官方文档 + * + * Implementation of the PersonService interface using an in-memory data store. + * Manages a collection of Person objects loaded from embedded CSV data. + * This class is thread-safe due to the use of ConcurrentHashMap and AtomicInteger. + */ +@Service +@Slf4j +public class PersonServiceImpl implements PersonService { + + private final Map personStore = new ConcurrentHashMap<>(); + + private AtomicInteger idGenerator; + + /** + * Embedded CSV data for initial population + */ + private static final String CSV_DATA = """ + Id,FirstName,LastName,Email,Sex,IpAddress,JobTitle,Age + 1,Fons,Tollfree,ftollfree0@senate.gov,Male,55.1 Tollfree Lane,Research Associate,31 + 2,Emlynne,Tabourier,etabourier1@networksolutions.com,Female,18 Tabourier Way,Associate Professor,38 + 3,Shae,Johncey,sjohncey2@yellowpages.com,Male,1 Johncey Circle,Structural Analysis Engineer,30 + 4,Sebastien,Bradly,sbradly3@mapquest.com,Male,2 Bradly Hill,Chief Executive Officer,40 + 5,Harriott,Kitteringham,hkitteringham4@typepad.com,Female,3 Kitteringham Drive,VP Sales,47 + 6,Anallise,Parradine,aparradine5@miibeian.gov.cn,Female,4 Parradine Street,Analog Circuit Design manager,44 + 7,Gorden,Kirkbright,gkirkbright6@reuters.com,Male,5 Kirkbright Plaza,Senior Editor,40 + 8,Veradis,Ledwitch,vledwitch7@google.com.au,Female,6 Ledwitch Avenue,Computer Systems Analyst IV,44 + 9,Agnesse,Penhalurick,apenhalurick8@google.it,Female,7 Penhalurick Terrace,Automation Specialist IV,41 + 10,Bibby,Hutable,bhutable9@craigslist.org,Female,8 Hutable Place,Account Representative I,43 + 11,Karoly,Lightoller,klightollera@rakuten.co.jp,Female,9 Lightoller Parkway,Senior Developer,46 + 12,Cristine,Durrad,cdurradb@aol.com,Female,10 Durrad Center,Senior Developer,48 + 13,Aggy,Napier,anapierc@hostgator.com,Female,11 Napier Court,VP Product Management,44 + 14,Prisca,Caddens,pcaddensd@vinaora.com,Female,12 Caddens Alley,Business Systems Development Analyst,41 + 15,Khalil,McKernan,kmckernane@google.fr,Male,13 McKernan Pass,Engineer IV,44 + 16,Lorry,MacTrusty,lmactrustyf@eventbrite.com,Male,14 MacTrusty Junction,Design Engineer,42 + 17,Casandra,Worsell,cworsellg@goo.gl,Female,15 Worsell Point,Systems Administrator IV,45 + 18,Ulrikaumeko,Haveline,uhavelineh@usgs.gov,Female,16 Haveline Trail,Financial Advisor,42 + 19,Shurlocke,Albany,salbanyi@artisteer.com,Male,17 Albany Plaza,Software Test Engineer III,46 + 20,Myrilla,Brimilcombe,mbrimilcombej@accuweather.com,Female,18 Brimilcombe Road,Programmer Analyst I,48 + 21,Carlina,Scimonelli,cscimonellik@va.gov,Female,19 Scimonelli Pass,Help Desk Technician,45 + 22,Tina,Goullee,tgoulleel@miibeian.gov.cn,Female,20 Goullee Crossing,Accountant IV,43 + 23,Adriaens,Storek,astorekm@devhub.com,Female,21 Storek Avenue,Recruiting Manager,40 + 24,Tedra,Giraudot,tgiraudotn@wiley.com,Female,22 Giraudot Terrace,Speech Pathologist,47 + 25,Josiah,Soares,jsoareso@google.nl,Male,23 Soares Street,Tax Accountant,45 + 26,Kayle,Gaukrodge,kgaukrodgep@wikispaces.com,Female,24 Gaukrodge Parkway,Accountant II,43 + 27,Ardys,Chuter,achuterq@ustream.tv,Female,25 Chuter Drive,Engineer IV,41 + 28,Francyne,Baudinet,fbaudinetr@newyorker.com,Female,26 Baudinet Center,VP Accounting,48 + 29,Gerick,Bullan,gbullans@seesaa.net,Male,27 Bullan Way,Senior Financial Analyst,43 + 30,Northrup,Grivori,ngrivorit@unc.edu,Male,28 Grivori Plaza,Systems Administrator I,45 + 31,Town,Duguid,tduguidu@squarespace.com,Male,29 Duguid Pass,Safety Technician IV,46 + 32,Pierette,Kopisch,pkopischv@google.com.br,Female,30 Kopisch Lane,Director of Sales,41 + 33,Jacquenetta,Le Prevost,jleprevostw@netlog.com,Female,31 Le Prevost Trail,Senior Developer,47 + 34,Garvy,Rusted,grustedx@aboutads.info,Male,32 Rusted Junction,Senior Developer,42 + 35,Clarice,Aysh,cayshy@merriam-webster.com,Female,33 Aysh Avenue,VP Quality Control,40 + 36,Tracie,Fedorski,tfedorskiz@bloglines.com,Male,34 Fedorski Terrace,Design Engineer,44 + 37,Noelyn,Matushenko,nmatushenko10@globo.com,Female,35 Matushenko Place,VP Sales,48 + 38,Rudiger,Klaesson,rklaesson11@usnews.com,Male,36 Klaesson Road,Database Administrator IV,43 + 39,Mirella,Syddie,msyddie12@geocities.jp,Female,37 Syddie Circle,Geological Engineer,46 + 40,Donalt,O'Lunny,dolunny13@elpais.com,Male,38 O'Lunny Center,Analog Circuit Design manager,41 + 41,Guntar,Deniskevich,gdeniskevich14@google.com.hk,Male,39 Deniskevich Way,Structural Engineer,47 + 42,Hort,Shufflebotham,hshufflebotham15@about.me,Male,40 Shufflebotham Court,Structural Analysis Engineer,45 + 43,Dominique,Thickett,dthickett16@slashdot.org,Male,41 Thickett Crossing,Safety Technician I,42 + 44,Zebulen,Piscopello,zpiscopello17@umich.edu,Male,42 Piscopello Parkway,Web Developer II,40 + 45,Mellicent,Mac Giany,mmacgiany18@state.tx.us,Female,43 Mac Giany Pass,Assistant Manager,44 + 46,Merle,Bounds,mbounds19@amazon.co.jp,Female,44 Bounds Alley,Systems Administrator III,41 + 47,Madelle,Farbrace,mfarbrace1a@xinhuanet.com,Female,45 Farbrace Terrace,Quality Engineer,48 + 48,Galvin,O'Sheeryne,gosheeryne1b@addtoany.com,Male,46 O'Sheeryne Way,Environmental Specialist,43 + 49,Guillemette,Bootherstone,gbootherstone1c@nationalgeographic.com,Female,47 Bootherstone Plaza,Professor,46 + 50,Letti,Aylmore,laylmore1d@vinaora.com,Female,48 Aylmore Circle,Automation Specialist I,40 + 51,Nonie,Rivalland,nrivalland1e@weather.com,Female,49 Rivalland Avenue,Software Test Engineer IV,45 + 52,Jacquelynn,Halfacre,jhalfacre1f@surveymonkey.com,Female,50 Halfacre Pass,Geologist II,42 + 53,Anderea,MacKibbon,amackibbon1g@weibo.com,Female,51 MacKibbon Parkway,Automation Specialist II,47 + 54,Wash,Klimko,wklimko1h@slashdot.org,Male,52 Klimko Alley,Database Administrator I,40 + 55,Flori,Kynett,fkynett1i@auda.org.au,Female,53 Kynett Trail,Quality Control Specialist,46 + 56,Libbey,Penswick,lpenswick1j@google.co.uk,Female,54 Penswick Point,VP Accounting,43 + 57,Silvanus,Skellorne,sskellorne1k@booking.com,Male,55 Skellorne Drive,Account Executive,48 + 58,Carmine,Mateos,cmateos1l@plala.or.jp,Male,56 Mateos Terrace,Systems Administrator I,41 + 59,Sheffie,Blazewicz,sblazewicz1m@google.com.au,Male,57 Blazewicz Center,VP Sales,44 + 60,Leanor,Worsnop,lworsnop1n@uol.com.br,Female,58 Worsnop Plaza,Systems Administrator III,45 + 61,Caspar,Pamment,cpamment1o@google.co.jp,Male,59 Pamment Court,Senior Financial Analyst,42 + 62,Justinian,Pentycost,jpentycost1p@sciencedaily.com,Male,60 Pentycost Way,Senior Quality Engineer,47 + 63,Gerianne,Jarnell,gjarnell1q@bing.com,Female,61 Jarnell Avenue,Help Desk Operator,40 + 64,Boycie,Zanetto,bzanetto1r@about.com,Male,62 Zanetto Place,Quality Engineer,46 + 65,Camilla,Mac Giany,cmacgiany1s@state.gov,Female,63 Mac Giany Parkway,Senior Cost Accountant,43 + 66,Hadlee,Piscopiello,hpiscopiello1t@artisteer.com,Male,64 Piscopiello Street,Account Representative III,48 + 67,Bobbie,Penvarden,bpenvarden1u@google.cn,Male,65 Penvarden Lane,Help Desk Operator,41 + 68,Ali,Gowlett,agowlett1v@parallels.com,Male,66 Gowlett Pass,VP Marketing,44 + 69,Olivette,Acome,oacome1w@qq.com,Female,67 Acome Hill,VP Product Management,45 + 70,Jehanna,Brotherheed,jbrotherheed1x@google.nl,Female,68 Brotherheed Junction,Database Administrator III,42 + 71,Morgan,Berthomieu,mberthomieu1y@artisteer.com,Male,69 Berthomieu Alley,Systems Administrator II,47 + 72,Linzy,Shilladay,lshilladay1z@icq.com,Female,70 Shilladay Trail,Research Assistant IV,40 + 73,Faydra,Brimner,fbrimner20@mozilla.org,Female,71 Brimner Road,Senior Editor,46 + 74,Gwenore,Oxlee,goxlee21@devhub.com,Female,72 Oxlee Terrace,Systems Administrator II,43 + 75,Evangelin,Beinke,ebeinke22@mozilla.com,Female,73 Beinke Circle,Accountant I,48 + 76,Missy,Cockling,mcockling23@si.edu,Female,74 Cockling Way,Software Engineer I,41 + 77,Suzanne,Klimschak,sklimschak24@etsy.com,Female,75 Klimschak Plaza,Tax Accountant,44 + 78,Candide,Goricke,cgoricke25@weebly.com,Female,76 Goricke Pass,Sales Associate,45 + 79,Gerome,Pinsent,gpinsent26@google.com.au,Male,77 Pinsent Junction,Software Consultant,42 + 80,Lezley,Mac Giany,lmacgiany27@scribd.com,Male,78 Mac Giany Alley,Operator,47 + 81,Tobiah,Durn,tdurn28@state.tx.us,Male,79 Durn Court,VP Sales,40 + 82,Sherlocke,Cockshoot,scockshoot29@yelp.com,Male,80 Cockshoot Street,Senior Financial Analyst,46 + 83,Myrle,Speenden,mspeenden2a@utexas.edu,Female,81 Speenden Center,Senior Developer,43 + 84,Isidore,Gorries,igorries2b@flavors.me,Male,82 Gorries Parkway,Sales Representative,48 + 85,Isac,Kitchingman,ikitchingman2c@businessinsider.com,Male,83 Kitchingman Drive,VP Accounting,41 + 86,Benedetta,Purrier,bpurrier2d@admin.ch,Female,84 Purrier Trail,VP Accounting,44 + 87,Tera,Fitchell,tfitchell2e@fotki.com,Female,85 Fitchell Place,Software Engineer IV,45 + 88,Abbe,Pamment,apamment2f@about.com,Male,86 Pamment Avenue,VP Sales,42 + 89,Jandy,Gommowe,jgommowe2g@angelfire.com,Female,87 Gommowe Road,Financial Analyst,47 + 90,Karena,Fussey,kfussey2h@google.com.au,Female,88 Fussey Point,Assistant Professor,40 + 91,Gaspar,Pammenter,gpammenter2i@google.com.br,Male,89 Pammenter Hill,Help Desk Operator,46 + 92,Stanwood,Mac Giany,smacgiany2j@prlog.org,Male,90 Mac Giany Terrace,Research Associate,43 + 93,Byrom,Beedell,bbeedell2k@google.co.jp,Male,91 Beedell Way,VP Sales,48 + 94,Annabella,Rowbottom,arowbottom2l@google.com.au,Female,92 Rowbottom Plaza,Help Desk Operator,41 + 95,Rodolphe,Debell,rdebell2m@imageshack.us,Male,93 Debell Pass,Design Engineer,44 + 96,Tyne,Gommey,tgommey2n@joomla.org,Female,94 Gommey Junction,VP Marketing,45 + 97,Christoper,Pincked,cpincked2o@icq.com,Male,95 Pincked Alley,Human Resources Manager,42 + 98,Kore,Le Prevost,kleprevost2p@tripadvisor.com,Female,96 Le Prevost Street,VP Quality Control,47 + 99,Ceciley,Petrolli,cpetrolli2q@oaic.gov.au,Female,97 Petrolli Court,Senior Developer,40 + 100,Elspeth,Mac Giany,emacgiany2r@icio.us,Female,98 Mac Giany Parkway,Internal Auditor,46 + """; + + /** + * Initializes the service after dependency injection by loading data from the CSV string. + * Uses @PostConstruct to ensure this runs after the bean is created. + */ + @PostConstruct + private void initializeData() { + log.info("Initializing PersonService data store..."); + int maxId = loadDataFromCsv(); + idGenerator = new AtomicInteger(maxId); + log.info("PersonService initialized with {} records. Next ID: {}", personStore.size(), idGenerator.get() + 1); + } + + /** + * Parses the embedded CSV data and populates the in-memory store. + * Calculates the maximum ID found in the data to initialize the ID generator. + * + * @return The maximum ID found in the loaded CSV data. + */ + private int loadDataFromCsv() { + final AtomicInteger currentMaxId = new AtomicInteger(0); + // Clear existing data before loading (important for tests or re-initialization scenarios) + personStore.clear(); + try (Stream lines = CSV_DATA.lines().skip(1)) { // Skip header row + lines.forEach(line -> { + try { + // Split carefully, handling potential commas within quoted fields if necessary (simple split here) + String[] fields = line.split(",", 8); // Limit split to handle potential commas in job title + if (fields.length == 8) { + int id = Integer.parseInt(fields[0].trim()); + String firstName = fields[1].trim(); + String lastName = fields[2].trim(); + String email = fields[3].trim(); + String sex = fields[4].trim(); + String ipAddress = fields[5].trim(); + String jobTitle = fields[6].trim(); + int age = Integer.parseInt(fields[7].trim()); + + Person person = new Person(id, firstName, lastName, email, sex, ipAddress, jobTitle, age); + personStore.put(id, person); + currentMaxId.updateAndGet(max -> Math.max(max, id)); + } else { + log.warn("Skipping malformed CSV line (expected 8 fields, found {}): {}", fields.length, line); + } + } catch (NumberFormatException e) { + log.warn("Skipping line due to parsing error (ID or Age): {} - Error: {}", line, e.getMessage()); + } catch (Exception e) { + log.error("Skipping line due to unexpected error: {} - Error: {}", line, e.getMessage(), e); + } + }); + } catch (Exception e) { + log.error("Fatal error reading embedded CSV data: {}", e.getMessage(), e); + // In a real application, might throw a specific initialization exception + } + return currentMaxId.get(); + } + + @Override + @Tool( + name = "ps_create_person", + description = "Create a new person record in the in-memory store." + ) + public Person createPerson(Person personData) { + if (personData == null) { + throw new IllegalArgumentException("Person data cannot be null"); + } + int newId = idGenerator.incrementAndGet(); + // Create a new Person record using data from the input, but with the generated ID + Person newPerson = new Person( + newId, + personData.firstName(), + personData.lastName(), + personData.email(), + personData.sex(), + personData.ipAddress(), + personData.jobTitle(), + personData.age() + ); + personStore.put(newId, newPerson); + log.debug("Created person: {}", newPerson); + return newPerson; + } + + @Override + @Tool( + name = "ps_get_person_by_id", + description = "Retrieve a person record by ID from the in-memory store." + ) + public Optional getPersonById(int id) { + Person person = personStore.get(id); + log.debug("Retrieved person by ID {}: {}", id, person); + return Optional.ofNullable(person); + } + + @Override + @Tool( + name = "ps_get_all_persons", + description = "Retrieve all person records from the in-memory store." + ) + public List getAllPersons() { + // Return an unmodifiable view of the values + List allPersons = personStore.values().stream().toList(); + log.debug("Retrieved all persons (count: {})", allPersons.size()); + return allPersons; + } + + @Override + @Tool( + name = "ps_update_person", + description = "Update an existing person record by ID in the in-memory store." + ) + public boolean updatePerson(int id, Person updatedPersonData) { + if (updatedPersonData == null) { + throw new IllegalArgumentException("Updated person data cannot be null"); + } + // Use computeIfPresent for atomic update if the key exists + Person result = personStore.computeIfPresent(id, (key, existingPerson) -> + // Create a new Person record with the original ID but updated data + new Person( + id, // Keep original ID + updatedPersonData.firstName(), + updatedPersonData.lastName(), + updatedPersonData.email(), + updatedPersonData.sex(), + updatedPersonData.ipAddress(), + updatedPersonData.jobTitle(), + updatedPersonData.age() + ) + ); + boolean updated = result != null; + log.debug("Update attempt for ID {}: {}", id, updated ? "Successful" : "Failed (Not Found)"); + if(updated) log.trace("Updated person data for ID {}: {}", id, result); + return updated; + } + + @Override + @Tool( + name = "ps_delete_person", + description = "Delete a person record by ID from the in-memory store." + ) + public boolean deletePerson(int id) { + boolean removed = personStore.remove(id) != null; + log.debug("Delete attempt for ID {}: {}", id, removed ? "Successful" : "Failed (Not Found)"); + return removed; + } + + @Override + @Tool( + name = "ps_search_by_job_title", + description = "Search for persons by job title in the in-memory store." + ) + public List searchByJobTitle(String jobTitleQuery) { + if (jobTitleQuery == null || jobTitleQuery.isBlank()) { + log.debug("Search by job title skipped due to blank query."); + return Collections.emptyList(); + } + String lowerCaseQuery = jobTitleQuery.toLowerCase(); + List results = personStore.values().stream() + .filter(person -> person.jobTitle() != null && person.jobTitle().toLowerCase().contains(lowerCaseQuery)) + .collect(Collectors.toList()); + log.debug("Search by job title '{}' found {} results.", jobTitleQuery, results.size()); + return Collections.unmodifiableList(results); + } + + @Override + @Tool( + name = "ps_filter_by_sex", + description = "Filters Persons by sex (case-insensitive)." + ) + public List filterBySex(String sex) { + if (sex == null || sex.isBlank()) { + log.debug("Filter by sex skipped due to blank filter."); + return Collections.emptyList(); + } + List results = personStore.values().stream() + .filter(person -> person.sex() != null && person.sex().equalsIgnoreCase(sex)) + .collect(Collectors.toList()); + log.debug("Filter by sex '{}' found {} results.", sex, results.size()); + return Collections.unmodifiableList(results); + } + + @Override + @Tool( + name = "ps_filter_by_age", + description = "Filters Persons by age." + ) + public List filterByAge(int age) { + if (age < 0) { + log.debug("Filter by age skipped due to negative age: {}", age); + return Collections.emptyList(); // Or throw IllegalArgumentException based on requirements + } + List results = personStore.values().stream() + .filter(person -> person.age() == age) + .collect(Collectors.toList()); + log.debug("Filter by age {} found {} results.", age, results.size()); + return Collections.unmodifiableList(results); + } + +} \ No newline at end of file diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java new file mode 100644 index 0000000000..44b53e1974 --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java @@ -0,0 +1,4 @@ +/** + * 参考 Tool Calling —— Methods as Tools + */ +package cn.iocoder.yudao.module.ai.tool.method; \ No newline at end of file diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java new file mode 100644 index 0000000000..9c3b202c4f --- /dev/null +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.ai.util; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.tika.Tika; + +/** + * 文件类型 Utils + * + * @author 芋道源码 + */ +@Slf4j +public class FileTypeUtils { + + private static final Tika TIKA = new Tika(); + + /** + * 已知文件名,获取文件类型,在某些情况下比通过字节数组准确,例如使用 jar 文件时,通过名字更为准确 + * + * @param name 文件名 + * @return mineType 无法识别时会返回“application/octet-stream” + */ + public static String getMineType(String name) { + return TIKA.detect(name); + } + + /** + * 判断是否是图片 + * + * @param mineType 类型 + * @return 是否是图片 + */ + public static boolean isImage(String mineType) { + return StrUtil.startWith(mineType, "image/"); + } + +} diff --git a/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java b/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java new file mode 100644 index 0000000000..454fad47b6 --- /dev/null +++ b/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link AnthropicChatModel} 集成测试类 + * + * @author 芋道源码 + */ +public class AnthropicChatModelTest { + + private final AnthropicChatModel chatModel = AnthropicChatModel.builder() + .anthropicApi(AnthropicApi.builder() + .apiKey("sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942") + .baseUrl("https://aihubmix.com") + .build()) + .defaultOptions(AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4) + .temperature(0.7) + .maxTokens(4096) + .build()) + .build(); + + @Test + @Disabled + public void testCall() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + ChatResponse response = chatModel.call(new Prompt(messages)); + // 打印结果 + System.out.println(response); + } + + @Test + @Disabled + public void testStream() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages)); + // 打印结果 + flux.doOnNext(System.out::println).then().block(); + } + + // TODO @芋艿:需要等 spring ai 升级:https://github.com/spring-projects/spring-ai/pull/2800 + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("thkinking 下,1+1 为什么等于 2 ")); + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4) + .thinking(AnthropicApi.ThinkingType.ENABLED, 3096) + .temperature(1D) + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult()); + }).then().block(); + } + +} diff --git a/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java b/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java new file mode 100644 index 0000000000..964a5f3c36 --- /dev/null +++ b/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; + +import cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini.GeminiChatModel; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link GeminiChatModel} 集成测试 + * + * @author 芋道源码 + */ +public class GeminiChatModelTests { + + private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(OpenAiApi.builder() + .baseUrl(GeminiChatModel.BASE_URL) + .completionsPath(GeminiChatModel.COMPLETE_PATH) + .apiKey("AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ") + .build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(GeminiChatModel.MODEL_DEFAULT) // 模型 + .temperature(0.7) + .build()) + .build(); + + private final GeminiChatModel chatModel = new GeminiChatModel(openAiChatModel); + + @Test + @Disabled + public void testCall() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + ChatResponse response = chatModel.call(new Prompt(messages)); + // 打印结果 + System.out.println(response); + } + + @Test + @Disabled + public void testStream() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages)); + // 打印结果 + flux.doOnNext(System.out::println).then().block(); + } + +} diff --git a/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java b/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java new file mode 100644 index 0000000000..0a02ab589d --- /dev/null +++ b/yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.websearch; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha.AiBoChaWebSearchClient; +import org.junit.jupiter.api.Test; + +/** + * {@link AiBoChaWebSearchClient} 集成测试类 + * + * @author 芋道源码 + */ +public class AiBoChaWebSearchClientTest { + + private final AiBoChaWebSearchClient webSearchClient = new AiBoChaWebSearchClient( + "sk-40500e52840f4d24b956d0b1d80d9abe"); + + @Test + public void testSearch() { + AiWebSearchRequest request = new AiWebSearchRequest() + .setQuery("阿里巴巴") + .setCount(3); + AiWebSearchResponse response = webSearchClient.search(request); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + +} \ No newline at end of file diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index e97d11bc4b..db914cc105 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -42,4 +42,14 @@ public interface FileApi { String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, String name, String directory, String type); + /** + * 生成文件预签名地址,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url, + Integer expirationSeconds); + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index 72c351129d..98bdba2a53 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -22,4 +22,9 @@ public class FileApiImpl implements FileApi { return fileService.createFile(content, name, directory, type); } + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + return fileService.presignGetUrl(url, expirationSeconds); + } + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java index d5611b7a06..f21e79a188 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java @@ -43,7 +43,7 @@ public class FileController { @PostMapping("/upload") @Operation(summary = "上传文件", description = "模式一:后端上传文件") - public CommonResult uploadFile(FileUploadReqVO uploadReqVO) throws Exception { + public CommonResult uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); byte[] content = IoUtil.readBytes(file.getInputStream()); return success(fileService.createFile(content, file.getOriginalFilename(), @@ -51,7 +51,7 @@ public class FileController { } @GetMapping("/presigned-url") - @Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") @Parameters({ @Parameter(name = "name", description = "文件名称", required = true), @Parameter(name = "directory", description = "文件目录") @@ -59,7 +59,7 @@ public class FileController { public CommonResult getFilePresignedUrl( @RequestParam("name") String name, @RequestParam(value = "directory", required = false) String directory) { - return success(fileService.getFilePresignedUrl(name, directory)); + return success(fileService.presignPutUrl(name, directory)); } @PostMapping("/create") diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java index 7f85e996d7..a4c1d202e8 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java @@ -41,7 +41,7 @@ public class AppFileController { } @GetMapping("/presigned-url") - @Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") @Parameters({ @Parameter(name = "name", description = "文件名称", required = true), @Parameter(name = "directory", description = "文件目录") @@ -49,7 +49,7 @@ public class AppFileController { public CommonResult getFilePresignedUrl( @RequestParam("name") String name, @RequestParam(value = "directory", required = false) String directory) { - return success(fileService.getFilePresignedUrl(name, directory)); + return success(fileService.presignPutUrl(name, directory)); } @PostMapping("/create") diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenColumnDO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenColumnDO.java index 44063acd1c..27283bffb2 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenColumnDO.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenColumnDO.java @@ -9,8 +9,6 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.generator.config.po.TableField; import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.experimental.Accessors; /** * 代码生成 column 字段定义 @@ -20,8 +18,6 @@ import lombok.experimental.Accessors; @TableName(value = "infra_codegen_column", autoResultMap = true) @KeySequence("infra_codegen_column_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 @Data -@Accessors(chain = true) -@EqualsAndHashCode(callSuper = true) @TenantIgnore public class CodegenColumnDO extends BaseDO { diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenTableDO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenTableDO.java index b46cbe4fef..5acab6fcc8 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenTableDO.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/codegen/CodegenTableDO.java @@ -11,8 +11,6 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.generator.config.po.TableInfo; import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.experimental.Accessors; /** * 代码生成 table 表定义 @@ -22,8 +20,6 @@ import lombok.experimental.Accessors; @TableName(value = "infra_codegen_table", autoResultMap = true) @KeySequence("infra_codegen_table_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 @Data -@Accessors(chain = true) -@EqualsAndHashCode(callSuper = true) @TenantIgnore public class CodegenTableDO extends BaseDO { diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java index 053b3c5101..cf1cd620ae 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java @@ -1,7 +1,5 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client; -import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; - /** * 文件客户端 * @@ -42,13 +40,26 @@ public interface FileClient { */ byte[] getContent(String path) throws Exception; + // ========== 文件签名,目前仅 S3 支持 ========== + /** - * 获得文件预签名地址 + * 获得文件预签名地址,用于上传 * * @param path 相对路径 * @return 文件预签名地址 */ - default FilePresignedUrlRespDTO getPresignedObjectUrl(String path) throws Exception { + default String presignPutUrl(String path) { + throw new UnsupportedOperationException("不支持的操作"); + } + + /** + * 生成文件预签名地址,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + default String presignGetUrl(String url, Integer expirationSeconds) { throw new UnsupportedOperationException("不支持的操作"); } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java index 7fa2a7ea9a..6e5c0229ba 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.local; import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IORuntimeException; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import java.io.File; @@ -38,7 +39,14 @@ public class LocalFileClient extends AbstractFileClient { @Override public byte[] getContent(String path) { String filePath = getFilePath(path); - return FileUtil.readBytes(filePath); + try { + return FileUtil.readBytes(filePath); + } catch (IORuntimeException ex) { + if (ex.getMessage().startsWith("File not exist:")) { + return null; + } + throw ex; + } } private String getFilePath(String path) { diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java deleted file mode 100644 index 6a1258e9e0..0000000000 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java +++ /dev/null @@ -1,29 +0,0 @@ -package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 文件预签名地址 Response DTO - * - * @author owen - */ -@Data -@AllArgsConstructor -@NoArgsConstructor -public class FilePresignedUrlRespDTO { - - /** - * 文件上传 URL(用于上传) - * - * 例如说: - */ - private String uploadUrl; - - /** - * 文件 URL(用于读取、下载等) - */ - private String url; - -} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index a33f0d738c..94ba6a3ebb 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -1,8 +1,10 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpUtil; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -15,9 +17,11 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.net.URI; +import java.net.URL; import java.time.Duration; /** @@ -27,6 +31,8 @@ import java.time.Duration; */ public class S3FileClient extends AbstractFileClient { + private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); + private S3Client client; private S3Presigner presigner; @@ -75,7 +81,7 @@ public class S3FileClient extends AbstractFileClient { // 上传文件 client.putObject(putRequest, RequestBody.fromBytes(content)); // 拼接返回路径 - return config.getDomain() + "/" + path; + return presignGetUrl(path, null); } @Override @@ -97,23 +103,33 @@ public class S3FileClient extends AbstractFileClient { } @Override - public FilePresignedUrlRespDTO getPresignedObjectUrl(String path) { - Duration expiration = Duration.ofHours(24); - return new FilePresignedUrlRespDTO(getPresignedUrl(path, expiration), config.getDomain() + "/" + path); + public String presignPutUrl(String path) { + return presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(EXPIRATION_DEFAULT) + .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build()) + .url().toString(); } - /** - * 生成动态的预签名上传 URL - * - * @param path 相对路径 - * @param expiration 过期时间 - * @return 生成的上传 URL - */ - private String getPresignedUrl(String path, Duration expiration) { - return presigner.presignPutObject(PutObjectPresignRequest.builder() + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + // 1. 将 url 转换为 path + String path = StrUtil.removePrefix(url, config.getDomain() + "/"); + path = HttpUtils.removeUrlQuery(path); + + // 2.1 情况一:公开访问:无需签名 + // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 + if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { + return config.getDomain() + "/" + path; + } + + // 2.2 情况二:私有访问:生成 GET 预签名 URL + String finalPath = path; + Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; + URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() .signatureDuration(expiration) - .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)) - .build()).url().toString(); + .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build()) + .url(); + return signedUrl.toString(); } /** diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java index fb19317e02..216197964a 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java @@ -73,6 +73,15 @@ public class S3FileClientConfig implements FileClientConfig { @NotNull(message = "enablePathStyleAccess 不能为空") private Boolean enablePathStyleAccess; + /** + * 是否公开访问 + * + * true:公开访问,所有人都可以访问 + * false:私有访问,只有配置的 accessKey 才可以访问 + */ + @NotNull(message = "是否公开访问不能为空") + private Boolean enablePublicAccess; + @SuppressWarnings("RedundantIfStatement") @AssertTrue(message = "domain 不能为空") @JsonIgnore diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java index 9cc4175884..28d6a9fa16 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java @@ -80,11 +80,17 @@ public class FileTypeUtils { */ public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { // 设置 header 和 contentType - response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename)); - String contentType = getMineType(content, filename); - response.setContentType(contentType); + String mineType = getMineType(content, filename); + response.setContentType(mineType); + // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html + if (isImage(mineType)) { + // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 + response.setHeader("Content-Disposition", "inline;filename=" + HttpUtils.encodeUtf8(filename)); + } else { + response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename)); + } // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 - if (StrUtil.containsIgnoreCase(contentType, "video")) { + if (StrUtil.containsIgnoreCase(mineType, "video")) { response.setHeader("Content-Length", String.valueOf(content.length)); response.setHeader("Content-Range", "bytes 0-" + (content.length - 1) + "/" + content.length); response.setHeader("Accept-Ranges", "bytes"); @@ -93,4 +99,14 @@ public class FileTypeUtils { IoUtil.write(response.getOutputStream(), false, content); } + /** + * 判断是否是图片 + * + * @param mineType 类型 + * @return 是否是图片 + */ + public static boolean isImage(String mineType) { + return StrUtil.startWith(mineType, "image/"); + } + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java index 5b15ad8739..5e3448b0fe 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java @@ -37,14 +37,22 @@ public interface FileService { String name, String directory, String type); /** - * 生成文件预签名地址信息 + * 生成文件预签名地址信息,用于上传 * * @param name 文件名 * @param directory 目录 * @return 预签名地址信息 */ - FilePresignedUrlRespVO getFilePresignedUrl(@NotEmpty(message = "文件名不能为空") String name, - String directory); + FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name, + String directory); + /** + * 生成文件预签名地址信息,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + String presignGetUrl(String url, Integer expirationSeconds); /** * 创建文件 diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 98447fb370..f47275d33c 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -6,6 +6,7 @@ import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; @@ -13,7 +14,6 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresigned import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; -import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; @@ -126,19 +126,27 @@ public class FileServiceImpl implements FileService { @Override @SneakyThrows - public FilePresignedUrlRespVO getFilePresignedUrl(String name, String directory) { + public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { // 1. 生成上传的 path,需要保证唯一 String path = generateUploadPath(name, directory); // 2. 获取文件预签名地址 FileClient fileClient = fileConfigService.getMasterFileClient(); - FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path); - return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class, - object -> object.setConfigId(fileClient.getId()).setPath(path)); + String uploadUrl = fileClient.presignPutUrl(path); + String visitUrl = fileClient.presignGetUrl(path, null); + return new FilePresignedUrlRespVO().setConfigId(fileClient.getId()) + .setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl); + } + + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + FileClient fileClient = fileConfigService.getMasterFileClient(); + return fileClient.presignGetUrl(url, expirationSeconds); } @Override public Long createFile(FileCreateReqVO createReqVO) { + createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); fileMapper.insert(file); return file.getId(); diff --git a/yudao-module-infra/src/main/resources/codegen/java/test/serviceTest.vm b/yudao-module-infra/src/main/resources/codegen/java/test/serviceTest.vm index bfd4600f5e..974e8e8404 100644 --- a/yudao-module-infra/src/main/resources/codegen/java/test/serviceTest.vm +++ b/yudao-module-infra/src/main/resources/codegen/java/test/serviceTest.vm @@ -2,7 +2,6 @@ package ${basePackage}.module.${table.moduleName}.service.${table.businessName}; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; import ${jakartaPackage}.annotation.Resource; diff --git a/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm index e1305586cc..3f290ccff8 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm @@ -170,6 +170,7 @@ await this.#[[$modal]]#.confirm('是否确认删除?') try { await ${simpleClassName}Api.delete${subSimpleClassName}List(this.checkedIds); + this.checkedIds = []; await this.getList(); this.#[[$modal]]#.msgSuccess("删除成功"); } catch {} diff --git a/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm index 30014a8ff4..bbc913114a 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm @@ -338,6 +338,7 @@ export default { await this.#[[$modal]]#.confirm('是否确认删除?') try { await ${simpleClassName}Api.delete${simpleClassName}List(this.checkedIds); + this.checkedIds = []; await this.getList(); this.#[[$modal]]#.msgSuccess("删除成功"); } catch {} diff --git a/yudao-module-infra/src/main/resources/codegen/vue3/views/components/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3/views/components/list_sub_erp.vue.vm index f9fbb9787b..a94cab5a59 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3/views/components/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3/views/components/list_sub_erp.vue.vm @@ -209,6 +209,7 @@ const handleDeleteBatch = async () => { // 删除的二次确认 await message.delConfirm() await ${simpleClassName}Api.delete${subSimpleClassName}List(checkedIds.value); + checkedIds.value = []; message.success(t('common.delSuccess')) await getList(); } catch {} diff --git a/yudao-module-infra/src/main/resources/codegen/vue3/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3/views/index.vue.vm index 851bc2b5e4..dfb97804ce 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3/views/index.vue.vm @@ -366,6 +366,7 @@ const handleDeleteBatch = async () => { // 删除的二次确认 await message.delConfirm() await ${simpleClassName}Api.delete${simpleClassName}List(checkedIds.value); + checkedIds.value = []; message.success(t('common.delSuccess')) await getList(); } catch {} diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/index.vue.vm index bb743305f8..6553ed0c87 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/index.vue.vm @@ -168,6 +168,7 @@ async function handleDeleteBatch() { }); try { await delete${simpleClassName}List(checkedIds.value); + checkedIds.value = []; message.success( $t('ui.actionMessage.deleteSuccess') ); await getList(); } finally { diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/modules/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/modules/list_sub_erp.vue.vm index cfd85589c9..999257d91d 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/modules/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/modules/list_sub_erp.vue.vm @@ -92,6 +92,7 @@ async function handleDeleteBatch() { }); try { await delete${subSimpleClassName}List(checkedIds.value); + checkedIds.value = []; message.success( $t('ui.actionMessage.deleteSuccess') ); await getList(); } finally { diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/index.vue.vm index 635e12ac24..1e13de2e97 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/index.vue.vm @@ -102,6 +102,7 @@ async function handleDeleteBatch() { }); try { await delete${simpleClassName}List(checkedIds.value); + checkedIds.value = []; message.success({ content: $t('ui.actionMessage.deleteSuccess'), key: 'action_key_msg', diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/modules/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/modules/list_sub_erp.vue.vm index 4001ed3992..e046226efc 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/modules/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/schema/views/modules/list_sub_erp.vue.vm @@ -82,6 +82,7 @@ async function handleDeleteBatch() { }); try { await delete${subSimpleClassName}List(checkedIds.value); + checkedIds.value = []; message.success({ content: $t('ui.actionMessage.deleteSuccess', [row.id]), key: 'action_key_msg', diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/index.vue.vm index 9897ba6773..ae77cd4c7b 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/index.vue.vm @@ -163,6 +163,7 @@ async function handleDeleteBatch() { }); try { await delete${simpleClassName}List(checkedIds.value); + checkedIds.value = []; ElMessage.success($t('ui.actionMessage.deleteSuccess')); await getList(); } finally { diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/modules/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/modules/list_sub_erp.vue.vm index e27965e4c1..ccad79a0d9 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/modules/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/general/views/modules/list_sub_erp.vue.vm @@ -87,6 +87,7 @@ async function handleDeleteBatch() { }); try { await delete${subSimpleClassName}List(checkedIds.value); + checkedIds.value = []; ElMessage.success($t('ui.actionMessage.deleteSuccess')); await getList(); } finally { diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/index.vue.vm index f9232d6b50..c29beb9aa9 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/index.vue.vm @@ -99,6 +99,7 @@ async function handleDeleteBatch() { }); try { await delete${simpleClassName}List(checkedIds.value); + checkedIds.value = []; ElMessage.success($t('ui.actionMessage.deleteSuccess')); onRefresh(); } finally { diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/modules/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/modules/list_sub_erp.vue.vm index 5afb9c7a0d..13a2415efd 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/modules/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_ele/schema/views/modules/list_sub_erp.vue.vm @@ -79,6 +79,7 @@ async function handleDeleteBatch() { }); try { await delete${subSimpleClassName}List(checkedIds.value); + checkedIds.value = []; ElMessage.success($t('ui.actionMessage.deleteSuccess')); onRefresh(); } finally { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java new file mode 100644 index 0000000000..eb55b1852a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.iot.api.device; + +import cn.iocoder.yudao.framework.common.enums.RpcConstants; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import org.springframework.context.annotation.Primary; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * IoT 设备 API 实现类 + * + * @author haohao + */ +@RestController +@Validated +@Primary // 保证优先匹配,因为 yudao-iot-gateway 也有 IotDeviceCommonApi 的实现,并且也可能会被 biz 引入 +public class IoTDeviceApiImpl implements IotDeviceCommonApi { + + @Resource + private IotDeviceService deviceService; + @Resource + private IotProductService productService; + + @Override + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/auth") + @PermitAll + public CommonResult authDevice(@RequestBody IotDeviceAuthReqDTO authReqDTO) { + return success(deviceService.authDevice(authReqDTO)); + } + + @Override + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/get") // 特殊:方便调用,暂时使用 POST,实际更推荐 GET + @PermitAll + public CommonResult getDevice(@RequestBody IotDeviceGetReqDTO getReqDTO) { + IotDeviceDO device = getReqDTO.getId() != null ? deviceService.getDeviceFromCache(getReqDTO.getId()) + : deviceService.getDeviceFromCache(getReqDTO.getProductKey(), getReqDTO.getDeviceName()); + return success(BeanUtils.toBean(device, IotDeviceRespDTO.class, deviceDTO -> { + IotProductDO product = productService.getProductFromCache(deviceDTO.getProductId()); + if (product != null) { + deviceDTO.setCodecType(product.getCodecType()); + } + })); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/IotAlertConfigController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/IotAlertConfigController.java new file mode 100644 index 0000000000..b6d225f6df --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/IotAlertConfigController.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertConfigService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; + +@Tag(name = "管理后台 - IoT 告警配置") +@RestController +@RequestMapping("/iot/alert-config") +@Validated +public class IotAlertConfigController { + + @Resource + private IotAlertConfigService alertConfigService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建告警配置") + @PreAuthorize("@ss.hasPermission('iot:alert-config:create')") + public CommonResult createAlertConfig(@Valid @RequestBody IotAlertConfigSaveReqVO createReqVO) { + return success(alertConfigService.createAlertConfig(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新告警配置") + @PreAuthorize("@ss.hasPermission('iot:alert-config:update')") + public CommonResult updateAlertConfig(@Valid @RequestBody IotAlertConfigSaveReqVO updateReqVO) { + alertConfigService.updateAlertConfig(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除告警配置") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('iot:alert-config:delete')") + public CommonResult deleteAlertConfig(@RequestParam("id") Long id) { + alertConfigService.deleteAlertConfig(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得告警配置") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:alert-config:query')") + public CommonResult getAlertConfig(@RequestParam("id") Long id) { + IotAlertConfigDO alertConfig = alertConfigService.getAlertConfig(id); + return success(BeanUtils.toBean(alertConfig, IotAlertConfigRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得告警配置分页") + @PreAuthorize("@ss.hasPermission('iot:alert-config:query')") + public CommonResult> getAlertConfigPage(@Valid IotAlertConfigPageReqVO pageReqVO) { + PageResult pageResult = alertConfigService.getAlertConfigPage(pageReqVO); + + // 转换返回 + Map userMap = adminUserApi.getUserMap( + convertSetByFlatMap(pageResult.getList(), config -> config.getReceiveUserIds().stream())); + return success(BeanUtils.toBean(pageResult, IotAlertConfigRespVO.class, vo -> { + vo.setReceiveUserNames(vo.getReceiveUserIds().stream() + .map(userMap::get) + .filter(Objects::nonNull) + .map(AdminUserRespDTO::getNickname) + .collect(Collectors.toList())); + })); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得告警配置简单列表", description = "只包含被开启的告警配置,主要用于前端的下拉选项") + @PreAuthorize("@ss.hasPermission('iot:alert-config:query')") + public CommonResult> getAlertConfigSimpleList() { + List list = alertConfigService.getAlertConfigListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, config -> // 只返回 id、name 字段 + new IotAlertConfigRespVO().setId(config.getId()).setName(config.getName()))); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/IotAlertRecordController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/IotAlertRecordController.java new file mode 100644 index 0000000000..91f15b989c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/IotAlertRecordController.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordProcessReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordRespVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static java.util.Collections.singleton; + +@Tag(name = "管理后台 - IoT 告警记录") +@RestController +@RequestMapping("/iot/alert-record") +@Validated +public class IotAlertRecordController { + + @Resource + private IotAlertRecordService alertRecordService; + + @GetMapping("/get") + @Operation(summary = "获得告警记录") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:alert-record:query')") + public CommonResult getAlertRecord(@RequestParam("id") Long id) { + IotAlertRecordDO alertRecord = alertRecordService.getAlertRecord(id); + return success(BeanUtils.toBean(alertRecord, IotAlertRecordRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得告警记录分页") + @PreAuthorize("@ss.hasPermission('iot:alert-record:query')") + public CommonResult> getAlertRecordPage(@Valid IotAlertRecordPageReqVO pageReqVO) { + PageResult pageResult = alertRecordService.getAlertRecordPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotAlertRecordRespVO.class)); + } + + @PutMapping("/process") + @Operation(summary = "处理告警记录") + @PreAuthorize("@ss.hasPermission('iot:alert-record:process')") + public CommonResult processAlertRecord(@Valid @RequestBody IotAlertRecordProcessReqVO processReqVO) { + alertRecordService.processAlertRecordList(singleton(processReqVO.getId()), processReqVO.getProcessRemark()); + return success(true); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigPageReqVO.java new file mode 100644 index 0000000000..0f9a1e9ce1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IoT 告警配置分页 Request VO") +@Data +public class IotAlertConfigPageReqVO extends PageParam { + + @Schema(description = "配置名称", example = "赵六") + private String name; + + @Schema(description = "配置状态", example = "1") + private Integer status; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigRespVO.java new file mode 100644 index 0000000000..e68a7b7851 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - IoT 告警配置 Response VO") +@Data +public class IotAlertConfigRespVO { + + @Schema(description = "配置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3566") + private Long id; + + @Schema(description = "配置名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + private String name; + + @Schema(description = "配置描述", example = "你猜") + private String description; + + @Schema(description = "告警级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer level; + + @Schema(description = "配置状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "关联的场景联动规则编号数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3") + private List sceneRuleIds; + + @Schema(description = "接收的用户编号数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "100,200") + private List receiveUserIds; + + @Schema(description = "接收的用户名称数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三,李四") + private List receiveUserNames; + + @Schema(description = "接收的类型数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3") + private List receiveTypes; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigSaveReqVO.java new file mode 100644 index 0000000000..694e8bfdf7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/config/IotAlertConfigSaveReqVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IoT 告警配置新增/修改 Request VO") +@Data +public class IotAlertConfigSaveReqVO { + + @Schema(description = "配置编号", example = "3566") + private Long id; + + @Schema(description = "配置名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + @NotEmpty(message = "配置名称不能为空") + private String name; + + @Schema(description = "配置描述", example = "你猜") + private String description; + + @Schema(description = "告警级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "告警级别不能为空") + private Integer level; + + @Schema(description = "配置状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "配置状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "关联的场景联动规则编号数组") + @NotEmpty(message = "关联的场景联动规则编号数组不能为空") + private List sceneRuleIds; + + @Schema(description = "接收的用户编号数组") + @NotEmpty(message = "接收的用户编号数组不能为空") + private List receiveUserIds; + + @Schema(description = "接收的类型数组") + @NotEmpty(message = "接收的类型数组不能为空") + private List receiveTypes; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordPageReqVO.java new file mode 100644 index 0000000000..109f240917 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordPageReqVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IoT 告警记录分页 Request VO") +@Data +public class IotAlertRecordPageReqVO extends PageParam { + + @Schema(description = "告警配置编号", example = "29320") + private Long configId; + + @Schema(description = "告警级别", example = "1") + private Integer level; + + @Schema(description = "产品编号", example = "2050") + private Long productId; + + @Schema(description = "设备编号", example = "21727") + private String deviceId; + + @Schema(description = "是否处理", example = "true") + private Boolean processStatus; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordProcessReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordProcessReqVO.java new file mode 100644 index 0000000000..b64f66c5b9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordProcessReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 告警记录处理 Request VO") +@Data +public class IotAlertRecordProcessReqVO { + + @Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "记录编号不能为空") + private Long id; + + @Schema(description = "处理结果(备注)", requiredMode = Schema.RequiredMode.REQUIRED, example = "已处理告警,问题已解决") + private String processRemark; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordRespVO.java new file mode 100644 index 0000000000..97ccf6cca4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/alert/vo/recrod/IotAlertRecordRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT 告警记录 Response VO") +@Data +public class IotAlertRecordRespVO { + + @Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "19904") + private Long id; + + @Schema(description = "告警配置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "29320") + private Long configId; + + @Schema(description = "告警名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + private String configName; + + @Schema(description = "告警级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer configLevel; + + @Schema(description = "产品编号", example = "2050") + private Long productId; + + @Schema(description = "设备编号", example = "21727") + private Long deviceId; + + @Schema(description = "触发的设备消息") + private IotDeviceMessage deviceMessage; + + @Schema(description = "是否处理", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Boolean processStatus; + + @Schema(description = "处理结果(备注)", example = "你说的对") + private String processRemark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.http b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.http new file mode 100644 index 0000000000..93c86e146b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.http @@ -0,0 +1,101 @@ +### 请求 /iot/device/message/send 接口(属性上报)=> 成功 +POST {{baseUrl}}/iot/device/message/send +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +{ + "deviceId": 25, + "method": "thing.property.post", + "params": { + "width": 1, + "height": "2", + "oneThree": "3" + } +} + +### 请求 /iot/device/downstream 接口(服务调用)=> 成功 TODO 芋艿:未更新为最新 +POST {{baseUrl}}/iot/device/downstream +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +{ + "id": 25, + "type": "service", + "identifier": "temperature", + "data": { + "xx": "yy" + } +} + +### 请求 /iot/device/downstream 接口(属性设置)=> 成功 TODO 芋艿:未更新为最新 +POST {{baseUrl}}/iot/device/downstream +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +{ + "id": 25, + "type": "property", + "identifier": "set", + "data": { + "xx": "yy" + } +} + +### 请求 /iot/device/downstream 接口(属性获取)=> 成功 TODO 芋艿:未更新为最新 +POST {{baseUrl}}/iot/device/downstream +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +{ + "id": 25, + "type": "property", + "identifier": "get", + "data": ["xx", "yy"] +} + +### 请求 /iot/device/downstream 接口(配置设置)=> 成功 TODO 芋艿:未更新为最新 +POST {{baseUrl}}/iot/device/downstream +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +{ + "id": 25, + "type": "config", + "identifier": "set" +} + +### 请求 /iot/device/downstream 接口(OTA 升级)=> 成功 TODO 芋艿:未更新为最新 +POST {{baseUrl}}/iot/device/downstream +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +{ + "id": 25, + "type": "ota", + "identifier": "upgrade", + "data": { + "firmwareId": 1, + "version": "1.0.0", + "signMethod": "MD5", + "fileSign": "d41d8cd98f00b204e9800998ecf8427e", + "fileSize": 1024, + "fileUrl": "http://example.com/firmware.bin", + "information": "{\"desc\":\"升级到最新版本\"}" + } +} + +### 查询设备消息对分页 - 基础查询(设备编号25) +GET {{baseUrl}}/iot/device/message/pair-page?deviceId=25&pageNo=1&pageSize=10 +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} + +### 查询设备消息对分页 - 按标识符过滤(identifier=eat) +GET {{baseUrl}}/iot/device/message/pair-page?deviceId=25&identifier=eat&pageNo=1&pageSize=10 +Authorization: Bearer {{token}} +tenant-id: {{adminTenantId}} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java new file mode 100644 index 0000000000..8e9d148c9c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespPairVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageSendReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +@Tag(name = "管理后台 - IoT 设备消息") +@RestController +@RequestMapping("/iot/device/message") +@Validated +public class IotDeviceMessageController { + + @Resource + private IotDeviceMessageService deviceMessageService; + @Resource + private IotDeviceService deviceService; + @Resource + private IotThingModelService thingModelService; + @Resource + private IotDeviceMessageMapper deviceMessageMapper; + + @GetMapping("/page") + @Operation(summary = "获得设备消息分页") + @PreAuthorize("@ss.hasPermission('iot:device:message-query')") + public CommonResult> getDeviceMessagePage( + @Valid IotDeviceMessagePageReqVO pageReqVO) { + PageResult pageResult = deviceMessageService.getDeviceMessagePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotDeviceMessageRespVO.class)); + } + + @GetMapping("/pair-page") + @Operation(summary = "获得设备消息对分页") + @PreAuthorize("@ss.hasPermission('iot:device:message-query')") + public CommonResult> getDeviceMessagePairPage( + @Valid IotDeviceMessagePageReqVO pageReqVO) { + // 1.1 先按照条件,查询 request 的消息(非 reply) + pageReqVO.setReply(false); + PageResult requestMessagePageResult = deviceMessageService.getDeviceMessagePage(pageReqVO); + if (CollUtil.isEmpty(requestMessagePageResult.getList())) { + return success(PageResult.empty()); + } + // 1.2 接着按照 requestIds,批量查询 reply 消息 + List requestIds = convertList(requestMessagePageResult.getList(), IotDeviceMessageDO::getRequestId); + List replyMessageList = deviceMessageService.getDeviceMessageListByRequestIdsAndReply( + pageReqVO.getDeviceId(), requestIds, true); + Map replyMessages = convertMap(replyMessageList, IotDeviceMessageDO::getRequestId); + + // 2. 组装结果 + List pairMessages = convertList(requestMessagePageResult.getList(), + requestMessage -> { + IotDeviceMessageDO replyMessage = replyMessages.get(requestMessage.getRequestId()); + return new IotDeviceMessageRespPairVO() + .setRequest(BeanUtils.toBean(requestMessage, IotDeviceMessageRespVO.class)) + .setReply(BeanUtils.toBean(replyMessage, IotDeviceMessageRespVO.class)); + }); + return success(new PageResult<>(pairMessages, requestMessagePageResult.getTotal())); + } + + @PostMapping("/send") + @Operation(summary = "发送消息", description = "可用于设备模拟") + @PreAuthorize("@ss.hasPermission('iot:device:message-end')") + public CommonResult sendDeviceMessage(@Valid @RequestBody IotDeviceMessageSendReqVO sendReqVO) { + deviceMessageService.sendDeviceMessage(BeanUtils.toBean(sendReqVO, IotDeviceMessage.class)); + return success(true); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceAuthInfoRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceAuthInfoRespVO.java new file mode 100644 index 0000000000..acd65ad800 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceAuthInfoRespVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 设备认证信息 Response VO") +@Data +public class IotDeviceAuthInfoRespVO { + + @Schema(description = "客户端 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "product123.device001") + @NotBlank(message = "客户端 ID 不能为空") + private String clientId; + + @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "device001&product123") + @NotBlank(message = "用户名不能为空") + private String username; + + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1a2b3c4d5e6f7890abcdef1234567890") + @NotBlank(message = "密码不能为空") + private String password; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceByProductKeyAndNamesReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceByProductKeyAndNamesReqVO.java new file mode 100644 index 0000000000..e617cad935 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceByProductKeyAndNamesReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 通过产品标识和设备名称列表获取设备 Request VO") +@Data +public class IotDeviceByProductKeyAndNamesReqVO { + + @Schema(description = "产品标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "1de24640dfe") + @NotBlank(message = "产品标识不能为空") + private String productKey; + + @Schema(description = "设备名称列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "device001,device002") + @NotEmpty(message = "设备名称列表不能为空") + private List deviceNames; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java new file mode 100644 index 0000000000..1894dc9d7e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.message; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IoT 设备消息分页查询 Request VO") +@Data +public class IotDeviceMessagePageReqVO extends PageParam { + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "设备编号不能为空") + private Long deviceId; + + @Schema(description = "消息类型", example = "property") + @InEnum(IotDeviceMessageMethodEnum.class) + private String method; + + @Schema(description = "是否上行", example = "true") + private Boolean upstream; + + @Schema(description = "是否回复", example = "true") + private Boolean reply; + + @Schema(description = "标识符", example = "temperature") + private String identifier; + + @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @Size(min = 2, max = 2, message = "请选择时间范围") + private LocalDateTime[] times; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespPairVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespPairVO.java new file mode 100644 index 0000000000..119dd02777 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespPairVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 设备消息对 Response VO") +@Data +public class IotDeviceMessageRespPairVO { + + @Schema(description = "请求消息", requiredMode = Schema.RequiredMode.REQUIRED) + private IotDeviceMessageRespVO request; + + @Schema(description = "响应消息") + private IotDeviceMessageRespVO reply; // 通过 requestId 配对 + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java new file mode 100644 index 0000000000..e53f5acb60 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT 设备消息 Response VO") +@Data +public class IotDeviceMessageRespVO { + + @Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private String id; + + @Schema(description = "上报时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime reportTime; + + @Schema(description = "记录时间戳", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime ts; + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "123") + private Long deviceId; + + @Schema(description = "服务编号", example = "server_123") + private String serverId; + + @Schema(description = "是否上行消息", example = "true", examples = "false") + private Boolean upstream; + + @Schema(description = "是否回复消息", example = "false", examples = "true") + private Boolean reply; + + @Schema(description = "标识符", example = "temperature") + private String identifier; + + // ========== codec(编解码)字段 ========== + + @Schema(description = "请求编号", example = "req_123") + private String requestId; + + @Schema(description = "请求方法", requiredMode = Schema.RequiredMode.REQUIRED, example = "thing.property.report") + private String method; + + @Schema(description = "请求参数") + private Object params; + + @Schema(description = "响应结果") + private Object data; + + @Schema(description = "响应错误码", example = "200") + private Integer code; + + @Schema(description = "响应提示", example = "操作成功") + private String msg; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageSendReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageSendReqVO.java new file mode 100644 index 0000000000..e93cabbd93 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageSendReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.message; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 设备消息发送 Request VO") // 属性上报、事件上报、状态变更等 +@Data +public class IotDeviceMessageSendReqVO { + + @Schema(description = "请求方法", requiredMode = Schema.RequiredMode.REQUIRED, example = "report") + @NotEmpty(message = "请求方法不能为空") + @InEnum(IotDeviceMessageMethodEnum.class) + private String method; + + @Schema(description = "请求参数") + private Object params; // 例如说:属性上报的 properties、事件上报的 params + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "177") + @NotNull(message = "设备编号不能为空") + private Long deviceId; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/property/IotDevicePropertyDetailRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/property/IotDevicePropertyDetailRespVO.java new file mode 100644 index 0000000000..57712691f8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/property/IotDevicePropertyDetailRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.property; + +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDataSpecs; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IoT 设备属性详细 Response VO") // 额外增加 来自 ThingModelProperty 的变量 属性 +@Data +public class IotDevicePropertyDetailRespVO extends IotDevicePropertyRespVO { + + @Schema(description = "属性名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "数据类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "int") + private String dataType; + + @Schema(description = "数据定义") + private ThingModelDataSpecs dataSpecs; + + @Schema(description = "数据定义列表") + private List dataSpecsList; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/IotOtaTaskController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/IotOtaTaskController.java new file mode 100644 index 0000000000..807f96993b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/IotOtaTaskController.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.iot.controller.admin.ota; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskCreateReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskDO; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IoT OTA 升级任务") +@RestController +@RequestMapping("/iot/ota/task") +@Validated +public class IotOtaTaskController { + + @Resource + private IotOtaTaskService otaTaskService; + + @PostMapping("/create") + @Operation(summary = "创建 OTA 升级任务") + @PreAuthorize(value = "@ss.hasPermission('iot:ota-task:create')") + public CommonResult createOtaTask(@Valid @RequestBody IotOtaTaskCreateReqVO createReqVO) { + return success(otaTaskService.createOtaTask(createReqVO)); + } + + @PostMapping("/cancel") + @Operation(summary = "取消 OTA 升级任务") + @Parameter(name = "id", description = "升级任务编号", required = true) + @PreAuthorize(value = "@ss.hasPermission('iot:ota-task:cancel')") + public CommonResult cancelOtaTask(@RequestParam("id") Long id) { + otaTaskService.cancelOtaTask(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得 OTA 升级任务分页") + @PreAuthorize(value = "@ss.hasPermission('iot:ota-task:query')") + public CommonResult> getOtaTaskPage(@Valid IotOtaTaskPageReqVO pageReqVO) { + PageResult pageResult = otaTaskService.getOtaTaskPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotOtaTaskRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得 OTA 升级任务") + @Parameter(name = "id", description = "升级任务编号", required = true, example = "1024") + @PreAuthorize(value = "@ss.hasPermission('iot:ota-task:query')") + public CommonResult getOtaTask(@RequestParam("id") Long id) { + IotOtaTaskDO upgradeTask = otaTaskService.getOtaTask(id); + return success(BeanUtils.toBean(upgradeTask, IotOtaTaskRespVO.class)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/IotOtaTaskRecordController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/IotOtaTaskRecordController.java new file mode 100644 index 0000000000..3f50cfab25 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/IotOtaTaskRecordController.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.iot.controller.admin.ota; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record.IotOtaTaskRecordPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record.IotOtaTaskRecordRespVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaFirmwareService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IoT OTA 升级任务记录") +@RestController +@RequestMapping("/iot/ota/task/record") +@Validated +public class IotOtaTaskRecordController { + + @Resource + private IotOtaTaskRecordService otaTaskRecordService; + @Resource + private IotDeviceService deviceService; + @Resource + private IotOtaFirmwareService otaFirmwareService; + + @GetMapping("/get-status-statistics") + @Operation(summary = "获得 OTA 升级记录状态统计") + @Parameters({ + @Parameter(name = "firmwareId", description = "固件编号", example = "1024"), + @Parameter(name = "taskId", description = "升级任务编号", example = "2048") + }) + @PreAuthorize("@ss.hasPermission('iot:ota-task-record:query')") + public CommonResult> getOtaTaskRecordStatusStatistics( + @RequestParam(value = "firmwareId", required = false) Long firmwareId, + @RequestParam(value = "taskId", required = false) Long taskId) { + return success(otaTaskRecordService.getOtaTaskRecordStatusStatistics(firmwareId, taskId)); + } + + @GetMapping("/page") + @Operation(summary = "获得 OTA 升级记录分页") + @PreAuthorize("@ss.hasPermission('iot:ota-task-record:query')") + public CommonResult> getOtaTaskRecordPage( + @Valid IotOtaTaskRecordPageReqVO pageReqVO) { + PageResult pageResult = otaTaskRecordService.getOtaTaskRecordPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty()); + } + + // 批量查询固件信息 + Map firmwareMap = otaFirmwareService.getOtaFirmwareMap( + convertSet(pageResult.getList(), IotOtaTaskRecordDO::getFromFirmwareId)); + Map deviceMap = deviceService.getDeviceMap( + convertSet(pageResult.getList(), IotOtaTaskRecordDO::getDeviceId)); + // 转换为响应 VO + return success(BeanUtils.toBean(pageResult, IotOtaTaskRecordRespVO.class, (vo) -> { + MapUtils.findAndThen(firmwareMap, vo.getFromFirmwareId(), firmware -> + vo.setFromFirmwareVersion(firmware.getVersion())); + MapUtils.findAndThen(deviceMap, vo.getDeviceId(), device -> + vo.setDeviceName(device.getDeviceName())); + })); + } + + @GetMapping("/get") + @Operation(summary = "获得 OTA 升级记录") + @PreAuthorize("@ss.hasPermission('iot:ota-task-record:query')") + @Parameter(name = "id", description = "升级记录编号", required = true, example = "1024") + public CommonResult getOtaTaskRecord(@RequestParam("id") Long id) { + IotOtaTaskRecordDO upgradeRecord = otaTaskRecordService.getOtaTaskRecord(id); + return success(BeanUtils.toBean(upgradeRecord, IotOtaTaskRecordRespVO.class)); + } + + @PutMapping("/cancel") + @Operation(summary = "取消 OTA 升级记录") + @PreAuthorize("@ss.hasPermission('iot:ota-task-record:cancel')") + @Parameter(name = "id", description = "升级记录编号", required = true, example = "1024") + public CommonResult cancelOtaTaskRecord(@RequestParam("id") Long id) { + otaTaskRecordService.cancelOtaTaskRecord(id); + return success(true); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/IotOtaTaskPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/IotOtaTaskPageReqVO.java new file mode 100644 index 0000000000..4638f1a401 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/IotOtaTaskPageReqVO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IoT OTA 升级任务分页 Request VO") +@Data +public class IotOtaTaskPageReqVO extends PageParam { + + @Schema(description = "任务名称", example = "升级任务") + private String name; + + @Schema(description = "固件编号", example = "1024") + private Long firmwareId; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/IotOtaTaskRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/IotOtaTaskRespVO.java new file mode 100644 index 0000000000..247f7c658f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/IotOtaTaskRespVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task; + +import com.fhs.core.trans.vo.VO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT OTA 升级任务 Response VO") +@Data +public class IotOtaTaskRespVO implements VO { + + @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "任务名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "升级任务") + private String name; + + @Schema(description = "任务描述", example = "升级任务") + private String description; + + @Schema(description = "固件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long firmwareId; + + @Schema(description = "任务状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer status; + + @Schema(description = "升级范围", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer deviceScope; + + @Schema(description = "设备总共数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer deviceTotalCount; + + @Schema(description = "设备成功数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "66") + private Integer deviceSuccessCount; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2022-07-08 07:30:00") + private LocalDateTime createTime; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/record/IotOtaTaskRecordPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/record/IotOtaTaskRecordPageReqVO.java new file mode 100644 index 0000000000..00c6fe7f32 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/record/IotOtaTaskRecordPageReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskRecordStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IoT OTA 升级记录分页 Request VO") +@Data +public class IotOtaTaskRecordPageReqVO extends PageParam { + + @Schema(description = "升级任务编号", example = "1024") + private Long taskId; + + @Schema(description = "升级记录状态", example = "5") + @InEnum(IotOtaTaskRecordStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/record/IotOtaTaskRecordRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/record/IotOtaTaskRecordRespVO.java new file mode 100644 index 0000000000..f7ab1edf58 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/ota/vo/task/record/IotOtaTaskRecordRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT OTA 升级任务记录 Response VO") +@Data +public class IotOtaTaskRecordRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "固件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long firmwareId; + + @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long taskId; + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long deviceId; + + @Schema(description = "设备名称", example = "智能开关") + private String deviceName; + + @Schema(description = "来源的固件编号", example = "1023") + private Long fromFirmwareId; + + @Schema(description = "来源固件版本", example = "1.0.0") + private String fromFirmwareVersion; + + @Schema(description = "升级状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "升级进度,百分比", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private Integer progress; + + @Schema(description = "升级进度描述", example = "正在下载固件...") + private String description; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotDataRuleController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotDataRuleController.java new file mode 100644 index 0000000000..f7e64b160d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotDataRuleController.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IoT 数据流转规则") +@RestController +@RequestMapping("/iot/data-rule") +@Validated +public class IotDataRuleController { + + @Resource + private IotDataRuleService dataRuleService; + + @PostMapping("/create") + @Operation(summary = "创建数据流转规则") + @PreAuthorize("@ss.hasPermission('iot:data-rule:create')") + public CommonResult createDataRule(@Valid @RequestBody IotDataRuleSaveReqVO createReqVO) { + return success(dataRuleService.createDataRule(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新数据流转规则") + @PreAuthorize("@ss.hasPermission('iot:data-rule:update')") + public CommonResult updateDataRule(@Valid @RequestBody IotDataRuleSaveReqVO updateReqVO) { + dataRuleService.updateDataRule(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除数据流转规则") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('iot:data-rule:delete')") + public CommonResult deleteDataRule(@RequestParam("id") Long id) { + dataRuleService.deleteDataRule(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得数据流转规则") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:data-rule:query')") + public CommonResult getDataRule(@RequestParam("id") Long id) { + IotDataRuleDO dataRule = dataRuleService.getDataRule(id); + return success(BeanUtils.toBean(dataRule, IotDataRuleRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得数据流转规则分页") + @PreAuthorize("@ss.hasPermission('iot:data-rule:query')") + public CommonResult> getDataRulePage(@Valid IotDataRulePageReqVO pageReqVO) { + PageResult pageResult = dataRuleService.getDataRulePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotDataRuleRespVO.class)); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotDataSinkController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotDataSinkController.java new file mode 100644 index 0000000000..6e1aae797c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotDataSinkController.java @@ -0,0 +1,84 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataSinkService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - IoT 数据流转目的") +@RestController +@RequestMapping("/iot/data-sink") +@Validated +public class IotDataSinkController { + + @Resource + private IotDataSinkService dataSinkService; + + @PostMapping("/create") + @Operation(summary = "创建数据目的") + @PreAuthorize("@ss.hasPermission('iot:data-sink:create')") + public CommonResult createDataSink(@Valid @RequestBody IotDataSinkSaveReqVO createReqVO) { + return success(dataSinkService.createDataSink(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新数据目的") + @PreAuthorize("@ss.hasPermission('iot:data-sink:update')") + public CommonResult updateDataSink(@Valid @RequestBody IotDataSinkSaveReqVO updateReqVO) { + dataSinkService.updateDataSink(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除数据目的") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('iot:data-sink:delete')") + public CommonResult deleteDataSink(@RequestParam("id") Long id) { + dataSinkService.deleteDataSink(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得数据目的") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:data-sink:query')") + public CommonResult getDataSink(@RequestParam("id") Long id) { + IotDataSinkDO sink = dataSinkService.getDataSink(id); + return success(BeanUtils.toBean(sink, IotDataSinkRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得数据目的分页") + @PreAuthorize("@ss.hasPermission('iot:data-sink:query')") + public CommonResult> getDataSinkPage(@Valid IotDataSinkPageReqVO pageReqVO) { + PageResult pageResult = dataSinkService.getDataSinkPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotDataSinkRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获取数据目的的精简信息列表", description = "主要用于前端的下拉选项") + public CommonResult> getDataSinkSimpleList() { + List list = dataSinkService.getDataSinkListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, sink -> // 只返回 id、name 字段 + new IotDataSinkRespVO().setId(sink.getId()).setName(sink.getName()))); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotSceneRuleController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotSceneRuleController.java new file mode 100644 index 0000000000..57d71be82a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/IotSceneRuleController.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRulePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleUpdateStatusReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - IoT 场景联动") +@RestController +@RequestMapping("/iot/scene-rule") +@Validated +public class IotSceneRuleController { + + @Resource + private IotSceneRuleService sceneRuleService; + + @PostMapping("/create") + @Operation(summary = "创建场景联动") + @PreAuthorize("@ss.hasPermission('iot:scene-rule:create')") + public CommonResult createSceneRule(@Valid @RequestBody IotSceneRuleSaveReqVO createReqVO) { + return success(sceneRuleService.createSceneRule(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新场景联动") + @PreAuthorize("@ss.hasPermission('iot:scene-rule:update')") + public CommonResult updateSceneRule(@Valid @RequestBody IotSceneRuleSaveReqVO updateReqVO) { + sceneRuleService.updateSceneRule(updateReqVO); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "更新场景联动状态") + @PreAuthorize("@ss.hasPermission('iot:scene-rule:update')") + public CommonResult updateSceneRuleStatus(@Valid @RequestBody IotSceneRuleUpdateStatusReqVO updateReqVO) { + sceneRuleService.updateSceneRuleStatus(updateReqVO.getId(), updateReqVO.getStatus()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除场景联动") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('iot:scene-rule:delete')") + public CommonResult deleteSceneRule(@RequestParam("id") Long id) { + sceneRuleService.deleteSceneRule(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得场景联动") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:scene-rule:query')") + public CommonResult getSceneRule(@RequestParam("id") Long id) { + IotSceneRuleDO sceneRule = sceneRuleService.getSceneRule(id); + return success(BeanUtils.toBean(sceneRule, IotSceneRuleRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得场景联动分页") + @PreAuthorize("@ss.hasPermission('iot:scene-rule:query')") + public CommonResult> getSceneRulePage(@Valid IotSceneRulePageReqVO pageReqVO) { + PageResult pageResult = sceneRuleService.getSceneRulePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotSceneRuleRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获取场景联动的精简信息列表", description = "主要用于前端的下拉选项") + public CommonResult> getSceneRuleSimpleList() { + List list = sceneRuleService.getSceneRuleListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, scene -> // 只返回 id、name 字段 + new IotSceneRuleRespVO().setId(scene.getId()).setName(scene.getName()))); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/package-info.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/package-info.java new file mode 100644 index 0000000000..6be90cf325 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/package-info.java @@ -0,0 +1 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRulePageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRulePageReqVO.java new file mode 100644 index 0000000000..8e21c7992c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRulePageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IoT 数据流转规则分页 Request VO") +@Data +public class IotDataRulePageReqVO extends PageParam { + + @Schema(description = "数据流转规则名称", example = "芋艿") + private String name; + + @Schema(description = "数据流转规则状态", example = "1") + private Integer status; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRuleRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRuleRespVO.java new file mode 100644 index 0000000000..3427370f7c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRuleRespVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule; + +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - IoT 数据流转规则 Response VO") +@Data +public class IotDataRuleRespVO { + + @Schema(description = "数据流转规则编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "8540") + private Long id; + + @Schema(description = "数据流转规则名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + private String name; + + @Schema(description = "数据流转规则描述", example = "你猜") + private String description; + + @Schema(description = "数据流转规则状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "数据源配置数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List sourceConfigs; + + @Schema(description = "数据目的编号数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List sinkIds; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRuleSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRuleSaveReqVO.java new file mode 100644 index 0000000000..47748c6eb1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/rule/IotDataRuleSaveReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IoT 数据流转规则新增/修改 Request VO") +@Data +public class IotDataRuleSaveReqVO { + + @Schema(description = "数据流转规则编号", example = "8540") + private Long id; + + @Schema(description = "数据流转规则名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") + @NotEmpty(message = "数据流转规则名称不能为空") + private String name; + + @Schema(description = "数据流转规则描述", example = "你猜") + private String description; + + @Schema(description = "数据流转规则状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "数据流转规则状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "数据源配置数组", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "数据源配置数组不能为空") + private List sourceConfigs; + + @Schema(description = "数据目的编号数组", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "数据目的编号数组不能为空") + private List sinkIds; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkRespVO.java new file mode 100644 index 0000000000..0ced03c225 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkRespVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; + +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT 数据流转目的 Response VO") +@Data +public class IotDataSinkRespVO { + + @Schema(description = "数据目的编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18564") + private Long id; + + @Schema(description = "数据目的名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + private String name; + + @Schema(description = "数据目的描述", example = "随便") + private String description; + + @Schema(description = "数据目的状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "数据目的类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "数据目的配置") + private IotAbstractDataSinkConfig config; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVO.java new file mode 100644 index 0000000000..b0e49dedd7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 数据流转目的新增/修改 Request VO") +@Data +public class IotDataSinkSaveReqVO { + + @Schema(description = "数据目的编号", example = "18564") + private Long id; + + @Schema(description = "数据目的名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + @NotEmpty(message = "数据目的名称不能为空") + private String name; + + @Schema(description = "数据目的描述", example = "随便") + private String description; + + @Schema(description = "数据目的状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "数据目的状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "数据目的类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "数据目的类型不能为空") + @InEnum(IotDataSinkTypeEnum.class) + private Integer type; + + @Schema(description = "数据目的配置") + @NotNull(message = "数据目的配置不能为空") + private IotAbstractDataSinkConfig config; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRulePageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRulePageReqVO.java new file mode 100644 index 0000000000..8345004b67 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRulePageReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IoT 场景联动分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class IotSceneRulePageReqVO extends PageParam { + + @Schema(description = "场景名称", example = "赵六") + private String name; + + @Schema(description = "场景描述", example = "你猜") + private String description; + + @Schema(description = "场景状态", example = "1") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleRespVO.java new file mode 100644 index 0000000000..835ef62933 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleRespVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene; + +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - IoT 场景联动 Response VO") +@Data +public class IotSceneRuleRespVO { + + @Schema(description = "场景编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15865") + private Long id; + + @Schema(description = "场景名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + private String name; + + @Schema(description = "场景描述", example = "你猜") + private String description; + + @Schema(description = "场景状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "触发器数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List triggers; + + @Schema(description = "执行器数组", requiredMode = Schema.RequiredMode.REQUIRED) + private List actions; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleSaveReqVO.java new file mode 100644 index 0000000000..4a5f1ed9fa --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleSaveReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IoT 场景联动新增/修改 Request VO") +@Data +public class IotSceneRuleSaveReqVO { + + @Schema(description = "场景编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15865") + private Long id; + + @Schema(description = "场景名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六") + @NotEmpty(message = "场景名称不能为空") + private String name; + + @Schema(description = "场景描述", example = "你猜") + private String description; + + @Schema(description = "场景状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "场景状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "触发器数组", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "触发器数组不能为空") + private List triggers; + + @Schema(description = "执行器数组", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "执行器数组不能为空") + private List actions; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleUpdateStatusReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleUpdateStatusReqVO.java new file mode 100644 index 0000000000..ea3721fdd9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/scene/IotSceneRuleUpdateStatusReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 场景联动更新状态 Request VO") +@Data +public class IotSceneRuleUpdateStatusReqVO { + + @Schema(description = "场景联动编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "场景联动编号不能为空") + private Long id; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "修改状态必须是 {value}") + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.http b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.http new file mode 100644 index 0000000000..b8cb6b544f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.http @@ -0,0 +1,11 @@ +### 请求 /iot/statistics/get-device-message-summary-by-date 接口(小时) +GET {{baseUrl}}/iot/statistics/get-device-message-summary-by-date?interval=0×[0]=2025-06-13 00:00:00×[1]=2025-06-14 23:59:59 +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} + +### 请求 /iot/statistics/get-device-message-summary-by-date 接口(天) +GET {{baseUrl}}/iot/statistics/get-device-message-summary-by-date?interval=1×[0]=2025-06-13 00:00:00×[1]=2025-06-14 23:59:59 +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/vo/IotStatisticsDeviceMessageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/vo/IotStatisticsDeviceMessageReqVO.java new file mode 100644 index 0000000000..73f83e70cf --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/vo/IotStatisticsDeviceMessageReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.controller.admin.statistics.vo; + +import cn.iocoder.yudao.framework.common.enums.DateIntervalEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IoT 设备消息数量统计 Response VO") +@Data +public class IotStatisticsDeviceMessageReqVO { + + @Schema(description = "时间间隔类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @InEnum(value = DateIntervalEnum.class, message = "时间间隔类型,必须是 {value}") + private Integer interval; + + @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @Size(min = 2, max = 2, message = "请选择时间范围") + private LocalDateTime[] times; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/vo/IotStatisticsDeviceMessageSummaryByDateRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/vo/IotStatisticsDeviceMessageSummaryByDateRespVO.java new file mode 100644 index 0000000000..9c605dd341 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/vo/IotStatisticsDeviceMessageSummaryByDateRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.iot.controller.admin.statistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 设备消息数量统计 Response VO") +@Data +public class IotStatisticsDeviceMessageSummaryByDateRespVO { + + @Schema(description = "时间轴", requiredMode = Schema.RequiredMode.REQUIRED, example = "202401") + private String time; + + @Schema(description = "上行消息数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer upstreamCount; + + @Schema(description = "上行消息数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Integer downstreamCount; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/thingmodel/vo/IotThingModelTSLRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/thingmodel/vo/IotThingModelTSLRespVO.java new file mode 100644 index 0000000000..d3809d8819 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/thingmodel/vo/IotThingModelTSLRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.iot.controller.admin.thingmodel.vo; + +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelEvent; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelService; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IoT 产品物模型 TSL Response VO") +@Data +public class IotThingModelTSLRespVO { + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long productId; + + @Schema(description = "产品标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "temperature_sensor") + private String productKey; + + @Schema(description = "属性列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List properties; + + @Schema(description = "服务列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List events; + + @Schema(description = "事件列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List services; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java new file mode 100644 index 0000000000..9f1f6a6a0c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.device; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备消息数据 DO + * + * 目前使用 TDengine 存储 + * + * @author alwayssuper + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceMessageDO { + + /** + * 消息编号 + */ + private String id; + /** + * 上报时间戳 + */ + private Long reportTime; + /** + * 存储时间戳 + */ + private Long ts; + + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + */ + private Long deviceId; + /** + * 租户编号 + */ + private Long tenantId; + + /** + * 服务编号,该消息由哪个 server 发送 + */ + private String serverId; + + /** + * 是否上行消息 + * + * 由 {@link cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils#isUpstreamMessage(IotDeviceMessage)} 计算。 + * 计算并存储的目的:方便计算多少条上行、多少条下行 + */ + private Boolean upstream; + /** + * 是否回复消息 + * + * 由 {@link cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils#isReplyMessage(IotDeviceMessage)} 计算。 + * 计算并存储的目的:方便计算多少条请求、多少条回复 + */ + private Boolean reply; + /** + * 标识符 + * + * 例如说:{@link IotThingModelDO#getIdentifier()} + * 目前,只有事件上报、服务调用才有!!! + */ + private String identifier; + + // ========== codec(编解码)字段 ========== + + /** + * 请求编号 + * + * 由设备生成,对应阿里云 IoT 的 Alink 协议中的 id、华为云 IoTDA 协议的 request_id + */ + private String requestId; + /** + * 请求方法 + * + * 枚举 {@link IotDeviceMessageMethodEnum} + * 例如说:thing.property.report 属性上报 + */ + private String method; + /** + * 请求参数 + * + * 例如说:属性上报的 properties、事件上报的 params + */ + private Object params; + /** + * 响应结果 + */ + private Object data; + /** + * 响应错误码 + */ + private Integer code; + /** + * 响应提示 + */ + private String msg; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/ota/IotOtaTaskDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/ota/IotOtaTaskDO.java new file mode 100644 index 0000000000..4c9124b89f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/ota/IotOtaTaskDO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.ota; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskDeviceScopeEnum; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT OTA 升级任务 DO + * + * @author 芋道源码 + */ +@TableName(value = "iot_ota_task", autoResultMap = true) +@KeySequence("iot_ota_task_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotOtaTaskDO extends BaseDO { + + /** + * 任务编号 + */ + @TableId + private Long id; + /** + * 任务名称 + */ + private String name; + /** + * 任务描述 + */ + private String description; + + /** + * 固件编号 + *

+ * 关联 {@link IotOtaFirmwareDO#getId()} + */ + private Long firmwareId; + + /** + * 任务状态 + *

+ * 关联 {@link IotOtaTaskStatusEnum} + */ + private Integer status; + + /** + * 设备升级范围 + *

+ * 关联 {@link IotOtaTaskDeviceScopeEnum} + */ + private Integer deviceScope; + /** + * 设备总数数量 + */ + private Integer deviceTotalCount; + /** + * 设备成功数量 + */ + private Integer deviceSuccessCount; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/ota/IotOtaTaskRecordDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/ota/IotOtaTaskRecordDO.java new file mode 100644 index 0000000000..d99a1bb60a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/ota/IotOtaTaskRecordDO.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.ota; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskRecordStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT OTA 升级任务记录 DO + * + * @author 芋道源码 + */ +@TableName(value = "iot_ota_task_record", autoResultMap = true) +@KeySequence("iot_ota_task_record_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotOtaTaskRecordDO extends BaseDO { + + public static final String DESCRIPTION_CANCEL_BY_TASK = "管理员手动取消升级任务(批量)"; + + public static final String DESCRIPTION_CANCEL_BY_RECORD = "管理员手动取消升级记录(单个)"; + + /** + * 升级记录编号 + */ + @TableId + private Long id; + + /** + * 固件编号 + * + * 关联 {@link IotOtaFirmwareDO#getId()} + */ + private Long firmwareId; + /** + * 任务编号 + * + * 关联 {@link IotOtaTaskDO#getId()} + */ + private Long taskId; + + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + */ + private Long deviceId; + /** + * 来源的固件编号 + * + * 关联 {@link IotDeviceDO#getFirmwareId()} + */ + private Long fromFirmwareId; + + /** + * 升级状态 + * + * 关联 {@link IotOtaTaskRecordStatusEnum} + */ + private Integer status; + /** + * 升级进度,百分比 + */ + private Integer progress; + /** + * 升级进度描述 + * + * 注意,只记录设备最后一次的升级进度描述 + * 如果想看历史记录,可以查看 {@link IotDeviceMessageDO} 设备日志 + */ + private String description; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotDataRuleDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotDataRuleDO.java new file mode 100644 index 0000000000..191df10d06 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotDataRuleDO.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.rule; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * IoT 数据流转规则 DO + * + * 监听 {@link SourceConfig} 数据源,转发到 {@link IotDataSinkDO} 数据目的 + * + * @author 芋道源码 + */ +@TableName(value = "iot_data_rule", autoResultMap = true) +@KeySequence("iot_data_rule_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotDataRuleDO extends BaseDO { + + /** + * 数据流转规格编号 + */ + private Long id; + /** + * 数据流转规格名称 + */ + private String name; + /** + * 数据流转规格描述 + */ + private String description; + /** + * 数据流转规格状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + + /** + * 数据源配置数组 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List sourceConfigs; + /** + * 数据目的编号数组 + * + * 关联 {@link IotDataSinkDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List sinkIds; + + // TODO @芋艿:未来考虑使用 groovy;支持数据处理; + + /** + * 数据源配置 + */ + @Data + public static class SourceConfig { + + /** + * 消息方法 + * + * 枚举 {@link IotDeviceMessageMethodEnum} 中的 upstream 上行部分 + */ + @NotEmpty(message = "消息方法不能为空") + private String method; + + /** + * 产品编号 + * + * 关联 {@link IotProductDO#getId()} + */ + private Long productId; + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + * 特殊:如果为 {@link IotDeviceDO#DEVICE_ID_ALL} 时,则是全部设备 + */ + @NotEmpty(message = "设备编号不能为空") + private Long deviceId; + + /** + * 标识符 + * + * 1. 物模型时,对应:{@link IotThingModelDO#getIdentifier()} + */ + private String identifier; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java new file mode 100644 index 0000000000..94aa1eb5a3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java @@ -0,0 +1,245 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.rule; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * IoT 场景联动规则 DO + * + * @author 芋道源码 + */ +@TableName(value = "iot_scene_rule", autoResultMap = true) +@KeySequence("iot_scene_rule_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotSceneRuleDO extends TenantBaseDO { + + /** + * 场景联动编号 + */ + @TableId + private Long id; + /** + * 场景联动名称 + */ + private String name; + /** + * 场景联动描述 + */ + private String description; + /** + * 场景联动状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + + /** + * 场景定义配置 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List triggers; + + /** + * 场景动作配置 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List actions; + + /** + * 场景定义配置 + */ + @Data + public static class Trigger { + + // ========== 事件部分 ========== + + /** + * 场景事件类型 + * + * 枚举 {@link IotSceneRuleTriggerTypeEnum} + * 1. {@link IotSceneRuleTriggerTypeEnum#DEVICE_STATE_UPDATE} 时,operator 非空,并且 value 为在线状态 + * 2. {@link IotSceneRuleTriggerTypeEnum#DEVICE_PROPERTY_POST} + * {@link IotSceneRuleTriggerTypeEnum#DEVICE_EVENT_POST} 时,identifier、operator 非空,并且 value 为属性值 + * 3. {@link IotSceneRuleTriggerTypeEnum#DEVICE_EVENT_POST} + * {@link IotSceneRuleTriggerTypeEnum#DEVICE_SERVICE_INVOKE} 时,identifier 非空,但是 operator、value 为空 + * 4. {@link IotSceneRuleTriggerTypeEnum#TIMER} 时,conditions 非空,并且设备无关(无需 productId、deviceId 字段) + */ + private Integer type; + + /** + * 产品编号 + * + * 关联 {@link IotProductDO#getId()} + */ + private Long productId; + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + * 特殊:如果为 {@link IotDeviceDO#DEVICE_ID_ALL} 时,则是全部设备 + */ + private Long deviceId; + /** + * 物模型标识符 + * + * 对应:{@link IotThingModelDO#getIdentifier()} + */ + private String identifier; + /** + * 操作符 + * + * 枚举 {@link IotSceneRuleConditionOperatorEnum} + */ + private String operator; + /** + * 参数(属性值、在线状态) + *

+ * 如果有多个值,则使用 "," 分隔,类似 "1,2,3"。 + * 例如说,{@link IotSceneRuleConditionOperatorEnum#IN}、{@link IotSceneRuleConditionOperatorEnum#BETWEEN} + */ + private String value; + + /** + * CRON 表达式 + */ + private String cronExpression; + + // ========== 条件部分 ========== + + /** + * 触发条件分组(状态条件分组)的数组 + *

+ * 第一层 List:分组与分组之间,是“或”的关系 + * 第二层 List:条件与条件之间,是“且”的关系 + */ + private List> conditionGroups; + + } + + /** + * 触发条件(状态条件) + */ + @Data + public static class TriggerCondition { + + /** + * 触发条件类型 + * + * 枚举 {@link IotSceneRuleConditionTypeEnum} + * 1. {@link IotSceneRuleConditionTypeEnum#DEVICE_STATE} 时,operator 非空,并且 value 为在线状态 + * 2. {@link IotSceneRuleConditionTypeEnum#DEVICE_PROPERTY} 时,identifier、operator 非空,并且 value 为属性值 + * 3. {@link IotSceneRuleConditionTypeEnum#CURRENT_TIME} 时,operator 非空(使用 DATE_TIME_ 和 TIME_ 部分),并且 value 非空 + */ + private Integer type; + + /** + * 产品编号 + * + * 关联 {@link IotProductDO#getId()} + */ + private Long productId; + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + */ + private Long deviceId; + /** + * 标识符(属性) + * + * 关联 {@link IotThingModelDO#getIdentifier()} + */ + private String identifier; + /** + * 操作符 + * + * 枚举 {@link IotSceneRuleConditionOperatorEnum} + */ + private String operator; + /** + * 参数 + * + * 如果有多个值,则使用 "," 分隔,类似 "1,2,3"。 + * 例如说,{@link IotSceneRuleConditionOperatorEnum#IN}、{@link IotSceneRuleConditionOperatorEnum#BETWEEN} + */ + private String param; + + } + + /** + * 场景动作配置 + */ + @Data + public static class Action { + + /** + * 执行类型 + * + * 枚举 {@link IotSceneRuleActionTypeEnum} + * 1. {@link IotSceneRuleActionTypeEnum#DEVICE_PROPERTY_SET} 时,params 非空 + * {@link IotSceneRuleActionTypeEnum#DEVICE_SERVICE_INVOKE} 时,params 非空 + * 2. {@link IotSceneRuleActionTypeEnum#ALERT_TRIGGER} 时,alertConfigId 为空,因为是 {@link IotAlertConfigDO} 里面关联它 + * 3. {@link IotSceneRuleActionTypeEnum#ALERT_RECOVER} 时,alertConfigId 非空 + */ + private Integer type; + + /** + * 产品编号 + * + * 关联 {@link IotProductDO#getId()} + */ + private Long productId; + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + */ + private Long deviceId; + + /** + * 标识符(服务) + *

+ * 关联 {@link IotThingModelDO#getIdentifier()} + */ + private String identifier; + + /** + * 请求参数 + * + * 一般来说,对应 {@link IotDeviceMessage#getParams()} 请求参数 + */ + private String params; + + /** + * 告警配置编号 + * + * 关联 {@link IotAlertConfigDO#getId()} + */ + private Long alertConfigId; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java new file mode 100644 index 0000000000..68a8fd699b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotAbstractDataSinkConfig.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config; + +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Data; + +/** + * IoT IotDataBridgeConfig 抽象类 + * + * 用于表示数据目的配置数据的通用类型,根据具体的 "type" 字段动态映射到对应的子类 + * 提供多态支持,适用于不同类型的数据结构序列化和反序列化场景。 + * + * @author HUIHUI + */ +@Data +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = IotDataSinkHttpConfig.class, name = "1"), + @JsonSubTypes.Type(value = IotDataSinkMqttConfig.class, name = "10"), + @JsonSubTypes.Type(value = IotDataSinkRedisConfig.class, name = "21"), + @JsonSubTypes.Type(value = IotDataSinkRocketMQConfig.class, name = "30"), + @JsonSubTypes.Type(value = IotDataSinkRabbitMQConfig.class, name = "31"), + @JsonSubTypes.Type(value = IotDataSinkKafkaConfig.class, name = "32"), +}) +public abstract class IotAbstractDataSinkConfig { + + /** + * 配置类型 + * + * 枚举 {@link IotDataSinkTypeEnum#getType()} + */ + private String type; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkRedisConfig.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkRedisConfig.java new file mode 100644 index 0000000000..07460ac368 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkRedisConfig.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotRedisDataStructureEnum; +import lombok.Data; + +/** + * IoT Redis 配置 {@link IotAbstractDataSinkConfig} 实现类 + * + * @author HUIHUI + */ +@Data +public class IotDataSinkRedisConfig extends IotAbstractDataSinkConfig { + + /** + * Redis 服务器地址 + */ + private String host; + /** + * 端口 + */ + private Integer port; + /** + * 密码 + */ + private String password; + /** + * 数据库索引 + */ + private Integer database; + + /** + * Redis 数据结构类型 + *

+ * 枚举 {@link IotRedisDataStructureEnum} + */ + @InEnum(IotRedisDataStructureEnum.class) + private Integer dataStructure; + + /** + * 主题/键名 + *

+ * 对于不同的数据结构: + * - Stream: 流的键名 + * - Hash: Hash 的键名 + * - List: 列表的键名 + * - Set: 集合的键名 + * - ZSet: 有序集合的键名 + * - String: 字符串的键名 + */ + private String topic; + + /** + * Hash 字段名(仅当 dataStructure 为 HASH 时使用) + */ + private String hashField; + + /** + * ZSet 分数字段(仅当 dataStructure 为 ZSET 时使用) + * 指定消息中哪个字段作为分数,如果不指定则使用当前时间戳 + */ + private String scoreField; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/dataType/ThingModelBoolOrEnumDataSpecs.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/dataType/ThingModelBoolOrEnumDataSpecs.java new file mode 100644 index 0000000000..8533fcc6f5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/dataType/ThingModelBoolOrEnumDataSpecs.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * IoT 物模型数据类型为布尔型或枚举型的 DataSpec 定义 + * + * 数据类型,取值为 bool 或 enum + * + * @author HUIHUI + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonIgnoreProperties({"dataType"}) // 忽略子类中的 dataType 字段,从而避免重复 +public class ThingModelBoolOrEnumDataSpecs extends ThingModelDataSpecs { + + @NotEmpty(message = "枚举项的名称不能为空") + @Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9][\\u4e00-\\u9fa5a-zA-Z0-9_-]{0,19}$", + message = "枚举项的名称只能包含中文、大小写英文字母、数字、下划线和短划线,必须以中文、英文字母或数字开头,长度不超过 20 个字符") + private String name; + + @NotNull(message = "枚举值不能为空") + private Integer value; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/alert/IotAlertConfigMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/alert/IotAlertConfigMapper.java new file mode 100644 index 0000000000..c5d7154ff6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/alert/IotAlertConfigMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.alert; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 告警配置 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface IotAlertConfigMapper extends BaseMapperX { + + default PageResult selectPage(IotAlertConfigPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(IotAlertConfigDO::getName, reqVO.getName()) + .eqIfPresent(IotAlertConfigDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(IotAlertConfigDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(IotAlertConfigDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(IotAlertConfigDO::getStatus, status); + } + + default List selectListBySceneRuleIdAndStatus(Long sceneRuleId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(IotAlertConfigDO::getStatus, status) + .apply(MyBatisUtils.findInSet("scene_rule_id", sceneRuleId))); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/alert/IotAlertRecordMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/alert/IotAlertRecordMapper.java new file mode 100644 index 0000000000..f23fe60f74 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/alert/IotAlertRecordMapper.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.alert; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * IoT 告警记录 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface IotAlertRecordMapper extends BaseMapperX { + + default PageResult selectPage(IotAlertRecordPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(IotAlertRecordDO::getConfigId, reqVO.getConfigId()) + .eqIfPresent(IotAlertRecordDO::getConfigLevel, reqVO.getLevel()) + .eqIfPresent(IotAlertRecordDO::getProductId, reqVO.getProductId()) + .eqIfPresent(IotAlertRecordDO::getDeviceId, reqVO.getDeviceId()) + .eqIfPresent(IotAlertRecordDO::getProcessStatus, reqVO.getProcessStatus()) + .betweenIfPresent(IotAlertRecordDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(IotAlertRecordDO::getId)); + } + + default List selectListBySceneRuleId(Long sceneRuleId, Long deviceId, Boolean processStatus) { + return selectList(new LambdaQueryWrapperX() + .eq(IotAlertRecordDO::getSceneRuleId, sceneRuleId) + .eqIfPresent(IotAlertRecordDO::getDeviceId, deviceId) + .eqIfPresent(IotAlertRecordDO::getProcessStatus, processStatus) + .orderByDesc(IotAlertRecordDO::getId)); + } + + default int updateList(Collection ids, IotAlertRecordDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .in(IotAlertRecordDO::getId, ids)); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/ota/IotOtaTaskMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/ota/IotOtaTaskMapper.java new file mode 100644 index 0000000000..cf73231234 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/ota/IotOtaTaskMapper.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.ota; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface IotOtaTaskMapper extends BaseMapperX { + + default IotOtaTaskDO selectByFirmwareIdAndName(Long firmwareId, String name) { + return selectOne(IotOtaTaskDO::getFirmwareId, firmwareId, + IotOtaTaskDO::getName, name); + } + + default PageResult selectPage(IotOtaTaskPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(IotOtaTaskDO::getFirmwareId, pageReqVO.getFirmwareId()) + .likeIfPresent(IotOtaTaskDO::getName, pageReqVO.getName()) + .orderByDesc(IotOtaTaskDO::getId)); + } + + default int updateByIdAndStatus(Long id, Integer whereStatus, IotOtaTaskDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .eq(IotOtaTaskDO::getId, id) + .eq(IotOtaTaskDO::getStatus, whereStatus)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/ota/IotOtaTaskRecordMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/ota/IotOtaTaskRecordMapper.java new file mode 100644 index 0000000000..017adc9192 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/ota/IotOtaTaskRecordMapper.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.ota; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record.IotOtaTaskRecordPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +@Mapper +public interface IotOtaTaskRecordMapper extends BaseMapperX { + + default List selectListByFirmwareIdAndTaskId(Long firmwareId, Long taskId) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(IotOtaTaskRecordDO::getFirmwareId, firmwareId) + .eqIfPresent(IotOtaTaskRecordDO::getTaskId, taskId) + .select(IotOtaTaskRecordDO::getDeviceId, IotOtaTaskRecordDO::getStatus)); + } + + default PageResult selectPage(IotOtaTaskRecordPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(IotOtaTaskRecordDO::getTaskId, pageReqVO.getTaskId()) + .eqIfPresent(IotOtaTaskRecordDO::getStatus, pageReqVO.getStatus())); + } + + default List selectListByTaskIdAndStatus(Long taskId, Collection statuses) { + return selectList(new LambdaQueryWrapperX() + .eq(IotOtaTaskRecordDO::getTaskId, taskId) + .in(IotOtaTaskRecordDO::getStatus, statuses)); + } + + default Long selectCountByTaskIdAndStatus(Long taskId, Collection statuses) { + return selectCount(new LambdaQueryWrapperX() + .eq(IotOtaTaskRecordDO::getTaskId, taskId) + .in(IotOtaTaskRecordDO::getStatus, statuses)); + } + + default int updateByIdAndStatus(Long id, Integer status, + IotOtaTaskRecordDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .eq(IotOtaTaskRecordDO::getId, id) + .eq(IotOtaTaskRecordDO::getStatus, status)); + } + + default int updateByIdAndStatus(Long id, Collection whereStatuses, + IotOtaTaskRecordDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .eq(IotOtaTaskRecordDO::getId, id) + .in(IotOtaTaskRecordDO::getStatus, whereStatuses)); + } + + default void updateListByIdAndStatus(Collection ids, Collection whereStatuses, + IotOtaTaskRecordDO updateObj) { + update(updateObj, new LambdaUpdateWrapper() + .in(IotOtaTaskRecordDO::getId, ids) + .in(IotOtaTaskRecordDO::getStatus, whereStatuses)); + } + + default List selectListByDeviceIdAndStatus(Set deviceIds, Set statuses) { + return selectList(new LambdaQueryWrapperX() + .inIfPresent(IotOtaTaskRecordDO::getDeviceId, deviceIds) + .inIfPresent(IotOtaTaskRecordDO::getStatus, statuses)); + } + + default List selectListByDeviceIdAndStatus(Long deviceId, Set statuses) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(IotOtaTaskRecordDO::getDeviceId, deviceId) + .inIfPresent(IotOtaTaskRecordDO::getStatus, statuses)); + } + + default List selectListByStatus(Integer status) { + return selectList(IotOtaTaskRecordDO::getStatus, status); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java new file mode 100644 index 0000000000..7c0c17d3bc --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.rule; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 数据流转规则 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface IotDataRuleMapper extends BaseMapperX { + + default PageResult selectPage(IotDataRulePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(IotDataRuleDO::getName, reqVO.getName()) + .eqIfPresent(IotDataRuleDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(IotDataRuleDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(IotDataRuleDO::getId)); + } + + default List selectListBySinkId(Long sinkId) { + return selectList(new LambdaQueryWrapperX() + .apply(MyBatisUtils.findInSet("sink_ids", sinkId))); + } + + default List selectListByStatus(Integer status) { + return selectList(IotDataRuleDO::getStatus, status); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java new file mode 100644 index 0000000000..e65001db86 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.rule; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 数据流转目的 Mapper + * + * @author HUIHUI + */ +@Mapper +public interface IotDataSinkMapper extends BaseMapperX { + + default PageResult selectPage(IotDataSinkPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(IotDataSinkDO::getName, reqVO.getName()) + .eqIfPresent(IotDataSinkDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(IotDataSinkDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(IotDataSinkDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(IotDataSinkDO::getStatus, status); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotSceneRuleMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotSceneRuleMapper.java new file mode 100644 index 0000000000..4fd6490d15 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotSceneRuleMapper.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.rule; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRulePageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 场景联动 Mapper + * + * @author HUIHUI + */ +@Mapper +public interface IotSceneRuleMapper extends BaseMapperX { + + default PageResult selectPage(IotSceneRulePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(IotSceneRuleDO::getName, reqVO.getName()) + .likeIfPresent(IotSceneRuleDO::getDescription, reqVO.getDescription()) + .eqIfPresent(IotSceneRuleDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(IotSceneRuleDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(IotSceneRuleDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(IotSceneRuleDO::getStatus, status); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java new file mode 100644 index 0000000000..cef78f3cff --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/device/DeviceServerIdRedisDAO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.iot.dal.redis.device; + +import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +/** + * 设备关联的网关 serverId 的 Redis DAO + * + * @author 芋道源码 + */ +@Repository +public class DeviceServerIdRedisDAO { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + public void update(Long deviceId, String serverId) { + stringRedisTemplate.opsForHash().put(RedisKeyConstants.DEVICE_SERVER_ID, + String.valueOf(deviceId), serverId); + } + + public String get(Long deviceId) { + Object value = stringRedisTemplate.opsForHash().get(RedisKeyConstants.DEVICE_SERVER_ID, + String.valueOf(deviceId)); + return value != null ? (String) value : null; + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageMapper.java new file mode 100644 index 0000000000..b09895fd36 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageMapper.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.dal.tdengine; + +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.framework.tdengine.core.annotation.TDengineDS; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 设备消息 {@link IotDeviceMessageDO} Mapper 接口 + */ +@Mapper +@TDengineDS +@InterceptorIgnore(tenantLine = "true") // 避免 SQL 解析,因为 JSqlParser 对 TDengine 的 SQL 解析会报错 +public interface IotDeviceMessageMapper { + + /** + * 创建设备消息超级表 + */ + void createSTable(); + + /** + * 查询设备消息表是否存在 + * + * @return 存在则返回表名;不存在则返回 null + */ + String showSTable(); + + /** + * 插入设备消息数据 + * + * 如果子表不存在,会自动创建子表 + * + * @param message 设备消息数据 + */ + void insert(IotDeviceMessageDO message); + + /** + * 获得设备消息分页 + * + * @param reqVO 分页查询条件 + * @return 设备消息列表 + */ + IPage selectPage(IPage page, + @Param("reqVO") IotDeviceMessagePageReqVO reqVO); + + /** + * 统计设备消息数量 + * + * @param createTime 创建时间,如果为空,则统计所有消息数量 + * @return 消息数量 + */ + Long selectCountByCreateTime(@Param("createTime") Long createTime); + + /** + * 按照 requestIds 批量查询消息 + * + * @param deviceId 设备编号 + * @param requestIds 请求编号集合 + * @param reply 是否回复消息 + * @return 消息列表 + */ + List selectListByRequestIdsAndReply(@Param("deviceId") Long deviceId, + @Param("requestIds") Collection requestIds, + @Param("reply") Boolean reply); + + /** + * 按照时间范围(小时),统计设备的消息数量 + */ + List> selectDeviceMessageCountGroupByDate(@Param("startTime") Long startTime, + @Param("endTime") Long endTime); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/DictTypeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/DictTypeConstants.java new file mode 100644 index 0000000000..4f07ddfc1c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/DictTypeConstants.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.iot.enums; + +/** + * IoT 字典类型的枚举类 + * + * @author 芋道源码 + */ +public class DictTypeConstants { + + public static final String NET_TYPE = "iot_net_type"; + public static final String LOCATION_TYPE = "iot_location_type"; + public static final String CODEC_TYPE = "iot_codec_type"; + + public static final String PRODUCT_STATUS = "iot_product_status"; + public static final String PRODUCT_DEVICE_TYPE = "iot_product_device_type"; + + public static final String DEVICE_STATE = "iot_device_state"; + + public static final String ALERT_LEVEL = "iot_alert_level"; + + public static final String OTA_TASK_DEVICE_SCOPE = "iot_ota_task_device_scope"; + public static final String OTA_TASK_STATUS = "iot_ota_task_status"; + public static final String OTA_TASK_RECORD_STATUS = "iot_ota_task_record_status"; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java new file mode 100644 index 0000000000..d1cf60e206 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.iot.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * iot 错误码枚举类 + *

+ * iot 系统,使用 1-050-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 产品相关 1-050-001-000 ============ + ErrorCode PRODUCT_NOT_EXISTS = new ErrorCode(1_050_001_000, "产品不存在"); + ErrorCode PRODUCT_KEY_EXISTS = new ErrorCode(1_050_001_001, "产品标识已经存在"); + ErrorCode PRODUCT_STATUS_NOT_DELETE = new ErrorCode(1_050_001_002, "产品状是发布状态,不允许删除"); + ErrorCode PRODUCT_STATUS_NOT_ALLOW_THING_MODEL = new ErrorCode(1_050_001_003, "产品状是发布状态,不允许操作物模型"); + ErrorCode PRODUCT_DELETE_FAIL_HAS_DEVICE = new ErrorCode(1_050_001_004, "产品下存在设备,不允许删除"); + + // ========== 产品物模型 1-050-002-000 ============ + ErrorCode THING_MODEL_NOT_EXISTS = new ErrorCode(1_050_002_000, "产品物模型不存在"); + ErrorCode THING_MODEL_EXISTS_BY_PRODUCT_KEY = new ErrorCode(1_050_002_001, "ProductKey 对应的产品物模型已存在"); + ErrorCode THING_MODEL_IDENTIFIER_EXISTS = new ErrorCode(1_050_002_002, "存在重复的功能标识符。"); + ErrorCode THING_MODEL_NAME_EXISTS = new ErrorCode(1_050_002_003, "存在重复的功能名称。"); + ErrorCode THING_MODEL_IDENTIFIER_INVALID = new ErrorCode(1_050_002_003, "产品物模型标识无效"); + + // ========== 设备 1-050-003-000 ============ + ErrorCode DEVICE_NOT_EXISTS = new ErrorCode(1_050_003_000, "设备不存在"); + ErrorCode DEVICE_NAME_EXISTS = new ErrorCode(1_050_003_001, "设备名称在同一产品下必须唯一"); + ErrorCode DEVICE_HAS_CHILDREN = new ErrorCode(1_050_003_002, "有子设备,不允许删除"); + ErrorCode DEVICE_KEY_EXISTS = new ErrorCode(1_050_003_003, "设备标识已经存在"); + ErrorCode DEVICE_GATEWAY_NOT_EXISTS = new ErrorCode(1_050_003_004, "网关设备不存在"); + ErrorCode DEVICE_NOT_GATEWAY = new ErrorCode(1_050_003_005, "设备不是网关设备"); + ErrorCode DEVICE_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_050_003_006, "导入设备数据不能为空!"); + ErrorCode DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL = new ErrorCode(1_050_003_007, "下行设备消息失败,原因:设备未连接网关"); + ErrorCode DEVICE_SERIAL_NUMBER_EXISTS = new ErrorCode(1_050_003_008, "设备序列号已存在,序列号必须全局唯一"); + + // ========== 产品分类 1-050-004-000 ========== + ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); + + // ========== 设备分组 1-050-005-000 ========== + ErrorCode DEVICE_GROUP_NOT_EXISTS = new ErrorCode(1_050_005_000, "设备分组不存在"); + ErrorCode DEVICE_GROUP_DELETE_FAIL_DEVICE_EXISTS = new ErrorCode(1_050_005_001, "设备分组下存在设备,不允许删除"); + + // ========== OTA 固件相关 1-050-008-000 ========== + + ErrorCode OTA_FIRMWARE_NOT_EXISTS = new ErrorCode(1_050_008_000, "固件信息不存在"); + ErrorCode OTA_FIRMWARE_PRODUCT_VERSION_DUPLICATE = new ErrorCode(1_050_008_001, "产品版本号重复"); + + // ========== OTA 升级任务相关 1-050-008-100 ========== + + ErrorCode OTA_TASK_NOT_EXISTS = new ErrorCode(1_050_008_100, "升级任务不存在"); + ErrorCode OTA_TASK_CREATE_FAIL_NAME_DUPLICATE = new ErrorCode(1_050_008_101, "创建 OTA 任务失败,原因:任务名称重复"); + ErrorCode OTA_TASK_CREATE_FAIL_DEVICE_FIRMWARE_EXISTS = new ErrorCode(1_050_008_102, + "创建 OTA 任务失败,原因:设备({})已经是该固件版本"); + ErrorCode OTA_TASK_CREATE_FAIL_DEVICE_OTA_IN_PROCESS = new ErrorCode(1_050_008_102, + "创建 OTA 任务失败,原因:设备({})已经在升级中..."); + ErrorCode OTA_TASK_CREATE_FAIL_DEVICE_EMPTY = new ErrorCode(1_050_008_103, "创建 OTA 任务失败,原因:没有可升级的设备"); + ErrorCode OTA_TASK_CANCEL_FAIL_STATUS_END = new ErrorCode(1_050_008_104, "取消 OTA 任务失败,原因:任务状态不是进行中"); + + // ========== OTA 升级任务记录相关 1-050-008-200 ========== + + ErrorCode OTA_TASK_RECORD_NOT_EXISTS = new ErrorCode(1_050_008_200, "升级记录不存在"); + ErrorCode OTA_TASK_RECORD_CANCEL_FAIL_STATUS_ERROR = new ErrorCode(1_050_008_201, "取消 OTA 升级记录失败,原因:记录状态不是进行中"); + ErrorCode OTA_TASK_RECORD_UPDATE_PROGRESS_FAIL_NO_EXISTS = new ErrorCode(1_050_008_202, "更新 OTA 升级记录进度失败,原因:该设备没有进行中的升级记录"); + + // ========== IoT 数据流转规则 1-050-010-000 ========== + ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); + + // ========== IoT 数据流转目的 1-050-011-000 ========== + ErrorCode DATA_SINK_NOT_EXISTS = new ErrorCode(1_050_011_000, "数据桥梁不存在"); + ErrorCode DATA_SINK_DELETE_FAIL_USED_BY_RULE = new ErrorCode(1_050_011_001, "数据流转目的正在被数据流转规则使用,无法删除"); + + // ========== IoT 场景联动 1-050-012-000 ========== + ErrorCode RULE_SCENE_NOT_EXISTS = new ErrorCode(1_050_012_000, "场景联动不存在"); + + // ========== IoT 告警配置 1-050-013-000 ========== + ErrorCode ALERT_CONFIG_NOT_EXISTS = new ErrorCode(1_050_013_000, "IoT 告警配置不存在"); + + // ========== IoT 告警记录 1-050-014-000 ========== + ErrorCode ALERT_RECORD_NOT_EXISTS = new ErrorCode(1_050_014_000, "IoT 告警记录不存在"); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ota/IotOtaTaskRecordStatusEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ota/IotOtaTaskRecordStatusEnum.java new file mode 100644 index 0000000000..0f95eb79cc --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ota/IotOtaTaskRecordStatusEnum.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.iot.enums.ota; + + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import cn.iocoder.yudao.framework.common.util.collection.SetUtils; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +/** + * IoT OTA 升级任务记录的状态枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum IotOtaTaskRecordStatusEnum implements ArrayValuable { + + PENDING(0), // 待推送 + PUSHED(10), // 已推送 + UPGRADING(20), // 升级中 + SUCCESS(30), // 升级成功 + FAILURE(40), // 升级失败 + CANCELED(50),; // 升级取消 + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(IotOtaTaskRecordStatusEnum::getStatus).toArray(Integer[]::new); + + public static final Set IN_PROCESS_STATUSES = SetUtils.asSet( + PENDING.getStatus(), + PUSHED.getStatus(), + UPGRADING.getStatus()); + + public static final List PRIORITY_STATUSES = Arrays.asList( + SUCCESS.getStatus(), + PENDING.getStatus(), PUSHED.getStatus(), UPGRADING.getStatus(), + FAILURE.getStatus(), CANCELED.getStatus()); + + /** + * 状态 + */ + private final Integer status; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static IotOtaTaskRecordStatusEnum of(Integer status) { + return ArrayUtil.firstMatch(o -> o.getStatus().equals(status), values()); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java new file mode 100644 index 0000000000..45a557db61 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.iot.enums.rule; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 数据目的的类型枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum IotDataSinkTypeEnum implements ArrayValuable { + + HTTP(1, "HTTP"), + TCP(2, "TCP"), // TODO @puhui999:待实现; + WEBSOCKET(3, "WebSocket"), // TODO @puhui999:待实现; + + MQTT(10, "MQTT"), // TODO 待实现; + + DATABASE(20, "Database"), // TODO @puhui999:待实现;可以简单点,对应的表名是什么,字段先固定了。 + REDIS(21, "Redis"), + + ROCKETMQ(30, "RocketMQ"), + RABBITMQ(31, "RabbitMQ"), + KAFKA(32, "Kafka"); + + private final Integer type; + + private final String name; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotDataSinkTypeEnum::getType).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleActionTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleActionTypeEnum.java new file mode 100644 index 0000000000..7e9e4de631 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleActionTypeEnum.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.iot.enums.rule; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 规则场景的触发类型枚举 + * + * 设备触发,定时触发 + */ +@RequiredArgsConstructor +@Getter +public enum IotSceneRuleActionTypeEnum implements ArrayValuable { + + /** + * 设备属性设置 + * + * 对应 {@link IotDeviceMessageMethodEnum#PROPERTY_SET} + */ + DEVICE_PROPERTY_SET(1), + /** + * 设备服务调用 + * + * 对应 {@link IotDeviceMessageMethodEnum#SERVICE_INVOKE} + */ + DEVICE_SERVICE_INVOKE(2), + + /** + * 告警触发 + */ + ALERT_TRIGGER(100), + /** + * 告警恢复 + */ + ALERT_RECOVER(101), + + ; + + private final Integer type; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotSceneRuleActionTypeEnum::getType).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionTypeEnum.java new file mode 100644 index 0000000000..81d7e6e1f5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleConditionTypeEnum.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.enums.rule; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 条件类型枚举 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum IotSceneRuleConditionTypeEnum implements ArrayValuable { + + DEVICE_STATE(1, "设备状态"), + DEVICE_PROPERTY(2, "设备属性"), + + CURRENT_TIME(100, "当前时间"), + + ; + + private final Integer type; + private final String name; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotSceneRuleConditionTypeEnum::getType).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static IotSceneRuleConditionTypeEnum typeOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleTriggerTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleTriggerTypeEnum.java new file mode 100644 index 0000000000..bfc84c9f60 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotSceneRuleTriggerTypeEnum.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.iot.enums.rule; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 场景流转的触发类型枚举 + * + * 为什么不直接使用 IotDeviceMessageMethodEnum 呢? + * 原因是,物模型属性上报,存在批量上报的情况,不只对应一个 method!!! + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum IotSceneRuleTriggerTypeEnum implements ArrayValuable { + + // TODO @芋艿:后续“对应”部分,要 @下,等包结构梳理完; + /** + * 设备上下线变更 + * + * 对应 IotDeviceMessageMethodEnum.STATE_UPDATE + */ + DEVICE_STATE_UPDATE(1), + /** + * 物模型属性上报 + * + * 对应 IotDeviceMessageMethodEnum.DEVICE_PROPERTY_POST + */ + DEVICE_PROPERTY_POST(2), + /** + * 设备事件上报 + * + * 对应 IotDeviceMessageMethodEnum.DEVICE_EVENT_POST + */ + DEVICE_EVENT_POST(3), + /** + * 设备服务调用 + * + * 对应 IotDeviceMessageMethodEnum.DEVICE_SERVICE_INVOKE + */ + DEVICE_SERVICE_INVOKE(4), + + /** + * 定时触发 + */ + TIMER(100) + + ; + + private final Integer type; + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotSceneRuleTriggerTypeEnum::getType).toArray(Integer[]::new); + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static IotSceneRuleTriggerTypeEnum typeOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iot/config/YudaoIotProperties.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iot/config/YudaoIotProperties.java new file mode 100644 index 0000000000..07473c0293 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iot/config/YudaoIotProperties.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.iot.framework.iot.config; + +import lombok.Data; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * 芋道 IoT 全局配置类 + * + * @author 芋道源码 + */ +@Component +@Data +public class YudaoIotProperties { + + /** + * 设备连接超时时间 + */ + private Duration keepAliveTime = Duration.ofMinutes(10); + /** + * 设备连接超时时间的因子 + * + * 因为设备可能会有网络抖动,所以需要乘以一个因子,避免误判 + */ + private double keepAliveFactor = 1.5D; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iot/package-info.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iot/package-info.java new file mode 100644 index 0000000000..0930a1409c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iot/package-info.java @@ -0,0 +1,4 @@ +/** + * iot 模块的【全局】拓展封装 + */ +package cn.iocoder.yudao.module.iot.framework.iot; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java new file mode 100644 index 0000000000..8a15c5e7bb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.iot.job.ota; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskRecordStatusEnum; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaFirmwareService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * IoT OTA 升级推送 Job:查询待推送的 OTA 升级记录,并推送给设备 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class IotOtaUpgradeJob implements JobHandler { + + @Resource + private IotOtaTaskRecordService otaTaskRecordService; + @Resource + private IotOtaFirmwareService otaFirmwareService; + @Resource + private IotDeviceService deviceService; + + @Override + @TenantJob + public String execute(String param) throws Exception { + // 1. 查询待推送的 OTA 升级记录 + List records = otaTaskRecordService.getOtaRecordListByStatus( + IotOtaTaskRecordStatusEnum.PENDING.getStatus()); + if (CollUtil.isEmpty(records)) { + return null; + } + + // TODO 芋艿:可以优化成批量获取 原因是:1. N+1 问题;2. offline 的设备无需查询 + // 2. 遍历推送记录 + int successCount = 0; + int failureCount = 0; + Map otaFirmwares = new HashMap<>(); + for (IotOtaTaskRecordDO record : records) { + try { + // 2.1 设备如果不在线,直接跳过 + IotDeviceDO device = deviceService.getDeviceFromCache(record.getDeviceId()); + // TODO 芋艿:【优化】当前逻辑跳过了离线的设备,但未充分利用 MQTT 的离线消息能力。 + // 1. MQTT 协议本身支持持久化会话(Clean Session=false)和 QoS > 0 的消息,允许 broker 为离线设备缓存消息。 + // 2. 对于 OTA 升级这类非实时性强的任务,即使设备当前离线,也应该可以推送升级指令。设备在下次上线时即可收到。 + // 3. 后续可以考虑:增加一个“允许离线推送”的选项。如果开启,即使设备状态为 OFFLINE,也应尝试推送消息,依赖 MQTT Broker 的能力进行离线缓存。 + if (device == null || IotDeviceStateEnum.isNotOnline(device.getState())) { + continue; + } + // 2.2 获取 OTA 固件信息 + IotOtaFirmwareDO fireware = otaFirmwares.get(record.getFirmwareId()); + if (fireware == null) { + fireware = otaFirmwareService.getOtaFirmware(record.getFirmwareId()); + otaFirmwares.put(record.getFirmwareId(), fireware); + } + // 2.3 推送 OTA 升级任务 + boolean result = otaTaskRecordService.pushOtaTaskRecord(record, fireware, device); + if (result) { + successCount++; + } else { + failureCount++; + } + } catch (Exception e) { + failureCount++; + log.error("[execute][推送 OTA 升级任务({})发生异常]", record.getId(), e); + } + } + return StrUtil.format("升级任务推送成功:{} 条,送失败:{} 条", successCount, failureCount); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/rule/IotSceneRuleJob.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/rule/IotSceneRuleJob.java new file mode 100644 index 0000000000..9967ccc3b1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/rule/IotSceneRuleJob.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.iot.job.rule; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobExecutionContext; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.util.Map; + +/** + * IoT 规则场景 Job,用于执行 {@link IotSceneRuleTriggerTypeEnum#TIMER} 类型的规则场景 + * + * @author 芋道源码 + */ +@Slf4j +public class IotSceneRuleJob extends QuartzJobBean { + + /** + * JobData Key - 规则场景编号 + */ + public static final String JOB_DATA_KEY_RULE_SCENE_ID = "sceneRuleId"; + + @Resource + private IotSceneRuleService sceneRuleService; + + @Override + protected void executeInternal(JobExecutionContext context) { + // 获得规则场景编号 + Long sceneRuleId = context.getMergedJobDataMap().getLong(JOB_DATA_KEY_RULE_SCENE_ID); + + // 执行规则场景 + sceneRuleService.executeSceneRuleByTimer(sceneRuleId); + } + + /** + * 创建 JobData Map + * + * @param sceneRuleId 规则场景编号 + * @return JobData Map + */ + public static Map buildJobDataMap(Long sceneRuleId) { + return MapUtil.of(JOB_DATA_KEY_RULE_SCENE_ID, sceneRuleId); + } + + /** + * 创建 Job 名字 + * + * @param sceneRuleId 规则场景编号 + * @return Job 名字 + */ + public static String buildJobName(Long sceneRuleId) { + return String.format("%s_%d", IotSceneRuleJob.class.getSimpleName(), sceneRuleId); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java new file mode 100644 index 0000000000..7e039d0327 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.iot.mq.consumer.device; + +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 针对 {@link IotDeviceMessage} 的业务处理器:调用 method 对应的逻辑。例如说: + * 1. {@link IotDeviceMessageMethodEnum#PROPERTY_POST} 属性上报时,记录设备属性 + * + * @author alwayssuper + */ +@Component +@Slf4j +public class IotDeviceMessageSubscriber implements IotMessageSubscriber { + + @Resource + private IotDeviceService deviceService; + @Resource + private IotDevicePropertyService devicePropertyService; + @Resource + private IotDeviceMessageService deviceMessageService; + + @Resource + private IotMessageBus messageBus; + + @PostConstruct + public void init() { + messageBus.register(this); + } + + @Override + public String getTopic() { + return IotDeviceMessage.MESSAGE_BUS_DEVICE_MESSAGE_TOPIC; + } + + @Override + public String getGroup() { + return "iot_device_message_consumer"; + } + + @Override + public void onMessage(IotDeviceMessage message) { + if (!IotDeviceMessageUtils.isUpstreamMessage(message)) { + log.error("[onMessage][message({}) 非上行消息,不进行处理]", message); + return; + } + + TenantUtils.execute(message.getTenantId(), () -> { + // 1.1 更新设备的最后时间 + IotDeviceDO device = deviceService.validateDeviceExistsFromCache(message.getDeviceId()); + devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now()); + // 1.2 更新设备的连接 server + // TODO 芋艿:HTTP 网关的上行消息,不应该更新 serverId,会覆盖掉 MQTT 等长连接的 serverId,导致下行消息无法发送。 + devicePropertyService.updateDeviceServerIdAsync(device.getId(), message.getServerId()); + + // 2. 未上线的设备,强制上线 + forceDeviceOnline(message, device); + + // 3. 核心:处理消息 + deviceMessageService.handleUpstreamDeviceMessage(message, device); + }); + } + + private void forceDeviceOnline(IotDeviceMessage message, IotDeviceDO device) { + // 已经在线,无需处理 + if (ObjectUtil.equal(device.getState(), IotDeviceStateEnum.ONLINE.getState())) { + return; + } + // 如果是 STATE 相关的消息,无需处理,不然就重复处理状态了 + if (Objects.equals(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { + return; + } + + // 特殊:设备非在线时,主动标记设备为在线 + // 为什么不直接更新状态呢?因为通过 IotDeviceMessage 可以经过一系列的处理,例如说记录日志、规则引擎等等 + try { + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline().setDeviceId(device.getId())); + } catch (Exception e) { + // 注意:即使执行失败,也不影响主流程 + log.error("[forceDeviceOnline][message({}) device({}) 强制设备上线失败]", message, device, e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotDataRuleMessageHandler.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotDataRuleMessageHandler.java new file mode 100644 index 0000000000..843592a272 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotDataRuleMessageHandler.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.mq.consumer.rule; + +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.service.rule.data.IotDataRuleService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 针对 {@link IotDeviceMessage} 的消费者,处理数据流转 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class IotDataRuleMessageHandler implements IotMessageSubscriber { + + @Resource + private IotDataRuleService dataRuleService; + + @Resource + private IotMessageBus messageBus; + + @PostConstruct + public void init() { + messageBus.register(this); + } + + @Override + public String getTopic() { + return IotDeviceMessage.MESSAGE_BUS_DEVICE_MESSAGE_TOPIC; + } + + @Override + public String getGroup() { + return "iot_data_rule_consumer"; + } + + @Override + public void onMessage(IotDeviceMessage message) { + TenantUtils.execute(message.getTenantId(), () -> dataRuleService.executeDataRule(message)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java new file mode 100644 index 0000000000..c39cefe4ab --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.iot.mq.consumer.rule; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +// TODO @puhui999:后面重构哈 +/** + * 针对 {@link IotDeviceMessage} 的消费者,处理规则场景 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class IotSceneRuleMessageHandler implements IotMessageSubscriber { + + @Resource + private IotSceneRuleService sceneRuleService; + + @Resource + private IotMessageBus messageBus; + + @PostConstruct + public void init() { + messageBus.register(this); + } + + @Override + public String getTopic() { + return IotDeviceMessage.MESSAGE_BUS_DEVICE_MESSAGE_TOPIC; + } + + @Override + public String getGroup() { + return "iot_rule_consumer"; + } + + @Override + public void onMessage(IotDeviceMessage message) { + if (true) { + return; + } + log.info("[onMessage][消息内容({})]", message); + sceneRuleService.executeSceneRuleByDevice(message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertConfigService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertConfigService.java new file mode 100644 index 0000000000..d58d42789c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertConfigService.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.iot.service.alert; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * IoT 告警配置 Service 接口 + * + * @author 芋道源码 + */ +public interface IotAlertConfigService { + + /** + * 创建告警配置 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createAlertConfig(@Valid IotAlertConfigSaveReqVO createReqVO); + + /** + * 更新告警配置 + * + * @param updateReqVO 更新信息 + */ + void updateAlertConfig(@Valid IotAlertConfigSaveReqVO updateReqVO); + + /** + * 删除告警配置 + * + * @param id 编号 + */ + void deleteAlertConfig(Long id); + + /** + * 获得告警配置 + * + * @param id 编号 + * @return 告警配置 + */ + IotAlertConfigDO getAlertConfig(Long id); + + /** + * 获得告警配置分页 + * + * @param pageReqVO 分页查询 + * @return 告警配置分页 + */ + PageResult getAlertConfigPage(IotAlertConfigPageReqVO pageReqVO); + + /** + * 获得告警配置列表 + * + * @param status 状态 + * @return 告警配置列表 + */ + List getAlertConfigListByStatus(Integer status); + + /** + * 获得告警配置列表 + * + * @param sceneRuleId 场景流动规则编号 + * @return 告警配置列表 + */ + List getAlertConfigListBySceneRuleIdAndStatus(Long sceneRuleId, Integer status); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertConfigServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertConfigServiceImpl.java new file mode 100644 index 0000000000..aa9378767a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertConfigServiceImpl.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.iot.service.alert; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.mysql.alert.IotAlertConfigMapper; +import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.ALERT_CONFIG_NOT_EXISTS; + +/** + * IoT 告警配置 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class IotAlertConfigServiceImpl implements IotAlertConfigService { + + @Resource + private IotAlertConfigMapper alertConfigMapper; + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private IotSceneRuleService sceneRuleService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + public Long createAlertConfig(IotAlertConfigSaveReqVO createReqVO) { + // 校验关联数据是否存在 + sceneRuleService.validateSceneRuleList(createReqVO.getSceneRuleIds()); + adminUserApi.validateUserList(createReqVO.getReceiveUserIds()); + + // 插入 + IotAlertConfigDO alertConfig = BeanUtils.toBean(createReqVO, IotAlertConfigDO.class); + alertConfigMapper.insert(alertConfig); + return alertConfig.getId(); + } + + @Override + public void updateAlertConfig(IotAlertConfigSaveReqVO updateReqVO) { + // 校验存在 + validateAlertConfigExists(updateReqVO.getId()); + // 校验关联数据是否存在 + sceneRuleService.validateSceneRuleList(updateReqVO.getSceneRuleIds()); + adminUserApi.validateUserList(updateReqVO.getReceiveUserIds()); + + // 更新 + IotAlertConfigDO updateObj = BeanUtils.toBean(updateReqVO, IotAlertConfigDO.class); + alertConfigMapper.updateById(updateObj); + } + + @Override + public void deleteAlertConfig(Long id) { + // 校验存在 + validateAlertConfigExists(id); + // 删除 + alertConfigMapper.deleteById(id); + } + + private void validateAlertConfigExists(Long id) { + if (alertConfigMapper.selectById(id) == null) { + throw exception(ALERT_CONFIG_NOT_EXISTS); + } + } + + @Override + public IotAlertConfigDO getAlertConfig(Long id) { + return alertConfigMapper.selectById(id); + } + + @Override + public PageResult getAlertConfigPage(IotAlertConfigPageReqVO pageReqVO) { + return alertConfigMapper.selectPage(pageReqVO); + } + + @Override + public List getAlertConfigListByStatus(Integer status) { + return alertConfigMapper.selectListByStatus(status); + } + + @Override + public List getAlertConfigListBySceneRuleIdAndStatus(Long sceneRuleId, Integer status) { + return alertConfigMapper.selectListBySceneRuleIdAndStatus(sceneRuleId, status); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java new file mode 100644 index 0000000000..68a2da97c9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.iot.service.alert; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordPageReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; +import jakarta.validation.constraints.NotNull; + +import java.util.Collection; +import java.util.List; + +/** + * IoT 告警记录 Service 接口 + * + * @author 芋道源码 + */ +public interface IotAlertRecordService { + + /** + * 获得告警记录 + * + * @param id 编号 + * @return 告警记录 + */ + IotAlertRecordDO getAlertRecord(Long id); + + /** + * 获得告警记录分页 + * + * @param pageReqVO 分页查询 + * @return 告警记录分页 + */ + PageResult getAlertRecordPage(IotAlertRecordPageReqVO pageReqVO); + + /** + * 获得指定场景规则的告警记录列表 + * + * @param sceneRuleId 场景规则编号 + * @param deviceId 设备编号 + * @param processStatus 处理状态,允许空 + * @return 告警记录列表 + */ + List getAlertRecordListBySceneRuleId(@NotNull(message = "场景规则编号不能为空") Long sceneRuleId, + Long deviceId, Boolean processStatus); + + /** + * 处理告警记录 + * + * @param ids 告警记录编号 + * @param remark 处理结果(备注) + */ + void processAlertRecordList(Collection ids, String remark); + + /** + * 创建告警记录(包含场景规则编号) + * + * @param config 告警配置 + * @param sceneRuleId 场景规则编号 + * @param deviceMessage 设备消息,可为空 + * @return 告警记录编号 + */ + Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, IotDeviceMessage deviceMessage); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java new file mode 100644 index 0000000000..34a673a4b5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/alert/IotAlertRecordServiceImpl.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.iot.service.alert; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordPageReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.mysql.alert.IotAlertRecordMapper; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +/** + * IoT 告警记录 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class IotAlertRecordServiceImpl implements IotAlertRecordService { + + @Resource + private IotAlertRecordMapper alertRecordMapper; + + @Resource + private IotDeviceService deviceService; + + @Override + public IotAlertRecordDO getAlertRecord(Long id) { + return alertRecordMapper.selectById(id); + } + + @Override + public PageResult getAlertRecordPage(IotAlertRecordPageReqVO pageReqVO) { + return alertRecordMapper.selectPage(pageReqVO); + } + + @Override + public List getAlertRecordListBySceneRuleId(Long sceneRuleId, Long deviceId, Boolean processStatus) { + return alertRecordMapper.selectListBySceneRuleId(sceneRuleId, deviceId, processStatus); + } + + @Override + public void processAlertRecordList(Collection ids, String processRemark) { + if (CollUtil.isEmpty(ids)) { + return; + } + // 批量更新告警记录的处理状态 + alertRecordMapper.updateList(ids, IotAlertRecordDO.builder() + .processStatus(true).processRemark(processRemark).build()); + } + + @Override + public Long createAlertRecord(IotAlertConfigDO config, Long sceneRuleId, IotDeviceMessage message) { + // 构建告警记录 + IotAlertRecordDO.IotAlertRecordDOBuilder builder = IotAlertRecordDO.builder() + .configId(config.getId()).configName(config.getName()).configLevel(config.getLevel()) + .sceneRuleId(sceneRuleId).processStatus(false); + if (message != null) { + builder.deviceMessage(message); + // 填充设备信息 + IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId()); + if (device != null) { + builder.productId(device.getProductId()).deviceId(device.getId()); + } + } + + // 插入记录 + IotAlertRecordDO record = builder.build(); + alertRecordMapper.insert(record); + return record.getId(); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java new file mode 100644 index 0000000000..4a300dfc30 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java @@ -0,0 +1,98 @@ +package cn.iocoder.yudao.module.iot.service.device.message; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import javax.annotation.Nullable; +import java.time.LocalDateTime; +import java.util.List; + +/** + * IoT 设备消息 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDeviceMessageService { + + /** + * 初始化设备消息的 TDengine 超级表 + * + * 系统启动时,会自动初始化一次 + */ + void defineDeviceMessageStable(); + + /** + * 发送设备消息 + * + * @param message 消息(“codec(编解码)字段” 部分字段) + * @param device 设备 + * @return 设备消息 + */ + IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device); + + /** + * 发送设备消息 + * + * @param message 消息(“codec(编解码)字段” 部分字段) + * @return 设备消息 + */ + IotDeviceMessage sendDeviceMessage(IotDeviceMessage message); + + /** + * 处理设备上行的消息,包括如下步骤: + * + * 1. 处理消息 + * 2. 记录消息 + * 3. 回复消息 + * + * @param message 消息 + * @param device 设备 + */ + void handleUpstreamDeviceMessage(IotDeviceMessage message, IotDeviceDO device); + + /** + * 获得设备消息分页 + * + * @param pageReqVO 分页查询 + * @return 设备消息分页 + */ + PageResult getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO); + + /** + * 获得指定 requestId 的设备消息列表 + * + * @param deviceId 设备编号 + * @param requestIds requestId 列表 + * @param reply 是否回复 + * @return 设备消息列表 + */ + List getDeviceMessageListByRequestIdsAndReply( + @NotNull(message = "设备编号不能为空") Long deviceId, + @NotEmpty(message = "请求编号不能为空") List requestIds, + Boolean reply); + + /** + * 获得设备消息数量 + * + * @param createTime 创建时间,如果为空,则统计所有消息数量 + * @return 消息数量 + */ + Long getDeviceMessageCount(@Nullable LocalDateTime createTime); + + /** + * 获取设备消息的数据统计 + * + * @param reqVO 统计请求 + * @return 设备消息的数据统计 + */ + List getDeviceMessageSummaryByDate( + IotStatisticsDeviceMessageReqVO reqVO); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java new file mode 100644 index 0000000000..01d1c45eee --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java @@ -0,0 +1,271 @@ +package cn.iocoder.yudao.module.iot.service.device.message; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; +import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.google.common.base.Objects; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL; + +/** + * IoT 设备消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { + + @Resource + private IotDeviceService deviceService; + @Resource + private IotDevicePropertyService devicePropertyService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private IotOtaTaskRecordService otaTaskRecordService; + + @Resource + private IotDeviceMessageMapper deviceMessageMapper; + + @Resource + private IotDeviceMessageProducer deviceMessageProducer; + + @Override + public void defineDeviceMessageStable() { + if (StrUtil.isNotEmpty(deviceMessageMapper.showSTable())) { + log.info("[defineDeviceMessageStable][设备消息超级表已存在,创建跳过]"); + return; + } + log.info("[defineDeviceMessageStable][设备消息超级表不存在,创建开始...]"); + deviceMessageMapper.createSTable(); + log.info("[defineDeviceMessageStable][设备消息超级表不存在,创建成功]"); + } + + @Async + void createDeviceLogAsync(IotDeviceMessage message) { + IotDeviceMessageDO messageDO = BeanUtils.toBean(message, IotDeviceMessageDO.class) + .setUpstream(IotDeviceMessageUtils.isUpstreamMessage(message)) + .setReply(IotDeviceMessageUtils.isReplyMessage(message)) + .setIdentifier(IotDeviceMessageUtils.getIdentifier(message)); + if (message.getParams() != null) { + messageDO.setParams(JsonUtils.toJsonString(messageDO.getParams())); + } + if (messageDO.getData() != null) { + messageDO.setData(JsonUtils.toJsonString(messageDO.getData())); + } + deviceMessageMapper.insert(messageDO); + } + + @Override + public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message) { + IotDeviceDO device = deviceService.validateDeviceExists(message.getDeviceId()); + return sendDeviceMessage(message, device); + } + + // TODO @芋艿:针对连接网关的设备,是不是 productKey、deviceName 需要调整下; + @Override + public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device) { + return sendDeviceMessage(message, device, null); + } + + private IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { + // 1. 补充信息 + appendDeviceMessage(message, device); + + // 2.1 情况一:发送上行消息 + boolean upstream = IotDeviceMessageUtils.isUpstreamMessage(message); + if (upstream) { + deviceMessageProducer.sendDeviceMessage(message); + return message; + } + + // 2.2 情况二:发送下行消息 + // 如果是下行消息,需要校验 serverId 存在 + // TODO 芋艿:【设计】下行消息需要区分 PUSH 和 PULL 模型 + // 1. PUSH 模型:适用于 MQTT 等长连接协议。通过 serverId 将消息路由到指定网关,实时推送。 + // 2. PULL 模型:适用于 HTTP 等短连接协议。设备无固定 serverId,无法主动推送。 + // 解决方案: + // 当 serverId 不存在时,将下行消息存入“待拉取消息表”(例如 iot_device_pull_message)。 + // 设备端通过定时轮询一个新增的 API(例如 /iot/message/pull)来拉取属于自己的消息。 + if (StrUtil.isEmpty(serverId)) { + serverId = devicePropertyService.getDeviceServerId(device.getId()); + if (StrUtil.isEmpty(serverId)) { + throw exception(DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL); + } + } + deviceMessageProducer.sendDeviceMessageToGateway(serverId, message); + // 特殊:记录消息日志。原因:上行消息,消费时,已经会记录;下行消息,因为消费在 Gateway 端,所以需要在这里记录 + getSelf().createDeviceLogAsync(message); + return message; + } + + /** + * 补充消息的后端字段 + * + * @param message 消息 + * @param device 设备信息 + */ + private void appendDeviceMessage(IotDeviceMessage message, IotDeviceDO device) { + message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now()) + .setDeviceId(device.getId()).setTenantId(device.getTenantId()); + // 特殊:如果设备没有指定 requestId,则使用 messageId + if (StrUtil.isEmpty(message.getRequestId())) { + message.setRequestId(message.getId()); + } + } + + @Override + public void handleUpstreamDeviceMessage(IotDeviceMessage message, IotDeviceDO device) { + // 1. 处理消息 + Object replyData = null; + ServiceException serviceException = null; + try { + replyData = handleUpstreamDeviceMessage0(message, device); + } catch (ServiceException ex) { + serviceException = ex; + log.warn("[handleUpstreamDeviceMessage][message({}) 业务异常]", message, serviceException); + } catch (Exception ex) { + log.error("[handleUpstreamDeviceMessage][message({}) 发生异常]", message, ex); + throw ex; + } + + // 2. 记录消息 + getSelf().createDeviceLogAsync(message); + + // 3. 回复消息。前提:非 _reply 消息,并且非禁用回复的消息 + if (IotDeviceMessageUtils.isReplyMessage(message) + || IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod()) + || StrUtil.isEmpty(message.getServerId())) { + return; + } + try { + IotDeviceMessage replyMessage = IotDeviceMessage.replyOf(message.getRequestId(), message.getMethod(), replyData, + serviceException != null ? serviceException.getCode() : null, + serviceException != null ? serviceException.getMessage() : null); + sendDeviceMessage(replyMessage, device, message.getServerId()); + } catch (Exception ex) { + log.error("[handleUpstreamDeviceMessage][message({}) 回复消息失败]", message, ex); + } + } + + // TODO @芋艿:可优化:未来逻辑复杂后,可以独立拆除 Processor 处理器 + @SuppressWarnings("SameReturnValue") + private Object handleUpstreamDeviceMessage0(IotDeviceMessage message, IotDeviceDO device) { + // 设备上下线 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { + String stateStr = IotDeviceMessageUtils.getIdentifier(message); + assert stateStr != null; + Assert.notEmpty(stateStr, "设备状态不能为空"); + deviceService.updateDeviceState(device, Integer.valueOf(stateStr)); + // TODO 芋艿:子设备的关联 + return null; + } + + // 属性上报 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())) { + devicePropertyService.saveDeviceProperty(device, message); + return null; + } + + // OTA 上报升级进度 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.OTA_PROGRESS.getMethod())) { + otaTaskRecordService.updateOtaRecordProgress(device, message); + return null; + } + + // TODO @芋艿:这里可以按需,添加别的逻辑; + return null; + } + + @Override + public PageResult getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) { + try { + IPage page = deviceMessageMapper.selectPage( + new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize()), pageReqVO); + return new PageResult<>(page.getRecords(), page.getTotal()); + } catch (Exception exception) { + if (exception.getMessage().contains("Table does not exist")) { + return PageResult.empty(); + } + throw exception; + } + } + + @Override + public List getDeviceMessageListByRequestIdsAndReply(Long deviceId, + List requestIds, + Boolean reply) { + return deviceMessageMapper.selectListByRequestIdsAndReply(deviceId, requestIds, reply); + } + + @Override + public Long getDeviceMessageCount(LocalDateTime createTime) { + return deviceMessageMapper.selectCountByCreateTime( + createTime != null ? LocalDateTimeUtil.toEpochMilli(createTime) : null); + } + + @Override + public List getDeviceMessageSummaryByDate( + IotStatisticsDeviceMessageReqVO reqVO) { + // 1. 按小时统计,获取分项统计数据 + List> countList = deviceMessageMapper.selectDeviceMessageCountGroupByDate( + LocalDateTimeUtil.toEpochMilli(reqVO.getTimes()[0]), + LocalDateTimeUtil.toEpochMilli(reqVO.getTimes()[1])); + + // 2. 按照日期间隔,合并数据 + List timeRanges = LocalDateTimeUtils.getDateRangeList(reqVO.getTimes()[0], reqVO.getTimes()[1], + reqVO.getInterval()); + return convertList(timeRanges, times -> { + Integer upstreamCount = countList.stream() + .filter(vo -> LocalDateTimeUtils.isBetween(times[0], times[1], (Timestamp) vo.get("time"))) + .mapToInt(value -> MapUtil.getInt(value, "upstream_count")).sum(); + Integer downstreamCount = countList.stream() + .filter(vo -> LocalDateTimeUtils.isBetween(times[0], times[1], (Timestamp) vo.get("time"))) + .mapToInt(value -> MapUtil.getInt(value, "downstream_count")).sum(); + return new IotStatisticsDeviceMessageSummaryByDateRespVO() + .setTime(LocalDateTimeUtils.formatDateRange(times[0], times[1], reqVO.getInterval())) + .setUpstreamCount(upstreamCount).setDownstreamCount(downstreamCount); + }); + } + + private IotDeviceMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java new file mode 100644 index 0000000000..24c117d655 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyService.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.iot.service.device.property; + +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; +import jakarta.validation.Valid; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * IoT 设备【属性】数据 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDevicePropertyService { + + // ========== 设备属性相关操作 ========== + + /** + * 定义设备属性数据的结构 + * + * @param productId 产品编号 + */ + void defineDevicePropertyData(Long productId); + + /** + * 保存设备数据 + * + * @param device 设备 + * @param message 设备消息 + */ + void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message); + + /** + * 获得设备属性最新数据 + * + * @param deviceId 设备编号 + * @return 设备属性最新数据 + */ + Map getLatestDeviceProperties(Long deviceId); + + /** + * 获得设备属性历史数据 + * + * @param listReqVO 列表请求 + * @return 设备属性历史数据 + */ + List getHistoryDevicePropertyList(@Valid IotDevicePropertyHistoryListReqVO listReqVO); + + // ========== 设备时间相关操作 ========== + + /** + * 获得最后上报时间小于指定时间的设备编号集合 + * + * @param maxReportTime 最大上报时间 + * @return 设备编号集合 + */ + Set getDeviceIdListByReportTime(LocalDateTime maxReportTime); + + /** + * 更新设备上报时间 + * + * @param id 设备编号 + * @param reportTime 上报时间 + */ + void updateDeviceReportTimeAsync(Long id, LocalDateTime reportTime); + + /** + * 更新设备关联的网关服务 serverId + * + * @param id 设备编号 + * @param serverId 网关 serverId + */ + void updateDeviceServerIdAsync(Long id, String serverId); + + /** + * 获得设备关联的网关服务 serverId + * + * @param id 设备编号 + * @return 网关 serverId + */ + String getDeviceServerId(Long id); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordService.java new file mode 100644 index 0000000000..be9db71ecb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordService.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.iot.service.ota; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record.IotOtaTaskRecordPageReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; +import jakarta.validation.Valid; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * IoT OTA 升级记录 Service 接口 + */ +public interface IotOtaTaskRecordService { + + /** + * 批量创建 OTA 升级记录 + * + * @param devices 设备列表 + * @param firmwareId 固件编号 + * @param taskId 任务编号 + */ + void createOtaTaskRecordList(List devices, Long firmwareId, Long taskId); + + /** + * 获取 OTA 升级记录的状态统计 + * + * @param firmwareId 固件编号 + * @param taskId 任务编号 + * @return 状态统计 Map,key 为状态码,value 为对应状态的升级记录数量 + */ + Map getOtaTaskRecordStatusStatistics(Long firmwareId, Long taskId); + + /** + * 获取 OTA 升级记录 + * + * @param id 编号 + * @return OTA 升级记录 + */ + IotOtaTaskRecordDO getOtaTaskRecord(Long id); + + /** + * 获取 OTA 升级记录分页 + * + * @param pageReqVO 分页查询 + * @return OTA 升级记录分页 + */ + PageResult getOtaTaskRecordPage(@Valid IotOtaTaskRecordPageReqVO pageReqVO); + + /** + * 根据 OTA 任务编号,取消未结束的升级记录 + * + * @param taskId 升级任务编号 + */ + void cancelTaskRecordListByTaskId(Long taskId); + + /** + * 根据设备编号和记录状态,获取 OTA 升级记录列表 + * + * @param deviceIds 设备编号集合 + * @param statuses 记录状态集合 + * @return OTA 升级记录列表 + */ + List getOtaTaskRecordListByDeviceIdAndStatus(Set deviceIds, Set statuses); + + /** + * 根据记录状态,获取 OTA 升级记录列表 + * + * @param status 升级记录状态 + * @return 升级记录列表 + */ + List getOtaRecordListByStatus(Integer status); + + /** + * 取消 OTA 升级记录 + * + * @param id 记录编号 + */ + void cancelOtaTaskRecord(Long id); + + /** + * 推送 OTA 升级任务记录 + * + * @param record 任务记录 + * @param fireware 固件信息 + * @param device 设备信息 + * @return 是否推送成功 + */ + boolean pushOtaTaskRecord(IotOtaTaskRecordDO record, IotOtaFirmwareDO fireware, IotDeviceDO device); + + /** + * 更新 OTA 升级记录进度 + * + * @param device 设备信息 + * @param message 设备消息 + */ + void updateOtaRecordProgress(IotDeviceDO device, IotDeviceMessage message); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java new file mode 100644 index 0000000000..eb75b91540 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java @@ -0,0 +1,231 @@ +package cn.iocoder.yudao.module.iot.service.ota; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record.IotOtaTaskRecordPageReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; +import cn.iocoder.yudao.module.iot.dal.mysql.ota.IotOtaTaskRecordMapper; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskRecordStatusEnum; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; + +/** + * OTA 升级任务记录 Service 实现类 + */ +@Service +@Validated +@Slf4j +public class IotOtaTaskRecordServiceImpl implements IotOtaTaskRecordService { + + @Resource + private IotOtaTaskRecordMapper otaTaskRecordMapper; + + @Resource + private IotOtaFirmwareService otaFirmwareService; + @Resource + private IotOtaTaskService otaTaskService; + @Resource + private IotDeviceMessageService deviceMessageService; + @Resource + private IotDeviceService deviceService; + + @Override + public void createOtaTaskRecordList(List devices, Long firmwareId, Long taskId) { + List records = convertList(devices, device -> + IotOtaTaskRecordDO.builder().firmwareId(firmwareId).taskId(taskId) + .deviceId(device.getId()).fromFirmwareId(Convert.toLong(device.getFirmwareId())) + .status(IotOtaTaskRecordStatusEnum.PENDING.getStatus()).progress(0).build()); + otaTaskRecordMapper.insertBatch(records); + } + + @Override + public Map getOtaTaskRecordStatusStatistics(Long firmwareId, Long taskId) { + // 按照 status 枚举,初始化 countMap 为 0 + Map countMap = convertMap(Arrays.asList(IotOtaTaskRecordStatusEnum.values()), + IotOtaTaskRecordStatusEnum::getStatus, iotOtaTaskRecordStatusEnum -> 0L); + + // 查询记录,只返回 id、status 字段 + List records = otaTaskRecordMapper.selectListByFirmwareIdAndTaskId(firmwareId, taskId); + Map> deviceStatusesMap = convertMultiMap(records, + IotOtaTaskRecordDO::getDeviceId, IotOtaTaskRecordDO::getStatus); + // 找到第一个匹配的优先级状态,避免重复计算 + deviceStatusesMap.forEach((deviceId, statuses) -> { + for (Integer priorityStatus : IotOtaTaskRecordStatusEnum.PRIORITY_STATUSES) { + if (statuses.contains(priorityStatus)) { + countMap.put(priorityStatus, countMap.get(priorityStatus) + 1); + return; + } + } + }); + return countMap; + } + + @Override + public IotOtaTaskRecordDO getOtaTaskRecord(Long id) { + return otaTaskRecordMapper.selectById(id); + } + + @Override + public PageResult getOtaTaskRecordPage(IotOtaTaskRecordPageReqVO pageReqVO) { + return otaTaskRecordMapper.selectPage(pageReqVO); + } + + @Override + public void cancelTaskRecordListByTaskId(Long taskId) { + List records = otaTaskRecordMapper.selectListByTaskIdAndStatus( + taskId, IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES); + if (CollUtil.isEmpty(records)) { + return; + } + // 批量更新 + Collection ids = convertSet(records, IotOtaTaskRecordDO::getId); + otaTaskRecordMapper.updateListByIdAndStatus(ids, IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES, + IotOtaTaskRecordDO.builder().status(IotOtaTaskRecordStatusEnum.CANCELED.getStatus()) + .description(IotOtaTaskRecordDO.DESCRIPTION_CANCEL_BY_TASK).build()); + } + + @Override + public List getOtaTaskRecordListByDeviceIdAndStatus(Set deviceIds, Set statuses) { + return otaTaskRecordMapper.selectListByDeviceIdAndStatus(deviceIds, statuses); + } + + @Override + public List getOtaRecordListByStatus(Integer status) { + return otaTaskRecordMapper.selectListByStatus(status); + } + + @Override + public void cancelOtaTaskRecord(Long id) { + // 1. 校验记录是否存在 + IotOtaTaskRecordDO record = validateUpgradeRecordExists(id); + + // 2. 更新记录状态为取消 + int updateCount = otaTaskRecordMapper.updateByIdAndStatus(record.getId(), IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES, + IotOtaTaskRecordDO.builder().id(id).status(IotOtaTaskRecordStatusEnum.CANCELED.getStatus()) + .description(IotOtaTaskRecordDO.DESCRIPTION_CANCEL_BY_RECORD).build()); + if (updateCount == 0) { + throw exception(OTA_TASK_RECORD_CANCEL_FAIL_STATUS_ERROR); + } + + // 3. 检查并更新任务状态 + checkAndUpdateOtaTaskStatus(record.getTaskId()); + } + + @Override + public boolean pushOtaTaskRecord(IotOtaTaskRecordDO record, IotOtaFirmwareDO fireware, IotDeviceDO device) { + try { + // 1. 推送 OTA 任务记录 + IotDeviceMessage message = IotDeviceMessage.buildOtaUpgrade( + fireware.getVersion(), fireware.getFileUrl(), fireware.getFileSize(), + fireware.getFileDigestAlgorithm(), fireware.getFileDigestValue()); + deviceMessageService.sendDeviceMessage(message, device); + + // 2. 更新 OTA 升级记录状态为进行中 + int updateCount = otaTaskRecordMapper.updateByIdAndStatus( + record.getId(), IotOtaTaskRecordStatusEnum.PENDING.getStatus(), + IotOtaTaskRecordDO.builder().status(IotOtaTaskRecordStatusEnum.PUSHED.getStatus()) + .description(StrUtil.format("已推送,设备消息编号({})", message.getId())).build()); + Assert.isTrue(updateCount == 1, "更新设备记录({})状态失败", record.getId()); + return true; + } catch (Exception ex) { + log.error("[pushOtaTaskRecord][推送 OTA 任务记录({}) 失败]", record.getId(), ex); + otaTaskRecordMapper.updateById(IotOtaTaskRecordDO.builder().id(record.getId()) + .description(StrUtil.format("推送失败,错误信息({})", ex.getMessage())).build()); + return false; + } + } + + private IotOtaTaskRecordDO validateUpgradeRecordExists(Long id) { + IotOtaTaskRecordDO upgradeRecord = otaTaskRecordMapper.selectById(id); + if (upgradeRecord == null) { + throw exception(OTA_TASK_RECORD_NOT_EXISTS); + } + return upgradeRecord; + } + + @Override + @Transactional(rollbackFor = Exception.class) + @SuppressWarnings("unchecked") + public void updateOtaRecordProgress(IotDeviceDO device, IotDeviceMessage message) { + // 1.1 参数解析 + Map params = (Map) message.getParams(); + String version = MapUtil.getStr(params, "version"); + Assert.notBlank(version, "version 不能为空"); + Integer status = MapUtil.getInt(params, "status"); + Assert.notNull(status, "status 不能为空"); + Assert.notNull(IotOtaTaskRecordStatusEnum.of(status), "status 状态不正确"); + String description = MapUtil.getStr(params, "description"); + Integer progress = MapUtil.getInt(params, "progress"); + Assert.notNull(progress, "progress 不能为空"); + Assert.isTrue(progress >= 0 && progress <= 100, "progress 必须在 0-100 之间"); + // 1.2 查询 OTA 升级记录 + List records = otaTaskRecordMapper.selectListByDeviceIdAndStatus( + device.getId(), IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES); + if (CollUtil.isEmpty(records)) { + throw exception(OTA_TASK_RECORD_UPDATE_PROGRESS_FAIL_NO_EXISTS); + } + if (records.size() > 1) { + log.warn("[updateOtaRecordProgress][message({}) 对应升级记录过多({})]", message, records); + } + IotOtaTaskRecordDO record = CollUtil.getFirst(records); + // 1.3 查询 OTA 固件 + IotOtaFirmwareDO firmware = otaFirmwareService.getOtaFirmwareByProductIdAndVersion( + device.getProductId(), version); + if (firmware == null) { + throw exception(OTA_FIRMWARE_NOT_EXISTS); + } + + // 2. 更新 OTA 升级记录状态 + int updateCount = otaTaskRecordMapper.updateByIdAndStatus( + record.getId(), IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES, + IotOtaTaskRecordDO.builder().status(status).description(description).progress(progress).build()); + if (updateCount == 0) { + throw exception(OTA_TASK_RECORD_UPDATE_PROGRESS_FAIL_NO_EXISTS); + } + + // 3. 如果升级成功,则更新设备固件版本 + if (IotOtaTaskRecordStatusEnum.SUCCESS.getStatus().equals(status)) { + deviceService.updateDeviceFirmware(device.getId(), firmware.getId()); + } + + // 4. 如果状态是“已结束”(非进行中),则更新任务状态 + if (!IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES.contains(status)) { + checkAndUpdateOtaTaskStatus(record.getTaskId()); + } + } + + /** + * 检查并更新任务状态 + * 如果任务下没有进行中的记录,则将任务状态更新为已结束 + */ + private void checkAndUpdateOtaTaskStatus(Long taskId) { + // 如果还有进行中的记录,直接返回 + Long inProcessCount = otaTaskRecordMapper.selectCountByTaskIdAndStatus( + taskId, IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES); + if (inProcessCount > 0) { + return; + } + + // 没有进行中的记录,将任务状态更新为已结束 + otaTaskService.updateOtaTaskStatusEnd(taskId); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskService.java new file mode 100644 index 0000000000..ead91e2874 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskService.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.service.ota; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskCreateReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskDO; +import jakarta.validation.Valid; + +/** + * IoT OTA 升级任务 Service 接口 + * + * @author Shelly Chan + */ +public interface IotOtaTaskService { + + /** + * 创建 OTA 升级任务 + * + * @param createReqVO 创建请求对象 + * @return 升级任务编号 + */ + Long createOtaTask(@Valid IotOtaTaskCreateReqVO createReqVO); + + /** + * 取消 OTA 升级任务 + * + * @param id 升级任务编号 + */ + void cancelOtaTask(Long id); + + /** + * 获取 OTA 升级任务 + * + * @param id 升级任务编号 + * @return 升级任务 + */ + IotOtaTaskDO getOtaTask(Long id); + + /** + * 分页查询 OTA 升级任务 + * + * @param pageReqVO 分页查询请求 + * @return 升级任务分页结果 + */ + PageResult getOtaTaskPage(@Valid IotOtaTaskPageReqVO pageReqVO); + + /** + * 更新 OTA 任务状态为已结束 + * + * @param id 任务编号 + */ + void updateOtaTaskStatusEnd(Long id); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskServiceImpl.java new file mode 100644 index 0000000000..d6a9b9fda2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskServiceImpl.java @@ -0,0 +1,167 @@ +package cn.iocoder.yudao.module.iot.service.ota; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskCreateReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.IotOtaTaskPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; +import cn.iocoder.yudao.module.iot.dal.mysql.ota.IotOtaTaskMapper; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskDeviceScopeEnum; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskRecordStatusEnum; +import cn.iocoder.yudao.module.iot.enums.ota.IotOtaTaskStatusEnum; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; + +/** + * IoT OTA 升级任务 Service 实现类 + * + * @author Shelly Chan + */ +@Service +@Validated +@Slf4j +public class IotOtaTaskServiceImpl implements IotOtaTaskService { + + @Resource + private IotOtaTaskMapper otaTaskMapper; + + @Resource + private IotDeviceService deviceService; + @Resource + private IotOtaFirmwareService otaFirmwareService; + @Resource + @Lazy // 延迟,避免循环依赖报错 + private IotOtaTaskRecordService otaTaskRecordService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createOtaTask(IotOtaTaskCreateReqVO createReqVO) { + // 1.1 校验固件信息是否存在 + IotOtaFirmwareDO firmware = otaFirmwareService.validateFirmwareExists(createReqVO.getFirmwareId()); + // 1.2 校验同一固件的升级任务名称不重复 + if (otaTaskMapper.selectByFirmwareIdAndName(firmware.getId(), createReqVO.getName()) != null) { + throw exception(OTA_TASK_CREATE_FAIL_NAME_DUPLICATE); + } + // 1.3 校验设备范围信息 + List devices = validateOtaTaskDeviceScope(createReqVO, firmware.getProductId()); + + // 2. 保存升级任务,直接转换 + IotOtaTaskDO task = BeanUtils.toBean(createReqVO, IotOtaTaskDO.class) + .setStatus(IotOtaTaskStatusEnum.IN_PROGRESS.getStatus()) + .setDeviceTotalCount(devices.size()).setDeviceSuccessCount(0); + otaTaskMapper.insert(task); + + // 3. 生成设备升级记录 + otaTaskRecordService.createOtaTaskRecordList(devices, firmware.getId(), task.getId()); + return task.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelOtaTask(Long id) { + // 1.1 校验升级任务是否存在 + IotOtaTaskDO upgradeTask = validateUpgradeTaskExists(id); + // 1.2 校验升级任务是否可以取消 + if (ObjUtil.notEqual(upgradeTask.getStatus(), IotOtaTaskStatusEnum.IN_PROGRESS.getStatus())) { + throw exception(OTA_TASK_CANCEL_FAIL_STATUS_END); + } + + // 2. 更新升级任务状态为已取消 + otaTaskMapper.updateById(IotOtaTaskDO.builder() + .id(id).status(IotOtaTaskStatusEnum.CANCELED.getStatus()) + .build()); + + // 3. 更新升级记录状态为已取消 + otaTaskRecordService.cancelTaskRecordListByTaskId(id); + } + + @Override + public IotOtaTaskDO getOtaTask(Long id) { + return otaTaskMapper.selectById(id); + } + + @Override + public PageResult getOtaTaskPage(IotOtaTaskPageReqVO pageReqVO) { + return otaTaskMapper.selectPage(pageReqVO); + } + + @Override + public void updateOtaTaskStatusEnd(Long taskId) { + int updateCount = otaTaskMapper.updateByIdAndStatus(taskId, IotOtaTaskStatusEnum.IN_PROGRESS.getStatus(), + new IotOtaTaskDO().setStatus(IotOtaTaskStatusEnum.END.getStatus())); + if (updateCount == 0) { + log.warn("[updateOtaTaskStatusEnd][任务({})不存在或状态不是进行中,无法更新]", taskId); + } + } + + private List validateOtaTaskDeviceScope(IotOtaTaskCreateReqVO createReqVO, Long productId) { + // 情况一:选择设备 + if (Objects.equals(createReqVO.getDeviceScope(), IotOtaTaskDeviceScopeEnum.SELECT.getScope())) { + // 1.1 校验设备存在 + List devices = deviceService.validateDeviceListExists(createReqVO.getDeviceIds()); + for (IotDeviceDO device : devices) { + if (ObjUtil.notEqual(device.getProductId(), productId)) { + throw exception(DEVICE_NOT_EXISTS); + } + } + // 1.2 校验设备是否已经是该固件版本 + devices.forEach(device -> { + if (Objects.equals(device.getFirmwareId(), createReqVO.getFirmwareId())) { + throw exception(OTA_TASK_CREATE_FAIL_DEVICE_FIRMWARE_EXISTS, device.getDeviceName()); + } + }); + // 1.3 校验设备是否已经在升级中 + List records = otaTaskRecordService.getOtaTaskRecordListByDeviceIdAndStatus( + convertSet(devices, IotDeviceDO::getId), IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES); + devices.forEach(device -> { + if (CollUtil.contains(records, item -> item.getDeviceId().equals(device.getId()))) { + throw exception(OTA_TASK_CREATE_FAIL_DEVICE_OTA_IN_PROCESS, device.getDeviceName()); + } + }); + return devices; + } + // 情况二:全部设备 + if (Objects.equals(createReqVO.getDeviceScope(), IotOtaTaskDeviceScopeEnum.ALL.getScope())) { + List devices = deviceService.getDeviceListByProductId(productId); + // 2.1.1 移除已经是该固件版本的设备 + devices.removeIf(device -> Objects.equals(device.getFirmwareId(), createReqVO.getFirmwareId())); + // 2.1.2 移除已经在升级中的设备 + List records = otaTaskRecordService.getOtaTaskRecordListByDeviceIdAndStatus( + convertSet(devices, IotDeviceDO::getId), IotOtaTaskRecordStatusEnum.IN_PROCESS_STATUSES); + devices.removeIf(device -> CollUtil.contains(records, + item -> item.getDeviceId().equals(device.getId()))); + // 2.2 校验是否有可升级的设备 + if (CollUtil.isEmpty(devices)) { + throw exception(OTA_TASK_CREATE_FAIL_DEVICE_EMPTY); + } + return devices; + } + throw new IllegalArgumentException("不支持的设备范围:" + createReqVO.getDeviceScope()); + } + + private IotOtaTaskDO validateUpgradeTaskExists(Long id) { + IotOtaTaskDO upgradeTask = otaTaskMapper.selectById(id); + if (Objects.isNull(upgradeTask)) { + throw exception(OTA_TASK_NOT_EXISTS); + } + return upgradeTask; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleService.java new file mode 100644 index 0000000000..1e0a813305 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleService.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.iot.service.rule.data; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * IoT 数据流转规则 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDataRuleService { + + /** + * 创建数据流转规则 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createDataRule(@Valid IotDataRuleSaveReqVO createReqVO); + + /** + * 更新数据流转规则 + * + * @param updateReqVO 更新信息 + */ + void updateDataRule(@Valid IotDataRuleSaveReqVO updateReqVO); + + /** + * 删除数据流转规则 + * + * @param id 编号 + */ + void deleteDataRule(Long id); + + /** + * 获得数据流转规则 + * + * @param id 编号 + * @return 数据流转规则 + */ + IotDataRuleDO getDataRule(Long id); + + /** + * 获得数据流转规则分页 + * + * @param pageReqVO 分页查询 + * @return 数据流转规则分页 + */ + PageResult getDataRulePage(IotDataRulePageReqVO pageReqVO); + + /** + * 根据数据目的编号,获得数据流转规则列表 + * + * @param sinkId 数据目的编号 + * @return 是否被使用 + */ + List getDataRuleListBySinkId(Long sinkId); + + /** + * 执行数据流转规则 + * + * @param message 消息 + */ + void executeDataRule(IotDeviceMessage message); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java new file mode 100644 index 0000000000..8eafcb681a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java @@ -0,0 +1,259 @@ +package cn.iocoder.yudao.module.iot.service.rule.data; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.common.util.spring.SpringUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRulePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.rule.IotDataRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataRuleDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotDataRuleMapper; +import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import cn.iocoder.yudao.module.iot.service.rule.data.action.IotDataRuleAction; +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NOT_EXISTS; + +/** + * IoT 数据流转规则 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class IotDataRuleServiceImpl implements IotDataRuleService { + + @Resource + private IotDataRuleMapper dataRuleMapper; + + @Resource + private IotProductService productService; + @Resource + private IotDeviceService deviceService; + @Resource + private IotThingModelService thingModelService; + @Resource + private IotDataSinkService dataSinkService; + + @Resource + private List dataRuleActions; + + @Override + @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true) + public Long createDataRule(IotDataRuleSaveReqVO createReqVO) { + // 校验数据源配置和数据目的 + validateDataRuleConfig(createReqVO); + // 新增 + IotDataRuleDO dataRule = BeanUtils.toBean(createReqVO, IotDataRuleDO.class); + dataRuleMapper.insert(dataRule); + return dataRule.getId(); + } + + @Override + @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true) + public void updateDataRule(IotDataRuleSaveReqVO updateReqVO) { + // 校验存在 + validateDataRuleExists(updateReqVO.getId()); + // 校验数据源配置和数据目的 + validateDataRuleConfig(updateReqVO); + + // 更新 + IotDataRuleDO updateObj = BeanUtils.toBean(updateReqVO, IotDataRuleDO.class); + dataRuleMapper.updateById(updateObj); + } + + @Override + @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true) + public void deleteDataRule(Long id) { + // 校验存在 + validateDataRuleExists(id); + // 删除 + dataRuleMapper.deleteById(id); + } + + private void validateDataRuleExists(Long id) { + if (dataRuleMapper.selectById(id) == null) { + throw exception(DATA_RULE_NOT_EXISTS); + } + } + + /** + * 校验数据流转规则配置 + * + * @param reqVO 数据流转规则保存请求VO + */ + private void validateDataRuleConfig(IotDataRuleSaveReqVO reqVO) { + // 1. 校验数据源配置 + validateSourceConfigs(reqVO.getSourceConfigs()); + // 2. 校验数据目的 + dataSinkService.validateDataSinksExist(reqVO.getSinkIds()); + } + + /** + * 校验数据源配置 + * + * @param sourceConfigs 数据源配置列表 + */ + private void validateSourceConfigs(List sourceConfigs) { + // 1. 校验产品 + productService.validateProductsExist( + convertSet(sourceConfigs, IotDataRuleDO.SourceConfig::getProductId)); + + // 2. 校验设备 + deviceService.validateDeviceListExists(convertSet(sourceConfigs, IotDataRuleDO.SourceConfig::getDeviceId, + config -> ObjUtil.notEqual(config.getDeviceId(), IotDeviceDO.DEVICE_ID_ALL))); + + // 3. 校验物模型存在 + validateThingModelsExist(sourceConfigs); + } + + /** + * 校验物模型存在 + * + * @param sourceConfigs 数据源配置列表 + */ + private void validateThingModelsExist(List sourceConfigs) { + Map> productIdIdentifiers = new HashMap<>(); + for (IotDataRuleDO.SourceConfig config : sourceConfigs) { + if (StrUtil.isEmpty(config.getIdentifier())) { + continue; + } + productIdIdentifiers.computeIfAbsent(config.getProductId(), + productId -> new HashSet<>()).add(config.getIdentifier()); + } + for (Map.Entry> entry : productIdIdentifiers.entrySet()) { + thingModelService.validateThingModelListExists(entry.getKey(), entry.getValue()); + } + } + + @Override + public IotDataRuleDO getDataRule(Long id) { + return dataRuleMapper.selectById(id); + } + + @Override + public PageResult getDataRulePage(IotDataRulePageReqVO pageReqVO) { + return dataRuleMapper.selectPage(pageReqVO); + } + + @Override + public List getDataRuleListBySinkId(Long sinkId) { + return dataRuleMapper.selectListBySinkId(sinkId); + } + + @Cacheable(value = RedisKeyConstants.DATA_RULE_LIST, + key = "#deviceId + '_' + #method + '_' + (#identifier ?: '')") + public List getDataRuleListByConditionFromCache(Long deviceId, String method, String identifier) { + // 1. 查询所有开启的数据流转规则 + List rules = dataRuleMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + // 2. 内存里过滤匹配的规则 + List matchedRules = new ArrayList<>(); + for (IotDataRuleDO rule : rules) { + IotDataRuleDO.SourceConfig found = CollUtil.findOne(rule.getSourceConfigs(), + config -> ObjectUtils.equalsAny(config.getDeviceId(), deviceId, IotDeviceDO.DEVICE_ID_ALL) + && Objects.equals(config.getMethod(), method) + && (StrUtil.isEmpty(config.getIdentifier()) || ObjUtil.equal(config.getIdentifier(), identifier))); + if (found != null) { + matchedRules.add(new IotDataRuleDO().setId(rule.getId()).setSinkIds(rule.getSinkIds())); + } + } + return matchedRules; + } + + @Override + public void executeDataRule(IotDeviceMessage message) { + try { + // 1. 获取匹配的数据流转规则 + Long deviceId = message.getDeviceId(); + String method = message.getMethod(); + String identifier = IotDeviceMessageUtils.getIdentifier(message); + List rules = getSelf().getDataRuleListByConditionFromCache(deviceId, method, identifier); + if (CollUtil.isEmpty(rules)) { + log.debug("[executeDataRule][设备({}) 方法({}) 标识符({}) 没有匹配的数据流转规则]", + deviceId, method, identifier); + return; + } + log.info("[executeDataRule][设备({}) 方法({}) 标识符({}) 匹配到 {} 条数据流转规则]", + deviceId, method, identifier, rules.size()); + + // 2. 遍历规则,执行数据流转 + rules.forEach(rule -> executeDataRule(message, rule)); + } catch (Exception e) { + log.error("[executeDataRule][消息({}) 执行数据流转规则异常]", message, e); + } + } + + /** + * 为指定规则的所有数据目的执行数据流转 + * + * @param message 设备消息 + * @param rule 数据流转规则 + */ + private void executeDataRule(IotDeviceMessage message, IotDataRuleDO rule) { + rule.getSinkIds().forEach(sinkId -> { + try { + // 获取数据目的配置 + IotDataSinkDO dataSink = dataSinkService.getDataSinkFromCache(sinkId); + if (dataSink == null) { + log.error("[executeDataRule][规则({}) 对应的数据目的({}) 不存在]", rule.getId(), sinkId); + return; + } + if (CommonStatusEnum.isDisable(dataSink.getStatus())) { + log.info("[executeDataRule][规则({}) 对应的数据目的({}) 状态为禁用]", rule.getId(), sinkId); + return; + } + + // 执行数据桥接操作 + executeDataRuleAction(message, dataSink); + } catch (Exception e) { + log.error("[executeDataRule][规则({}) 数据目的({}) 执行异常]", rule.getId(), sinkId, e); + } + }); + } + + /** + * 执行数据流转操作 + * + * @param message 设备消息 + * @param dataSink 数据目的 + */ + private void executeDataRuleAction(IotDeviceMessage message, IotDataSinkDO dataSink) { + dataRuleActions.forEach(action -> { + if (ObjUtil.notEqual(action.getType(), dataSink.getType())) { + return; + } + try { + action.execute(message, dataSink); + log.info("[executeDataRuleAction][消息({}) 数据目的({}) 执行成功]", message.getId(), dataSink.getId()); + } catch (Exception e) { + log.error("[executeDataRuleAction][消息({}) 数据目的({}) 执行异常]", message.getId(), dataSink.getId(), e); + } + }); + } + + private IotDataRuleServiceImpl getSelf() { + return SpringUtils.getBean(IotDataRuleServiceImpl.class); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkService.java new file mode 100644 index 0000000000..d0e2a5282e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkService.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.iot.service.rule.data; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * IoT 数据流转目的 Service 接口 + * + * @author HUIHUI + */ +public interface IotDataSinkService { + + /** + * 创建数据流转目的 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createDataSink(@Valid IotDataSinkSaveReqVO createReqVO); + + /** + * 更新数据流转目的 + * + * @param updateReqVO 更新信息 + */ + void updateDataSink(@Valid IotDataSinkSaveReqVO updateReqVO); + + /** + * 删除数据流转目的 + * + * @param id 编号 + */ + void deleteDataSink(Long id); + + /** + * 获得数据流转目的 + * + * @param id 编号 + * @return 数据流转目的 + */ + IotDataSinkDO getDataSink(Long id); + + /** + * 从缓存中获得数据流转目的 + * + * @param id 编号 + * @return 数据流转目的 + */ + IotDataSinkDO getDataSinkFromCache(Long id); + + /** + * 获得数据流转目的分页 + * + * @param pageReqVO 分页查询 + * @return 数据流转目的分页 + */ + PageResult getDataSinkPage(IotDataSinkPageReqVO pageReqVO); + + /** + * 获取数据流转目的列表 + * + * @param status 状态,如果为空,则不进行筛选 + * @return 数据流转目的列表 + */ + List getDataSinkListByStatus(Integer status); + + /** + * 批量校验数据目的存在 + * + * @param ids 数据目的编号集合 + */ + void validateDataSinksExist(Collection ids); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java new file mode 100644 index 0000000000..9977afba22 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.iot.service.rule.data; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotDataSinkMapper; +import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; +import jakarta.annotation.Resource; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_DELETE_FAIL_USED_BY_RULE; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NOT_EXISTS; + +/** + * IoT 数据流转目的 Service 实现类 + * + * @author HUIHUI + */ +@Service +@Validated +public class IotDataSinkServiceImpl implements IotDataSinkService { + + @Resource + private IotDataSinkMapper dataSinkMapper; + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private IotDataRuleService dataRuleService; + + @Override + public Long createDataSink(IotDataSinkSaveReqVO createReqVO) { + IotDataSinkDO dataBridge = BeanUtils.toBean(createReqVO, IotDataSinkDO.class); + dataSinkMapper.insert(dataBridge); + return dataBridge.getId(); + } + + @Override + public void updateDataSink(IotDataSinkSaveReqVO updateReqVO) { + // 校验存在 + validateDataBridgeExists(updateReqVO.getId()); + // 更新 + IotDataSinkDO updateObj = BeanUtils.toBean(updateReqVO, IotDataSinkDO.class); + dataSinkMapper.updateById(updateObj); + } + + @Override + public void deleteDataSink(Long id) { + // 校验存在 + validateDataBridgeExists(id); + // 校验是否被数据流转规则使用 + if (CollUtil.isNotEmpty(dataRuleService.getDataRuleListBySinkId(id))) { + throw exception(DATA_SINK_DELETE_FAIL_USED_BY_RULE); + } + // 删除 + dataSinkMapper.deleteById(id); + } + + private void validateDataBridgeExists(Long id) { + if (dataSinkMapper.selectById(id) == null) { + throw exception(DATA_SINK_NOT_EXISTS); + } + } + + @Override + public IotDataSinkDO getDataSink(Long id) { + return dataSinkMapper.selectById(id); + } + + @Override + @Cacheable(value = RedisKeyConstants.DATA_SINK, key = "#id") + public IotDataSinkDO getDataSinkFromCache(Long id) { + return dataSinkMapper.selectById(id); + } + + @Override + public PageResult getDataSinkPage(IotDataSinkPageReqVO pageReqVO) { + return dataSinkMapper.selectPage(pageReqVO); + } + + @Override + public List getDataSinkListByStatus(Integer status) { + return dataSinkMapper.selectListByStatus(status); + } + + @Override + public void validateDataSinksExist(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + List sinks = dataSinkMapper.selectByIds(ids); + if (sinks.size() != ids.size()) { + throw exception(DATA_SINK_NOT_EXISTS); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleAction.java new file mode 100644 index 0000000000..8e6458ba86 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleAction.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotDataSinkDO; + +/** + * IoT 数据流转目的的执行器 action 接口 + * + * @author HUIHUI + */ +public interface IotDataRuleAction { + + /** + * 获取数据流转目的类型 + * + * @return 数据流转目的类型 + */ + Integer getType(); + + /** + * 执行数据流转目的操作 + * + * @param message 设备消息 + * @param dataSink 数据流转目的 + */ + void execute(IotDeviceMessage message, IotDataSinkDO dataSink); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRabbitMQDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRabbitMQDataRuleAction.java new file mode 100644 index 0000000000..075871a376 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRabbitMQDataRuleAction.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkRabbitMQConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.stereotype.Component; + +/** + * RabbitMQ 的 {@link IotDataRuleAction} 实现类 + * + * @author HUIHUI + */ +@ConditionalOnClass(name = "com.rabbitmq.client.Channel") +@Component +@Slf4j +public class IotRabbitMQDataRuleAction + extends IotDataRuleCacheableAction { + + @Override + public Integer getType() { + return IotDataSinkTypeEnum.RABBITMQ.getType(); + } + + @Override + public void execute(IotDeviceMessage message, IotDataSinkRabbitMQConfig config) throws Exception { + try { + // 1.1 获取或创建 Channel + Channel channel = getProducer(config); + // 1.2 声明交换机、队列和绑定关系 + channel.exchangeDeclare(config.getExchange(), "direct", true); + channel.queueDeclare(config.getQueue(), true, false, false, null); + channel.queueBind(config.getQueue(), config.getExchange(), config.getRoutingKey()); + + // 2. 发送消息 + channel.basicPublish(config.getExchange(), config.getRoutingKey(), null, + JsonUtils.toJsonByte(message)); + log.info("[execute][message({}) config({}) 发送成功]", message, config); + } catch (Exception e) { + log.error("[execute][message({}) config({}) 发送失败]", message, config, e); + throw e; + } + } + + @Override + @SuppressWarnings("resource") + protected Channel initProducer(IotDataSinkRabbitMQConfig config) throws Exception { + // 1. 创建连接工厂 + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(config.getHost()); + factory.setPort(config.getPort()); + factory.setVirtualHost(config.getVirtualHost()); + factory.setUsername(config.getUsername()); + factory.setPassword(config.getPassword()); + // 2. 创建连接 + Connection connection = factory.newConnection(); + // 3. 创建信道 + return connection.createChannel(); + } + + @Override + protected void closeProducer(Channel channel) throws Exception { + if (channel.isOpen()) { + channel.close(); + } + Connection connection = channel.getConnection(); + if (connection.isOpen()) { + connection.close(); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRedisRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRedisRuleAction.java new file mode 100644 index 0000000000..904240da8b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRedisRuleAction.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkRedisConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotRedisDataStructureEnum; +import lombok.extern.slf4j.Slf4j; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.redisson.config.SingleServerConfig; +import org.redisson.spring.data.connection.RedissonConnectionFactory; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * Redis 的 {@link IotDataRuleAction} 实现类 + * 支持多种 Redis 数据结构:Stream、Hash、List、Set、ZSet、String + * + * @author HUIHUI + */ +@Component +@Slf4j +public class IotRedisRuleAction extends + IotDataRuleCacheableAction> { + + @Override + public Integer getType() { + return IotDataSinkTypeEnum.REDIS.getType(); + } + + @Override + public void execute(IotDeviceMessage message, IotDataSinkRedisConfig config) throws Exception { + // 1. 获取 RedisTemplate + RedisTemplate redisTemplate = getProducer(config); + + // 2. 根据数据结构类型执行不同的操作 + String messageJson = JsonUtils.toJsonString(message); + IotRedisDataStructureEnum dataStructure = getDataStructureByType(config.getDataStructure()); + switch (dataStructure) { + case STREAM: + executeStream(redisTemplate, config, messageJson); + break; + case HASH: + executeHash(redisTemplate, config, message, messageJson); + break; + case LIST: + executeList(redisTemplate, config, messageJson); + break; + case SET: + executeSet(redisTemplate, config, messageJson); + break; + case ZSET: + executeZSet(redisTemplate, config, message, messageJson); + break; + case STRING: + executeString(redisTemplate, config, messageJson); + break; + default: + throw new IllegalArgumentException("不支持的 Redis 数据结构类型: " + dataStructure); + } + + log.info("[execute][消息发送成功] dataStructure: {}, config: {}", dataStructure.getName(), config); + } + + /** + * 执行 Stream 操作 + */ + private void executeStream(RedisTemplate redisTemplate, IotDataSinkRedisConfig config, String messageJson) { + ObjectRecord record = StreamRecords.newRecord() + .ofObject(messageJson).withStreamKey(config.getTopic()); + redisTemplate.opsForStream().add(record); + } + + /** + * 执行 Hash 操作 + */ + private void executeHash(RedisTemplate redisTemplate, IotDataSinkRedisConfig config, + IotDeviceMessage message, String messageJson) { + String hashField = StrUtil.isNotBlank(config.getHashField()) ? + config.getHashField() : String.valueOf(message.getDeviceId()); + redisTemplate.opsForHash().put(config.getTopic(), hashField, messageJson); + } + + /** + * 执行 List 操作 + */ + private void executeList(RedisTemplate redisTemplate, IotDataSinkRedisConfig config, String messageJson) { + redisTemplate.opsForList().rightPush(config.getTopic(), messageJson); + } + + /** + * 执行 Set 操作 + */ + private void executeSet(RedisTemplate redisTemplate, IotDataSinkRedisConfig config, String messageJson) { + redisTemplate.opsForSet().add(config.getTopic(), messageJson); + } + + /** + * 执行 ZSet 操作 + */ + private void executeZSet(RedisTemplate redisTemplate, IotDataSinkRedisConfig config, + IotDeviceMessage message, String messageJson) { + double score; + if (StrUtil.isNotBlank(config.getScoreField())) { + // 尝试从消息中获取分数字段 + try { + Map messageMap = JsonUtils.parseObject(messageJson, Map.class); + Object scoreValue = messageMap.get(config.getScoreField()); + score = scoreValue instanceof Number ? ((Number) scoreValue).doubleValue() : System.currentTimeMillis(); + } catch (Exception e) { + score = System.currentTimeMillis(); + } + } else { + // 使用当前时间戳作为分数 + score = System.currentTimeMillis(); + } + redisTemplate.opsForZSet().add(config.getTopic(), messageJson, score); + } + + /** + * 执行 String 操作 + */ + private void executeString(RedisTemplate redisTemplate, IotDataSinkRedisConfig config, String messageJson) { + redisTemplate.opsForValue().set(config.getTopic(), messageJson); + } + + @Override + protected RedisTemplate initProducer(IotDataSinkRedisConfig config) { + // 1.1 创建 Redisson 配置 + Config redissonConfig = new Config(); + SingleServerConfig serverConfig = redissonConfig.useSingleServer() + .setAddress("redis://" + config.getHost() + ":" + config.getPort()) + .setDatabase(config.getDatabase()); + // 1.2 设置密码(如果有) + if (StrUtil.isNotBlank(config.getPassword())) { + serverConfig.setPassword(config.getPassword()); + } + + // 2.1 创建 RedisTemplate 并配置 + RedissonClient redisson = Redisson.create(redissonConfig); + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(new RedissonConnectionFactory(redisson)); + // 2.2 设置序列化器 + template.setKeySerializer(RedisSerializer.string()); + template.setHashKeySerializer(RedisSerializer.string()); + template.setValueSerializer(RedisSerializer.json()); + template.setHashValueSerializer(RedisSerializer.json()); + template.afterPropertiesSet(); + return template; + } + + @Override + protected void closeProducer(RedisTemplate producer) throws Exception { + RedisConnectionFactory factory = producer.getConnectionFactory(); + if (factory != null) { + ((RedissonConnectionFactory) factory).destroy(); + } + } + + /** + * 根据类型值获取数据结构枚举 + */ + private IotRedisDataStructureEnum getDataStructureByType(Integer type) { + for (IotRedisDataStructureEnum dataStructure : IotRedisDataStructureEnum.values()) { + if (dataStructure.getType().equals(type)) { + return dataStructure; + } + } + throw new IllegalArgumentException("不支持的 Redis 数据结构类型: " + type); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRocketMQDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRocketMQDataRuleAction.java new file mode 100644 index 0000000000..d73205c6df --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotRocketMQDataRuleAction.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.iot.service.rule.data.action; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkRocketMQConfig; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.client.producer.DefaultMQProducer; +import org.apache.rocketmq.client.producer.SendResult; +import org.apache.rocketmq.client.producer.SendStatus; +import org.apache.rocketmq.common.message.Message; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.stereotype.Component; + +/** + * RocketMQ 的 {@link IotDataRuleAction} 实现类 + * + * @author HUIHUI + */ +@ConditionalOnClass(name = "org.apache.rocketmq.client.producer.DefaultMQProducer") +@Component +@Slf4j +public class IotRocketMQDataRuleAction extends + IotDataRuleCacheableAction { + + @Override + public Integer getType() { + return IotDataSinkTypeEnum.ROCKETMQ.getType(); + } + + @Override + public void execute(IotDeviceMessage message, IotDataSinkRocketMQConfig config) throws Exception { + // 1. 获取或创建 Producer + DefaultMQProducer producer = getProducer(config); + + // 2.1 创建消息对象,指定 Topic、Tag 和消息体 + Message msg = new Message(config.getTopic(), config.getTags(), JsonUtils.toJsonByte(message)); + // 2.2 发送同步消息并处理结果 + SendResult sendResult = producer.send(msg); + // 2.3 处理发送结果 + if (SendStatus.SEND_OK.equals(sendResult.getSendStatus())) { + log.info("[execute][message({}) config({}) 发送成功,结果({})]", message, config, sendResult); + } else { + log.error("[execute][message({}) config({}) 发送失败,结果({})]", message, config, sendResult); + } + } + + @Override + protected DefaultMQProducer initProducer(IotDataSinkRocketMQConfig config) throws Exception { + DefaultMQProducer producer = new DefaultMQProducer(config.getGroup()); + producer.setNamesrvAddr(config.getNameServer()); + producer.start(); + return producer; + } + + @Override + protected void closeProducer(DefaultMQProducer producer) { + producer.shutdown(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleService.java new file mode 100644 index 0000000000..bdbc4f39b3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleService.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRulePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * IoT 规则场景规则 Service 接口 + * + * @author 芋道源码 + */ +public interface IotSceneRuleService { + + /** + * 创建场景联动 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSceneRule(@Valid IotSceneRuleSaveReqVO createReqVO); + + /** + * 更新场景联动 + * + * @param updateReqVO 更新信息 + */ + void updateSceneRule(@Valid IotSceneRuleSaveReqVO updateReqVO); + + /** + * 更新场景联动状态 + * + * @param id 场景联动编号 + * @param status 状态 + */ + void updateSceneRuleStatus(Long id, Integer status); + + /** + * 删除场景联动 + * + * @param id 编号 + */ + void deleteSceneRule(Long id); + + /** + * 获得场景联动 + * + * @param id 编号 + * @return 场景联动 + */ + IotSceneRuleDO getSceneRule(Long id); + + /** + * 获得场景联动分页 + * + * @param pageReqVO 分页查询 + * @return 场景联动分页 + */ + PageResult getSceneRulePage(IotSceneRulePageReqVO pageReqVO); + + /** + * 校验规则场景联动规则编号们是否存在。如下情况,视为无效: + * 1. 规则场景联动规则编号不存在 + * + * @param ids 场景联动规则编号数组 + */ + void validateSceneRuleList(Collection ids); + + /** + * 获得指定状态的场景联动列表 + * + * @param status 状态 + * @return 场景联动列表 + */ + List getSceneRuleListByStatus(Integer status); + + /** + * 【缓存】获得指定设备的场景列表 + * + * @param productId 产品 ID + * @param deviceId 设备 ID + * @return 场景列表 + */ + List getSceneRuleListByProductIdAndDeviceIdFromCache(Long productId, Long deviceId); + + /** + * 基于 {@link IotSceneRuleTriggerTypeEnum} 场景,执行规则场景 + * 1. {@link IotSceneRuleTriggerTypeEnum#DEVICE_STATE_UPDATE} + * 2. {@link IotSceneRuleTriggerTypeEnum#DEVICE_PROPERTY_POST} + * {@link IotSceneRuleTriggerTypeEnum#DEVICE_EVENT_POST} + * 3. {@link IotSceneRuleTriggerTypeEnum#DEVICE_EVENT_POST} + * {@link IotSceneRuleTriggerTypeEnum#DEVICE_SERVICE_INVOKE} + * @param message 消息 + */ + void executeSceneRuleByDevice(IotDeviceMessage message); + + /** + * 基于 {@link IotSceneRuleTriggerTypeEnum#TIMER} 场景,执行规则场景 + * + * @param id 场景联动规则编号 + */ + void executeSceneRuleByTimer(Long id); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java new file mode 100644 index 0000000000..7cbc5b56be --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java @@ -0,0 +1,380 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRulePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.framework.job.core.IotSchedulerManager; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.RULE_SCENE_NOT_EXISTS; + +/** + * IoT 规则场景 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class IotSceneRuleServiceImpl implements IotSceneRuleService { + + @Resource + private IotSceneRuleMapper sceneRuleMapper; + + // TODO @puhui999:定时任务,基于它调度; + @Resource(name = "iotSchedulerManager") + private IotSchedulerManager schedulerManager; + @Resource + private IotProductService productService; + @Resource + private IotDeviceService deviceService; + + @Resource + private IotSceneRuleMatcherManager sceneRuleMatcherManager; + @Resource + private List sceneRuleActions; + + @Override + public Long createSceneRule(IotSceneRuleSaveReqVO createReqVO) { + IotSceneRuleDO sceneRule = BeanUtils.toBean(createReqVO, IotSceneRuleDO.class); + sceneRuleMapper.insert(sceneRule); + return sceneRule.getId(); + } + + @Override + public void updateSceneRule(IotSceneRuleSaveReqVO updateReqVO) { + // 校验存在 + validateSceneRuleExists(updateReqVO.getId()); + // 更新 + IotSceneRuleDO updateObj = BeanUtils.toBean(updateReqVO, IotSceneRuleDO.class); + sceneRuleMapper.updateById(updateObj); + } + + @Override + public void updateSceneRuleStatus(Long id, Integer status) { + // 校验存在 + validateSceneRuleExists(id); + // 更新状态 + IotSceneRuleDO updateObj = new IotSceneRuleDO().setId(id).setStatus(status); + sceneRuleMapper.updateById(updateObj); + } + + @Override + public void deleteSceneRule(Long id) { + // 校验存在 + validateSceneRuleExists(id); + // 删除 + sceneRuleMapper.deleteById(id); + } + + private void validateSceneRuleExists(Long id) { + if (sceneRuleMapper.selectById(id) == null) { + throw exception(RULE_SCENE_NOT_EXISTS); + } + } + + @Override + public IotSceneRuleDO getSceneRule(Long id) { + return sceneRuleMapper.selectById(id); + } + + @Override + public PageResult getSceneRulePage(IotSceneRulePageReqVO pageReqVO) { + return sceneRuleMapper.selectPage(pageReqVO); + } + + @Override + public void validateSceneRuleList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + // 批量查询存在的规则场景 + List existingScenes = sceneRuleMapper.selectByIds(ids); + if (existingScenes.size() != ids.size()) { + throw exception(RULE_SCENE_NOT_EXISTS); + } + } + + @Override + public List getSceneRuleListByStatus(Integer status) { + return sceneRuleMapper.selectListByStatus(status); + } + + // TODO 芋艿,缓存待实现 @puhui999 + @Override + @TenantIgnore // 忽略租户隔离:因为 IotSceneRuleMessageHandler 调用时,一般未传递租户,所以需要忽略 + public List getSceneRuleListByProductIdAndDeviceIdFromCache(Long productId, Long deviceId) { + List list = sceneRuleMapper.selectList(); + // 只返回启用状态的规则场景 + List enabledList = filterList(list, + sceneRule -> CommonStatusEnum.isEnable(sceneRule.getStatus())); + + // 根据 productKey 和 deviceName 进行匹配 + return filterList(enabledList, sceneRule -> { + if (CollUtil.isEmpty(sceneRule.getTriggers())) { + return false; + } + + for (IotSceneRuleDO.Trigger trigger : sceneRule.getTriggers()) { + // 检查触发器是否匹配指定的产品和设备 + try { + // 1. 检查产品是否匹配 + if (trigger.getProductId() == null) { + return false; + } + if (trigger.getDeviceId() == null) { + return false; + } + // 检查是否是全部设备的特殊标识 + if (IotDeviceDO.DEVICE_ID_ALL.equals(trigger.getDeviceId())) { + return true; // 匹配所有设备 + } + // 检查具体设备 ID 是否匹配 + return ObjUtil.equal(productId, trigger.getProductId()) && ObjUtil.equal(deviceId, trigger.getDeviceId()); + } catch (Exception e) { + log.warn("[isMatchProductAndDevice][产品({}) 设备({}) 匹配触发器异常]", productId, deviceId, e); + return false; + } + } + return false; + }); + } + + @Override + public void executeSceneRuleByDevice(IotDeviceMessage message) { + // TODO @芋艿:这里的 tenantId,通过设备获取;@puhui999: + TenantUtils.execute(message.getTenantId(), () -> { + // 1. 获得设备匹配的规则场景 + List sceneRules = getMatchedSceneRuleListByMessage(message); + if (CollUtil.isEmpty(sceneRules)) { + return; + } + + // 2. 执行规则场景 + executeSceneRuleAction(message, sceneRules); + }); + } + + @Override + public void executeSceneRuleByTimer(Long id) { + // 1.1 获得规则场景 + IotSceneRuleDO scene = TenantUtils.executeIgnore(() -> sceneRuleMapper.selectById(id)); + if (scene == null) { + log.error("[executeSceneRuleByTimer][规则场景({}) 不存在]", id); + return; + } + if (CommonStatusEnum.isDisable(scene.getStatus())) { + log.info("[executeSceneRuleByTimer][规则场景({}) 已被禁用]", id); + return; + } + // 1.2 判断是否有定时触发器,避免脏数据 + IotSceneRuleDO.Trigger config = CollUtil.findOne(scene.getTriggers(), + trigger -> ObjUtil.equals(trigger.getType(), IotSceneRuleTriggerTypeEnum.TIMER.getType())); + if (config == null) { + log.error("[executeSceneRuleByTimer][规则场景({}) 不存在定时触发器]", scene); + return; + } + + // 2. 执行规则场景 + TenantUtils.execute(scene.getTenantId(), + () -> executeSceneRuleAction(null, ListUtil.toList(scene))); + } + + /** + * 基于消息,获得匹配的规则场景列表 + * + * @param message 设备消息 + * @return 规则场景列表 + */ + private List getMatchedSceneRuleListByMessage(IotDeviceMessage message) { + // 1. 匹配设备 + // TODO @芋艿:可能需要 getSelf(); 缓存 @puhui999; + // 1.1 通过 deviceId 获取设备信息 + IotDeviceDO device = deviceService.getDeviceFromCache(message.getDeviceId()); + if (device == null) { + log.warn("[getMatchedSceneRuleListByMessage][设备({}) 不存在]", message.getDeviceId()); + return List.of(); + } + + // 1.2 通过 productId 获取产品信息 + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product == null) { + log.warn("[getMatchedSceneRuleListByMessage][产品({}) 不存在]", device.getProductId()); + return List.of(); + } + + // 1.3 获取匹配的规则场景 + List sceneRules = getSceneRuleListByProductIdAndDeviceIdFromCache( + product.getId(), device.getId()); + if (CollUtil.isEmpty(sceneRules)) { + return sceneRules; + } + + // 2. 使用重构后的触发器匹配逻辑 + return filterList(sceneRules, sceneRule -> matchSceneRuleTriggers(message, sceneRule)); + } + + /** + * 匹配场景规则的所有触发器 + * + * @param message 设备消息 + * @param sceneRule 场景规则 + * @return 是否匹配 + */ + private boolean matchSceneRuleTriggers(IotDeviceMessage message, IotSceneRuleDO sceneRule) { + if (CollUtil.isEmpty(sceneRule.getTriggers())) { + log.debug("[matchSceneRuleTriggers][规则场景({}) 没有配置触发器]", sceneRule.getId()); + return false; + } + + for (IotSceneRuleDO.Trigger trigger : sceneRule.getTriggers()) { + if (matchSingleTrigger(message, trigger, sceneRule)) { + log.info("[matchSceneRuleTriggers][消息({}) 匹配到规则场景编号({}) 的触发器({})]", + message.getRequestId(), sceneRule.getId(), trigger.getType()); + return true; + } + } + return false; + } + + /** + * 匹配单个触发器 + * + * @param message 设备消息 + * @param trigger 触发器 + * @param sceneRule 场景规则(用于日志) + * @return 是否匹配 + */ + private boolean matchSingleTrigger(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger, IotSceneRuleDO sceneRule) { + try { + // 2. 检查触发器的条件分组 + return sceneRuleMatcherManager.isMatched(message, trigger) && isTriggerConditionGroupsMatched(message, trigger, sceneRule); + } catch (Exception e) { + log.error("[matchSingleTrigger][触发器匹配异常] sceneRuleId: {}, triggerType: {}, message: {}", + sceneRule.getId(), trigger.getType(), message, e); + return false; + } + } + + /** + * 检查触发器的条件分组是否匹配 + * + * @param message 设备消息 + * @param trigger 触发器 + * @param sceneRule 场景规则(用于日志) + * @return 是否匹配 + */ + private boolean isTriggerConditionGroupsMatched(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger, IotSceneRuleDO sceneRule) { + // 如果没有条件分组,则认为匹配成功(只依赖基础触发器匹配) + if (CollUtil.isEmpty(trigger.getConditionGroups())) { + return true; + } + + // 检查条件分组:分组与分组之间是"或"的关系,条件与条件之间是"且"的关系 + for (List conditionGroup : trigger.getConditionGroups()) { + if (CollUtil.isEmpty(conditionGroup)) { + continue; + } + + // 检查当前分组中的所有条件是否都匹配(且关系) + boolean allConditionsMatched = true; + for (IotSceneRuleDO.TriggerCondition condition : conditionGroup) { + if (!isTriggerConditionMatched(message, condition, sceneRule, trigger)) { + allConditionsMatched = false; + break; + } + } + + // 如果当前分组的所有条件都匹配,则整个触发器匹配成功 + if (allConditionsMatched) { + return true; + } + } + + // 所有分组都不匹配 + return false; + } + + /** + * 基于消息,判断触发器的子条件是否匹配 + * + * @param message 设备消息 + * @param condition 触发条件 + * @param sceneRule 规则场景(用于日志,无其它作用) + * @param trigger 触发器(用于日志,无其它作用) + * @return 是否匹配 + */ + private boolean isTriggerConditionMatched(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition, + IotSceneRuleDO sceneRule, IotSceneRuleDO.Trigger trigger) { + try { + return sceneRuleMatcherManager.isConditionMatched(message, condition); + } catch (Exception e) { + log.error("[isTriggerConditionMatched][规则场景编号({}) 的触发器({}) 条件匹配异常]", + sceneRule.getId(), trigger, e); + return false; + } + } + + /** + * 执行规则场景的动作 + * + * @param message 设备消息 + * @param sceneRules 规则场景列表 + */ + private void executeSceneRuleAction(IotDeviceMessage message, List sceneRules) { + // 1. 遍历规则场景 + sceneRules.forEach(sceneRule -> { + // 2. 遍历规则场景的动作 + sceneRule.getActions().forEach(actionConfig -> { + // 3.1 获取对应的动作 Action 数组 + List actions = filterList(sceneRuleActions, + action -> action.getType().getType().equals(actionConfig.getType())); + if (CollUtil.isEmpty(actions)) { + return; + } + // 3.2 执行动作 + actions.forEach(action -> { + try { + action.execute(message, sceneRule, actionConfig); + log.info("[executeSceneRuleAction][消息({}) 规则场景编号({}) 的执行动作({}) 成功]", + message, sceneRule.getId(), actionConfig); + } catch (Exception e) { + log.error("[executeSceneRuleAction][消息({}) 规则场景编号({}) 的执行动作({}) 执行异常]", + message, sceneRule.getId(), actionConfig, e); + } + }); + }); + }); + } + + private IotSceneRuleServiceImpl getSelf() { + return SpringUtil.getBean(IotSceneRuleServiceImpl.class); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertRecoverSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertRecoverSceneRuleAction.java new file mode 100644 index 0000000000..851f3815fa --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertRecoverSceneRuleAction.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +// TODO @puhui999、@芋艿:未测试;需要场景联动开发完 +/** + * IoT 告警恢复的 {@link IotSceneRuleAction} 实现类 + * + * @author 芋道源码 + */ +@Component +public class IotAlertRecoverSceneRuleAction implements IotSceneRuleAction { + + private static final String PROCESS_REMARK = "告警自动回复,基于【{}】场景联动规则"; + + @Resource + private IotAlertRecordService alertRecordService; + + @Override + public void execute(IotDeviceMessage message, + IotSceneRuleDO rule, IotSceneRuleDO.Action actionConfig) throws Exception { + Long deviceId = message != null ? message.getDeviceId() : null; + List alertRecords = alertRecordService.getAlertRecordListBySceneRuleId( + rule.getId(), deviceId, false); + if (CollUtil.isEmpty(alertRecords)) { + return; + } + alertRecordService.processAlertRecordList(convertList(alertRecords, IotAlertRecordDO::getId), + StrUtil.format(PROCESS_REMARK, rule.getName())); + } + + @Override + public IotSceneRuleActionTypeEnum getType() { + return IotSceneRuleActionTypeEnum.ALERT_RECOVER; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java new file mode 100644 index 0000000000..28223dbd6e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotAlertTriggerSceneRuleAction.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertConfigService; +import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService; +import cn.iocoder.yudao.module.system.api.mail.MailSendApi; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.sms.SmsSendApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import javax.annotation.Nullable; +import java.util.List; + +// TODO @puhui999、@芋艿:未测试;需要场景联动开发完 +/** + * IoT 告警触发的 {@link IotSceneRuleAction} 实现类 + * + * @author 芋道源码 + */ +@Component +public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction { + + @Resource + private IotAlertConfigService alertConfigService; + @Resource + private IotAlertRecordService alertRecordService; + + @Resource + private SmsSendApi smsSendApi; + @Resource + private MailSendApi mailSendApi; + @Resource + private NotifyMessageSendApi notifyMessageSendApi; + + @Override + public void execute(@Nullable IotDeviceMessage message, + IotSceneRuleDO rule, IotSceneRuleDO.Action actionConfig) throws Exception { + List alertConfigs = alertConfigService.getAlertConfigListBySceneRuleIdAndStatus( + rule.getId(), CommonStatusEnum.ENABLE.getStatus()); + if (CollUtil.isEmpty(alertConfigs)) { + return; + } + alertConfigs.forEach(alertConfig -> { + // 记录告警记录,传递场景规则ID + alertRecordService.createAlertRecord(alertConfig, rule.getId(), message); + // 发送告警消息 + sendAlertMessage(alertConfig, message); + }); + } + + private void sendAlertMessage(IotAlertConfigDO config, IotDeviceMessage deviceMessage) { + // TODO @芋艿:等场景联动开发完,再实现 + // TODO @芋艿:短信 + // TODO @芋艿:邮箱 + // TODO @芋艿:站内信 + } + + @Override + public IotSceneRuleActionTypeEnum getType() { + return IotSceneRuleActionTypeEnum.ALERT_TRIGGER; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceControlSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceControlSceneRuleAction.java new file mode 100644 index 0000000000..b71a92091b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceControlSceneRuleAction.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * IoT 设备控制的 {@link IotSceneRuleAction} 实现类 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class IotDeviceControlSceneRuleAction implements IotSceneRuleAction { + + @Resource + private IotDeviceService deviceService; + @Resource + private IotDeviceMessageService deviceMessageService; + + // TODO @puhui999:这里 + @Override + public void execute(IotDeviceMessage message, + IotSceneRuleDO rule, IotSceneRuleDO.Action actionConfig) { + //IotSceneRuleDO.ActionDeviceControl control = actionConfig.getDeviceControl(); + //Assert.notNull(control, "设备控制配置不能为空"); + //// 遍历每个设备,下发消息 + //control.getDeviceNames().forEach(deviceName -> { + // IotDeviceDO device = deviceService.getDeviceFromCache(control.getProductKey(), deviceName); + // if (device == null) { + // log.error("[execute][message({}) actionConfig({}) 对应的设备不存在]", message, actionConfig); + // return; + // } + // try { + // // TODO @芋艿:@puhui999:这块可能要改,从 type => method + // IotDeviceMessage downstreamMessage = deviceMessageService.sendDeviceMessage(IotDeviceMessage.requestOf( + // control.getType() + control.getIdentifier(), control.getData()).setDeviceId(device.getId())); + // log.info("[execute][message({}) actionConfig({}) 下发消息({})成功]", message, actionConfig, downstreamMessage); + // } catch (Exception e) { + // log.error("[execute][message({}) actionConfig({}) 下发消息失败]", message, actionConfig, e); + // } + //}); + } + + @Override + public IotSceneRuleActionTypeEnum getType() { + return IotSceneRuleActionTypeEnum.DEVICE_PROPERTY_SET; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotSceneRuleAction.java new file mode 100644 index 0000000000..c88a37f8ce --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotSceneRuleAction.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.action; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleActionTypeEnum; + +import javax.annotation.Nullable; + +/** + * IoT 场景联动的执行器接口 + * + * @author 芋道源码 + */ +public interface IotSceneRuleAction { + + /** + * 执行场景联动 + * + * @param message 消息,允许空 + * 1. 空的情况:定时触发 + * 2. 非空的情况:设备触发 + * @param rule 规则 + * @param actionConfig 执行配置(实际对应规则里的哪条执行配置) + */ + void execute(@Nullable IotDeviceMessage message, + IotSceneRuleDO rule, + IotSceneRuleDO.Action actionConfig) throws Exception; + + /** + * 获得类型 + * + * @return 类型 + */ + IotSceneRuleActionTypeEnum getType(); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcher.java new file mode 100644 index 0000000000..84795d9fe5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcher.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher; + +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotSceneRuleConditionMatcher; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger.IotSceneRuleTriggerMatcher; + +/** + * IoT 场景规则匹配器基础接口 + *

+ * 定义所有匹配器的通用行为,包括优先级、名称和启用状态 + *

+ * - {@link IotSceneRuleTriggerMatcher} 触发器匹配器 + * - {@link IotSceneRuleConditionMatcher} 条件匹配器 + * + * @author HUIHUI + */ +public interface IotSceneRuleMatcher { + + /** + * 获取匹配优先级(数值越小优先级越高) + *

+ * 用于在多个匹配器支持同一类型时确定优先级 + * + * @return 优先级数值 + */ + default int getPriority() { + return 100; + } + + /** + * 是否启用该匹配器 + *

+ * 可用于动态开关某些匹配器 + * + * @return 是否启用 + */ + default boolean isEnabled() { + return true; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java new file mode 100644 index 0000000000..7175e37a7e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherHelper.java @@ -0,0 +1,238 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher; + +import cn.hutool.core.text.CharPool; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.common.util.spring.SpringExpressionUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * IoT 场景规则匹配器工具类 + *

+ * 提供通用的条件评估逻辑和工具方法,供触发器和条件匹配器使用 + *

+ * 该类包含了匹配器实现中常用的工具方法,如条件评估、参数校验、日志记录等 + * + * @author HUIHUI + */ +@Slf4j +public final class IotSceneRuleMatcherHelper { + + /** + * 私有构造函数,防止实例化 + */ + private IotSceneRuleMatcherHelper() { + } + + /** + * 评估条件是否匹配 + * + * @param sourceValue 源值(来自消息) + * @param operator 操作符 + * @param paramValue 参数值(来自条件配置) + * @return 是否匹配 + */ + public static boolean evaluateCondition(Object sourceValue, String operator, String paramValue) { + try { + // 1. 校验操作符是否合法 + IotSceneRuleConditionOperatorEnum operatorEnum = IotSceneRuleConditionOperatorEnum.operatorOf(operator); + if (operatorEnum == null) { + log.warn("[evaluateCondition][operator({}) 操作符无效]", operator); + return false; + } + + // 2. 构建 Spring 表达式变量 + return evaluateConditionWithOperatorEnum(sourceValue, operatorEnum, paramValue); + } catch (Exception e) { + log.error("[evaluateCondition][sourceValue({}) operator({}) paramValue({}) 条件评估异常]", + sourceValue, operator, paramValue, e); + return false; + } + } + + /** + * 使用操作符枚举评估条件是否匹配 + * + * @param sourceValue 源值(来自消息) + * @param operatorEnum 操作符枚举 + * @param paramValue 参数值(来自条件配置) + * @return 是否匹配 + */ + @SuppressWarnings("DataFlowIssue") + public static boolean evaluateConditionWithOperatorEnum(Object sourceValue, IotSceneRuleConditionOperatorEnum operatorEnum, String paramValue) { + try { + // 1. 构建 Spring 表达式变量 + Map springExpressionVariables = buildSpringExpressionVariables(sourceValue, operatorEnum, paramValue); + + // 2. 计算 Spring 表达式 + return (Boolean) SpringExpressionUtils.parseExpression(operatorEnum.getSpringExpression(), springExpressionVariables); + } catch (Exception e) { + log.error("[evaluateConditionWithOperatorEnum][sourceValue({}) operatorEnum({}) paramValue({}) 条件评估异常]", + sourceValue, operatorEnum, paramValue, e); + return false; + } + } + + /** + * 构建 Spring 表达式变量 + */ + private static Map buildSpringExpressionVariables(Object sourceValue, IotSceneRuleConditionOperatorEnum operatorEnum, String paramValue) { + Map springExpressionVariables = new HashMap<>(); + + // 设置源值 + springExpressionVariables.put(IotSceneRuleConditionOperatorEnum.SPRING_EXPRESSION_SOURCE, sourceValue); + + // 处理参数值 + if (StrUtil.isNotBlank(paramValue)) { + List parameterValues = StrUtil.splitTrim(paramValue, CharPool.COMMA); + + // 设置原始参数值 + springExpressionVariables.put(IotSceneRuleConditionOperatorEnum.SPRING_EXPRESSION_VALUE, paramValue); + springExpressionVariables.put(IotSceneRuleConditionOperatorEnum.SPRING_EXPRESSION_VALUE_LIST, parameterValues); + + // 特殊处理:解决数字比较问题 + // Spring 表达式基于 compareTo 方法,对数字的比较存在问题,需要转换为数字类型 + if (isNumericComparisonOperator(operatorEnum) && isNumericComparison(sourceValue, parameterValues)) { + springExpressionVariables.put(IotSceneRuleConditionOperatorEnum.SPRING_EXPRESSION_SOURCE, + NumberUtil.parseDouble(String.valueOf(sourceValue))); + springExpressionVariables.put(IotSceneRuleConditionOperatorEnum.SPRING_EXPRESSION_VALUE, + NumberUtil.parseDouble(paramValue)); + springExpressionVariables.put(IotSceneRuleConditionOperatorEnum.SPRING_EXPRESSION_VALUE_LIST, + convertList(parameterValues, NumberUtil::parseDouble)); + } + } + + return springExpressionVariables; + } + + /** + * 判断是否为数字比较操作符 + */ + private static boolean isNumericComparisonOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { + return ObjectUtils.equalsAny(operatorEnum, + IotSceneRuleConditionOperatorEnum.BETWEEN, + IotSceneRuleConditionOperatorEnum.NOT_BETWEEN, + IotSceneRuleConditionOperatorEnum.GREATER_THAN, + IotSceneRuleConditionOperatorEnum.GREATER_THAN_OR_EQUALS, + IotSceneRuleConditionOperatorEnum.LESS_THAN, + IotSceneRuleConditionOperatorEnum.LESS_THAN_OR_EQUALS); + } + + /** + * 判断是否为数字比较场景 + */ + private static boolean isNumericComparison(Object sourceValue, List parameterValues) { + return NumberUtil.isNumber(String.valueOf(sourceValue)) && NumberUtils.isAllNumber(parameterValues); + } + + // ========== 【触发器】相关工具方法 ========== + + /** + * 检查基础触发器参数是否有效 + * + * @param trigger 触发器配置 + * @return 是否有效 + */ + public static boolean isBasicTriggerValid(IotSceneRuleDO.Trigger trigger) { + return trigger != null && trigger.getType() != null; + } + + /** + * 检查触发器操作符和值是否有效 + * + * @param trigger 触发器配置 + * @return 是否有效 + */ + public static boolean isTriggerOperatorAndValueValid(IotSceneRuleDO.Trigger trigger) { + return StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue()); + } + + /** + * 记录触发器匹配成功日志 + * + * @param message 设备消息 + * @param trigger 触发器配置 + */ + public static void logTriggerMatchSuccess(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + log.debug("[isMatched][message({}) trigger({}) 匹配触发器成功]", message.getRequestId(), trigger.getType()); + } + + /** + * 记录触发器匹配失败日志 + * + * @param message 设备消息 + * @param trigger 触发器配置 + * @param reason 失败原因 + */ + public static void logTriggerMatchFailure(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger, String reason) { + log.debug("[isMatched][message({}) trigger({}) reason({}) 匹配触发器失败]", message.getRequestId(), trigger.getType(), reason); + } + + // ========== 【条件】相关工具方法 ========== + + /** + * 检查基础条件参数是否有效 + * + * @param condition 触发条件 + * @return 是否有效 + */ + public static boolean isBasicConditionValid(IotSceneRuleDO.TriggerCondition condition) { + return condition != null && condition.getType() != null; + } + + /** + * 检查条件操作符和参数是否有效 + * + * @param condition 触发条件 + * @return 是否有效 + */ + public static boolean isConditionOperatorAndParamValid(IotSceneRuleDO.TriggerCondition condition) { + return StrUtil.isNotBlank(condition.getOperator()) && StrUtil.isNotBlank(condition.getParam()); + } + + /** + * 记录条件匹配成功日志 + * + * @param message 设备消息 + * @param condition 触发条件 + */ + public static void logConditionMatchSuccess(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition) { + log.debug("[isMatched][message({}) condition({}) 匹配条件成功]", message.getRequestId(), condition.getType()); + } + + /** + * 记录条件匹配失败日志 + * + * @param message 设备消息 + * @param condition 触发条件 + * @param reason 失败原因 + */ + public static void logConditionMatchFailure(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition, String reason) { + log.debug("[isMatched][message({}) condition({}) reason({}) 匹配条件失败]", message.getRequestId(), condition.getType(), reason); + } + + // ========== 【通用】工具方法 ========== + + /** + * 检查标识符是否匹配 + * + * @param expectedIdentifier 期望的标识符 + * @param actualIdentifier 实际的标识符 + * @return 是否匹配 + */ + public static boolean isIdentifierMatched(String expectedIdentifier, String actualIdentifier) { + return StrUtil.isNotBlank(expectedIdentifier) && expectedIdentifier.equals(actualIdentifier); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java new file mode 100644 index 0000000000..3658fc07cd --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/IotSceneRuleMatcherManager.java @@ -0,0 +1,159 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotSceneRuleConditionMatcher; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger.IotSceneRuleTriggerMatcher; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.function.Function; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * IoT 场景规则匹配器统一管理器 + *

+ * 负责管理所有匹配器(触发器匹配器和条件匹配器),并提供统一的匹配入口 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class IotSceneRuleMatcherManager { + + /** + * 触发器匹配器映射表 + */ + private final Map triggerMatchers; + + /** + * 条件匹配器映射表 + */ + private final Map conditionMatchers; + + public IotSceneRuleMatcherManager(List matchers) { + if (CollUtil.isEmpty(matchers)) { + log.warn("[IotSceneRuleMatcherManager][没有找到任何匹配器]"); + this.triggerMatchers = new HashMap<>(); + this.conditionMatchers = new HashMap<>(); + return; + } + + // 按优先级排序并过滤启用的匹配器 + List allMatchers = matchers.stream() + .filter(IotSceneRuleMatcher::isEnabled) + .sorted(Comparator.comparing(IotSceneRuleMatcher::getPriority)) + .toList(); + + // 分离触发器匹配器和条件匹配器 + List triggerMatchers = allMatchers.stream() + .filter(matcher -> matcher instanceof IotSceneRuleTriggerMatcher) + .map(matcher -> (IotSceneRuleTriggerMatcher) matcher) + .toList(); + List conditionMatchers = allMatchers.stream() + .filter(matcher -> matcher instanceof IotSceneRuleConditionMatcher) + .map(matcher -> (IotSceneRuleConditionMatcher) matcher) + .toList(); + + // 构建触发器匹配器映射表 + this.triggerMatchers = convertMap(triggerMatchers, IotSceneRuleTriggerMatcher::getSupportedTriggerType, + Function.identity(), + (existing, replacement) -> { + log.warn("[IotSceneRuleMatcherManager][触发器类型({})存在多个匹配器,使用优先级更高的: {}]", + existing.getSupportedTriggerType(), + existing.getPriority() <= replacement.getPriority() ? + existing.getSupportedTriggerType() : replacement.getSupportedTriggerType()); + return existing.getPriority() <= replacement.getPriority() ? existing : replacement; + }, LinkedHashMap::new); + // 构建条件匹配器映射表 + this.conditionMatchers = convertMap(conditionMatchers, IotSceneRuleConditionMatcher::getSupportedConditionType, + Function.identity(), + (existing, replacement) -> { + log.warn("[IotSceneRuleMatcherManager][条件类型({})存在多个匹配器,使用优先级更高的: {}]", + existing.getSupportedConditionType(), + existing.getPriority() <= replacement.getPriority() ? + existing.getSupportedConditionType() : replacement.getSupportedConditionType()); + return existing.getPriority() <= replacement.getPriority() ? existing : replacement; + }, + LinkedHashMap::new); + + // 日志输出初始化信息 + log.info("[IotSceneRuleMatcherManager][初始化完成,共加载({})个匹配器,其中触发器匹配器({})个,条件匹配器({})个]", + allMatchers.size(), this.triggerMatchers.size(), this.conditionMatchers.size()); + this.triggerMatchers.forEach((type, matcher) -> + log.info("[IotSceneRuleMatcherManager][触发器匹配器类型: ({}), 优先级: ({})] ", type, matcher.getPriority())); + this.conditionMatchers.forEach((type, matcher) -> + log.info("[IotSceneRuleMatcherManager][条件匹配器类型: ({}), 优先级: ({})]", type, matcher.getPriority())); + } + + /** + * 检查触发器是否匹配消息(主条件匹配) + * + * @param message 设备消息 + * @param trigger 触发器配置 + * @return 是否匹配 + */ + public boolean isMatched(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + if (message == null || trigger == null || trigger.getType() == null) { + log.debug("[isMatched][message({}) trigger({}) 参数无效]", message, trigger); + return false; + } + IotSceneRuleTriggerTypeEnum triggerType = IotSceneRuleTriggerTypeEnum.typeOf(trigger.getType()); + if (triggerType == null) { + log.warn("[isMatched][triggerType({}) 未知的触发器类型]", trigger.getType()); + return false; + } + IotSceneRuleTriggerMatcher matcher = triggerMatchers.get(triggerType); + if (matcher == null) { + log.warn("[isMatched][triggerType({}) 没有对应的匹配器]", triggerType); + return false; + } + + try { + return matcher.matches(message, trigger); + } catch (Exception e) { + log.error("[isMatched][触发器匹配异常] message: {}, trigger: {}", message, trigger, e); + return false; + } + } + + /** + * 检查子条件是否匹配消息 + * + * @param message 设备消息 + * @param condition 触发条件 + * @return 是否匹配 + */ + public boolean isConditionMatched(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition) { + if (message == null || condition == null || condition.getType() == null) { + log.debug("[isConditionMatched][message({}) condition({}) 参数无效]", message, condition); + return false; + } + + // 根据条件类型查找对应的匹配器 + IotSceneRuleConditionTypeEnum conditionType = IotSceneRuleConditionTypeEnum.typeOf(condition.getType()); + if (conditionType == null) { + log.warn("[isConditionMatched][conditionType({}) 未知的条件类型]", condition.getType()); + return false; + } + IotSceneRuleConditionMatcher matcher = conditionMatchers.get(conditionType); + if (matcher == null) { + log.warn("[isConditionMatched][conditionType({}) 没有对应的匹配器]", conditionType); + return false; + } + + // 执行匹配逻辑 + try { + return matcher.matches(message, condition); + } catch (Exception e) { + log.error("[isConditionMatched][message({}) condition({}) 条件匹配异常]", message, condition, e); + return false; + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/CurrentTimeConditionMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/CurrentTimeConditionMatcher.java new file mode 100644 index 0000000000..81c8fba597 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/CurrentTimeConditionMatcher.java @@ -0,0 +1,229 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.text.CharPool; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 当前时间条件匹配器 + *

+ * 处理时间相关的子条件匹配逻辑 + * + * @author HUIHUI + */ +@Component +@Slf4j +public class CurrentTimeConditionMatcher implements IotSceneRuleConditionMatcher { + + /** + * 时间格式化器 - HH:mm:ss + */ + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** + * 时间格式化器 - HH:mm + */ + private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm"); + + @Override + public IotSceneRuleConditionTypeEnum getSupportedConditionType() { + return IotSceneRuleConditionTypeEnum.CURRENT_TIME; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicConditionValid(condition)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "条件基础参数无效"); + return false; + } + + // 1.2 检查操作符和参数是否有效 + if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "操作符或参数无效"); + return false; + } + + // 1.3 验证操作符是否为支持的时间操作符 + String operator = condition.getOperator(); + IotSceneRuleConditionOperatorEnum operatorEnum = IotSceneRuleConditionOperatorEnum.operatorOf(operator); + if (operatorEnum == null) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "无效的操作符: " + operator); + return false; + } + + if (!isTimeOperator(operatorEnum)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "不支持的时间操作符: " + operator); + return false; + } + + // 2.1 执行时间匹配 + boolean matched = executeTimeMatching(operatorEnum, condition.getParam()); + + // 2.2 记录匹配结果 + if (matched) { + IotSceneRuleMatcherHelper.logConditionMatchSuccess(message, condition); + } else { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "时间条件不匹配"); + } + + return matched; + } + + /** + * 执行时间匹配逻辑 + * 直接实现时间条件匹配,不使用 Spring EL 表达式 + */ + private boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) { + try { + LocalDateTime now = LocalDateTime.now(); + + if (isDateTimeOperator(operatorEnum)) { + // 日期时间匹配(时间戳) + long currentTimestamp = now.toEpochSecond(java.time.ZoneOffset.of("+8")); + return matchDateTime(currentTimestamp, operatorEnum, param); + } else { + // 当日时间匹配(HH:mm:ss) + return matchTime(now.toLocalTime(), operatorEnum, param); + } + } catch (Exception e) { + log.error("[executeTimeMatching][operatorEnum({}) param({}) 时间匹配异常]", operatorEnum, param, e); + return false; + } + } + + /** + * 判断是否为日期时间操作符 + */ + private boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { + return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN || + operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN || + operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN; + } + + /** + * 判断是否为时间操作符 + */ + private boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { + return operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN || + operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN || + operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN || + isDateTimeOperator(operatorEnum); + } + + /** + * 匹配日期时间(时间戳) + * 直接实现时间戳比较逻辑 + */ + private boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum, String param) { + try { + long targetTimestamp = Long.parseLong(param); + switch (operatorEnum) { + case DATE_TIME_GREATER_THAN: + return currentTimestamp > targetTimestamp; + case DATE_TIME_LESS_THAN: + return currentTimestamp < targetTimestamp; + case DATE_TIME_BETWEEN: + return matchDateTimeBetween(currentTimestamp, param); + default: + log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum); + return false; + } + } catch (Exception e) { + log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e); + return false; + } + } + + /** + * 匹配日期时间区间 + */ + private boolean matchDateTimeBetween(long currentTimestamp, String param) { + List timestampRange = StrUtil.splitTrim(param, CharPool.COMMA); + if (timestampRange.size() != 2) { + log.warn("[matchDateTimeBetween][param({}) 时间戳区间参数格式错误]", param); + return false; + } + long startTimestamp = Long.parseLong(timestampRange.get(0).trim()); + long endTimestamp = Long.parseLong(timestampRange.get(1).trim()); + return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp; + } + + /** + * 匹配当日时间(HH:mm:ss) + * 直接实现时间比较逻辑 + */ + private boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum, String param) { + try { + LocalTime targetTime = parseTime(param); + switch (operatorEnum) { + case TIME_GREATER_THAN: + return currentTime.isAfter(targetTime); + case TIME_LESS_THAN: + return currentTime.isBefore(targetTime); + case TIME_BETWEEN: + return matchTimeBetween(currentTime, param); + default: + log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum); + return false; + } + } catch (Exception e) { + log.error("[matchTime][][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e); + return false; + } + } + + /** + * 匹配时间区间 + */ + private boolean matchTimeBetween(LocalTime currentTime, String param) { + List timeRange = StrUtil.splitTrim(param, CharPool.COMMA); + if (timeRange.size() != 2) { + log.warn("[matchTimeBetween][param({}) 时间区间参数格式错误]", param); + return false; + } + LocalTime startTime = parseTime(timeRange.get(0).trim()); + LocalTime endTime = parseTime(timeRange.get(1).trim()); + return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime); + } + + /** + * 解析时间字符串 + * 支持 HH:mm 和 HH:mm:ss 两种格式 + */ + private LocalTime parseTime(String timeStr) { + Assert.isFalse(StrUtil.isBlank(timeStr), "时间字符串不能为空"); + + try { + // 尝试不同的时间格式 + if (timeStr.length() == 5) { // HH:mm + return LocalTime.parse(timeStr, TIME_FORMATTER_SHORT); + } else if (timeStr.length() == 8) { // HH:mm:ss + return LocalTime.parse(timeStr, TIME_FORMATTER); + } else { + throw new IllegalArgumentException("时间格式长度不正确,期望 HH:mm 或 HH:mm:ss 格式"); + } + } catch (Exception e) { + log.error("[parseTime][timeStr({}) 时间格式解析失败]", timeStr, e); + throw new IllegalArgumentException("时间格式无效: " + timeStr, e); + } + } + + @Override + public int getPriority() { + return 40; // 较低优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DevicePropertyConditionMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DevicePropertyConditionMatcher.java new file mode 100644 index 0000000000..4a8a8ab6f5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DevicePropertyConditionMatcher.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.springframework.stereotype.Component; + +/** + * 设备属性条件匹配器 + *

+ * 处理设备属性相关的子条件匹配逻辑 + * + * @author HUIHUI + */ +@Component +public class DevicePropertyConditionMatcher implements IotSceneRuleConditionMatcher { + + @Override + public IotSceneRuleConditionTypeEnum getSupportedConditionType() { + return IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicConditionValid(condition)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "条件基础参数无效"); + return false; + } + + // 1.2 检查标识符是否匹配 + String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); + if (!IotSceneRuleMatcherHelper.isIdentifierMatched(condition.getIdentifier(), messageIdentifier)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "标识符不匹配,期望: " + condition.getIdentifier() + ", 实际: " + messageIdentifier); + return false; + } + + // 1.3 检查操作符和参数是否有效 + if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "操作符或参数无效"); + return false; + } + + // 2.1. 获取属性值 + Object propertyValue = message.getParams(); + if (propertyValue == null) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "消息中属性值为空"); + return false; + } + + // 2.2 使用条件评估器进行匹配 + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(propertyValue, condition.getOperator(), condition.getParam()); + if (matched) { + IotSceneRuleMatcherHelper.logConditionMatchSuccess(message, condition); + } else { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "设备属性条件不匹配"); + } + return matched; + } + + @Override + public int getPriority() { + return 25; // 中等优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DeviceStateConditionMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DeviceStateConditionMatcher.java new file mode 100644 index 0000000000..d5bb97a53e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DeviceStateConditionMatcher.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.springframework.stereotype.Component; + +/** + * 设备状态条件匹配器 + *

+ * 处理设备状态相关的子条件匹配逻辑 + * + * @author HUIHUI + */ +@Component +public class DeviceStateConditionMatcher implements IotSceneRuleConditionMatcher { + + @Override + public IotSceneRuleConditionTypeEnum getSupportedConditionType() { + return IotSceneRuleConditionTypeEnum.DEVICE_STATE; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicConditionValid(condition)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "条件基础参数无效"); + return false; + } + + // 1.2 检查操作符和参数是否有效 + if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "操作符或参数无效"); + return false; + } + + // 2.1 获取设备状态值 + Object stateValue = message.getParams(); + if (stateValue == null) { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "消息中设备状态值为空"); + return false; + } + + // 2.2 使用条件评估器进行匹配 + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(stateValue, condition.getOperator(), condition.getParam()); + if (matched) { + IotSceneRuleMatcherHelper.logConditionMatchSuccess(message, condition); + } else { + IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "设备状态条件不匹配"); + } + return matched; + } + + @Override + public int getPriority() { + return 30; // 中等优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotSceneRuleConditionMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotSceneRuleConditionMatcher.java new file mode 100644 index 0000000000..875e8b1563 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotSceneRuleConditionMatcher.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcher; + +/** + * IoT 场景规则条件匹配器接口 + *

+ * 专门处理子条件的匹配逻辑,如设备状态、属性值、时间条件等 + *

+ * 条件匹配器负责判断设备消息是否满足场景规则的附加条件, + * 在触发器匹配成功后进行进一步的条件筛选 + * + * @author HUIHUI + */ +public interface IotSceneRuleConditionMatcher extends IotSceneRuleMatcher { + + /** + * 获取支持的条件类型 + * + * @return 条件类型枚举 + */ + IotSceneRuleConditionTypeEnum getSupportedConditionType(); + + /** + * 检查条件是否匹配消息 + *

+ * 判断设备消息是否满足指定的触发条件 + * + * @param message 设备消息 + * @param condition 触发条件 + * @return 是否匹配 + */ + boolean matches(IotDeviceMessage message, IotSceneRuleDO.TriggerCondition condition); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceEventPostTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceEventPostTriggerMatcher.java new file mode 100644 index 0000000000..1ab1bb9d26 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceEventPostTriggerMatcher.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.springframework.stereotype.Component; + +/** + * 设备事件上报触发器匹配器 + *

+ * 处理设备事件上报的触发器匹配逻辑 + * + * @author HUIHUI + */ +@Component +public class DeviceEventPostTriggerMatcher implements IotSceneRuleTriggerMatcher { + + @Override + public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() { + return IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicTriggerValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效"); + return false; + } + + // 1.2 检查消息方法是否匹配 + if (!IotDeviceMessageMethodEnum.EVENT_POST.getMethod().equals(message.getMethod())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + + IotDeviceMessageMethodEnum.EVENT_POST.getMethod() + ", 实际: " + message.getMethod()); + return false; + } + + // 1.3 检查标识符是否匹配 + String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); + if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " + + trigger.getIdentifier() + ", 实际: " + messageIdentifier); + return false; + } + + // 2. 对于事件触发器,通常不需要检查操作符和值,只要事件发生即匹配 + // 但如果配置了操作符和值,则需要进行条件匹配 + if (StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue())) { + Object eventData = message.getData(); + if (eventData == null) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中事件数据为空"); + return false; + } + + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(eventData, trigger.getOperator(), trigger.getValue()); + if (!matched) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "事件数据条件不匹配"); + return false; + } + } + + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); + return true; + } + + @Override + public int getPriority() { + return 30; // 中等优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DevicePropertyPostTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DevicePropertyPostTriggerMatcher.java new file mode 100644 index 0000000000..6eccdab427 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DevicePropertyPostTriggerMatcher.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.springframework.stereotype.Component; + +/** + * 设备属性上报触发器匹配器 + *

+ * 处理设备属性数据上报的触发器匹配逻辑 + * + * @author HUIHUI + */ +@Component +public class DevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerMatcher { + + @Override + public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() { + return IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicTriggerValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效"); + return false; + } + + // 1.2 检查消息方法是否匹配 + if (!IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod().equals(message.getMethod())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod() + ", 实际: " + message.getMethod()); + return false; + } + + // 1.3 检查标识符是否匹配 + String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); + if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " + + trigger.getIdentifier() + ", 实际: " + messageIdentifier); + return false; + } + + // 1.4 检查操作符和值是否有效 + if (!IotSceneRuleMatcherHelper.isTriggerOperatorAndValueValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "操作符或值无效"); + return false; + } + + // 2.1 获取属性值 + Object propertyValue = message.getParams(); + if (propertyValue == null) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中属性值为空"); + return false; + } + + // 2.2 使用条件评估器进行匹配 + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(propertyValue, trigger.getOperator(), trigger.getValue()); + if (matched) { + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); + } else { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "属性值条件不匹配"); + } + return matched; + } + + @Override + public int getPriority() { + return 20; // 中等优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceServiceInvokeTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceServiceInvokeTriggerMatcher.java new file mode 100644 index 0000000000..e0caba2d37 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceServiceInvokeTriggerMatcher.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.springframework.stereotype.Component; + +/** + * 设备服务调用触发器匹配器 + *

+ * 处理设备服务调用的触发器匹配逻辑 + * + * @author HUIHUI + */ +@Component +public class DeviceServiceInvokeTriggerMatcher implements IotSceneRuleTriggerMatcher { + + @Override + public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() { + return IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicTriggerValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效"); + return false; + } + + // 1.2 检查消息方法是否匹配 + if (!IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod().equals(message.getMethod())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod() + ", 实际: " + message.getMethod()); + return false; + } + + // 1.3 检查标识符是否匹配 + String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); + if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " + trigger.getIdentifier() + ", 实际: " + messageIdentifier); + return false; + } + + // 2. 对于服务调用触发器,通常只需要匹配服务标识符即可 + // 不需要检查操作符和值,因为服务调用本身就是触发条件 + // TODO @puhui999: 服务调用时校验输入参数是否匹配条件 + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); + return true; + } + + @Override + public int getPriority() { + return 40; // 较低优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceStateUpdateTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceStateUpdateTriggerMatcher.java new file mode 100644 index 0000000000..edd3c4e907 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceStateUpdateTriggerMatcher.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.springframework.stereotype.Component; + +/** + * 设备状态更新触发器匹配器 + *

+ * 处理设备上下线状态变更的触发器匹配逻辑 + * + * @author HUIHUI + */ +@Component +public class DeviceStateUpdateTriggerMatcher implements IotSceneRuleTriggerMatcher { + + @Override + public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() { + return IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicTriggerValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效"); + return false; + } + + // 1.2 检查消息方法是否匹配 + if (!IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(message.getMethod())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod() + ", 实际: " + message.getMethod()); + return false; + } + + // 1.3 检查操作符和值是否有效 + if (!IotSceneRuleMatcherHelper.isTriggerOperatorAndValueValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "操作符或值无效"); + return false; + } + + // 2.1 获取设备状态值 + Object stateValue = message.getParams(); + if (stateValue == null) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中设备状态值为空"); + return false; + } + + // 2.2 使用条件评估器进行匹配 + // TODO @puhui999: 状态匹配重新实现 + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(stateValue, trigger.getOperator(), trigger.getValue()); + if (matched) { + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); + } else { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "状态值条件不匹配"); + } + return matched; + } + + @Override + public int getPriority() { + return 10; // 高优先级 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotSceneRuleTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotSceneRuleTriggerMatcher.java new file mode 100644 index 0000000000..89de00a686 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotSceneRuleTriggerMatcher.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcher; + +/** + * IoT 场景规则触发器匹配器接口 + *

+ * 专门处理主触发条件的匹配逻辑,如设备消息类型、定时器等 + *

+ * 触发器匹配器负责判断设备消息是否满足场景规则的主触发条件, + * 是场景规则执行的第一道门槛 + * + * @author HUIHUI + */ +public interface IotSceneRuleTriggerMatcher extends IotSceneRuleMatcher { + + /** + * 获取支持的触发器类型 + * + * @return 触发器类型枚举 + */ + IotSceneRuleTriggerTypeEnum getSupportedTriggerType(); + + /** + * 检查触发器是否匹配消息 + *

+ * 判断设备消息是否满足指定的触发器条件 + * + * @param message 设备消息 + * @param trigger 触发器配置 + * @return 是否匹配 + */ + boolean matches(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/TimerTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/TimerTriggerMatcher.java new file mode 100644 index 0000000000..794f8d6ae6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/TimerTriggerMatcher.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; +import org.quartz.CronExpression; +import org.springframework.stereotype.Component; + +/** + * 定时触发器匹配器 + *

+ * 处理定时触发的触发器匹配逻辑 + * 注意:定时触发器不依赖设备消息,主要用于定时任务场景 + * + * @author HUIHUI + */ +@Component +public class TimerTriggerMatcher implements IotSceneRuleTriggerMatcher { + + @Override + public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() { + return IotSceneRuleTriggerTypeEnum.TIMER; + } + + @Override + public boolean matches(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + // 1.1 基础参数校验 + if (!IotSceneRuleMatcherHelper.isBasicTriggerValid(trigger)) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效"); + return false; + } + + // 1.2 检查 CRON 表达式是否存在 + if (StrUtil.isBlank(trigger.getCronExpression())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "定时触发器缺少 CRON 表达式"); + return false; + } + + // 1.3 定时触发器通常不依赖具体的设备消息 + // 它是通过定时任务调度器触发的,这里主要是验证配置的有效性 + if (!CronExpression.isValidExpression(trigger.getCronExpression())) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "CRON 表达式格式无效: " + trigger.getCronExpression()); + return false; + } + + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); + return true; + } + + @Override + public int getPriority() { + return 50; // 最低优先级,因为定时触发器不依赖消息 + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml new file mode 100644 index 0000000000..deef23b5c7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml @@ -0,0 +1,109 @@ + + + + + + CREATE STABLE IF NOT EXISTS device_message ( + ts TIMESTAMP, + id NCHAR(50), + report_time TIMESTAMP, + tenant_id BIGINT, + server_id NCHAR(50), + upstream BOOL, + reply BOOL, + identifier NCHAR(100), + request_id NCHAR(50), + method NCHAR(100), + params NCHAR(2048), + data NCHAR(2048), + code INT, + msg NCHAR(256) + ) TAGS ( + device_id BIGINT + ) + + + + + + INSERT INTO device_message_${deviceId} ( + ts, id, report_time, tenant_id, server_id, + upstream, reply, identifier, request_id, method, + params, data, code, msg + ) + USING device_message + TAGS (#{deviceId}) + VALUES ( + NOW, #{id}, #{reportTime}, #{tenantId}, #{serverId}, + #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, + #{params}, #{data}, #{code}, #{msg} + ) + + + + + + + + + + + \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java new file mode 100644 index 0000000000..056794b797 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceSimpleTest.java @@ -0,0 +1,211 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.scene.IotSceneRuleSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper; +import cn.iocoder.yudao.module.iot.framework.job.core.IotSchedulerManager; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotSceneRuleServiceImpl} 的简化单元测试类 + * 使用 Mockito 进行纯单元测试,不依赖 Spring 容器 + * + * @author 芋道源码 + */ +public class IotSceneRuleServiceSimpleTest extends BaseMockitoUnitTest { + + @InjectMocks + private IotSceneRuleServiceImpl sceneRuleService; + + @Mock + private IotSceneRuleMapper sceneRuleMapper; + + @Mock + private List sceneRuleActions; + + @Mock + private IotSchedulerManager schedulerManager; + + @Mock + private IotProductService productService; + + @Mock + private IotDeviceService deviceService; + + @Test + public void testCreateScene_Rule_success() { + // 准备参数 + IotSceneRuleSaveReqVO createReqVO = randomPojo(IotSceneRuleSaveReqVO.class, o -> { + o.setId(null); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + o.setTriggers(Collections.singletonList(randomPojo(IotSceneRuleDO.Trigger.class))); + o.setActions(Collections.singletonList(randomPojo(IotSceneRuleDO.Action.class))); + }); + + // Mock 行为 + Long expectedId = randomLongId(); + when(sceneRuleMapper.insert(any(IotSceneRuleDO.class))).thenAnswer(invocation -> { + IotSceneRuleDO sceneRule = invocation.getArgument(0); + sceneRule.setId(expectedId); + return 1; + }); + + // 调用 + Long sceneRuleId = sceneRuleService.createSceneRule(createReqVO); + + // 断言 + assertEquals(expectedId, sceneRuleId); + verify(sceneRuleMapper, times(1)).insert(any(IotSceneRuleDO.class)); + } + + @Test + public void testUpdateScene_Rule_success() { + // 准备参数 + Long id = randomLongId(); + IotSceneRuleSaveReqVO updateReqVO = randomPojo(IotSceneRuleSaveReqVO.class, o -> { + o.setId(id); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + o.setTriggers(Collections.singletonList(randomPojo(IotSceneRuleDO.Trigger.class))); + o.setActions(Collections.singletonList(randomPojo(IotSceneRuleDO.Action.class))); + }); + + // Mock 行为 + IotSceneRuleDO existingSceneRule = randomPojo(IotSceneRuleDO.class, o -> o.setId(id)); + when(sceneRuleMapper.selectById(id)).thenReturn(existingSceneRule); + when(sceneRuleMapper.updateById(any(IotSceneRuleDO.class))).thenReturn(1); + + // 调用 + assertDoesNotThrow(() -> sceneRuleService.updateSceneRule(updateReqVO)); + + // 验证 + verify(sceneRuleMapper, times(1)).selectById(id); + verify(sceneRuleMapper, times(1)).updateById(any(IotSceneRuleDO.class)); + } + + @Test + public void testDeleteSceneRule_success() { + // 准备参数 + Long id = randomLongId(); + + // Mock 行为 + IotSceneRuleDO existingSceneRule = randomPojo(IotSceneRuleDO.class, o -> o.setId(id)); + when(sceneRuleMapper.selectById(id)).thenReturn(existingSceneRule); + when(sceneRuleMapper.deleteById(id)).thenReturn(1); + + // 调用 + assertDoesNotThrow(() -> sceneRuleService.deleteSceneRule(id)); + + // 验证 + verify(sceneRuleMapper, times(1)).selectById(id); + verify(sceneRuleMapper, times(1)).deleteById(id); + } + + @Test + public void testGetSceneRule() { + // 准备参数 + Long id = randomLongId(); + IotSceneRuleDO expectedSceneRule = randomPojo(IotSceneRuleDO.class, o -> o.setId(id)); + + // Mock 行为 + when(sceneRuleMapper.selectById(id)).thenReturn(expectedSceneRule); + + // 调用 + IotSceneRuleDO result = sceneRuleService.getSceneRule(id); + + // 断言 + assertEquals(expectedSceneRule, result); + verify(sceneRuleMapper, times(1)).selectById(id); + } + + @Test + public void testUpdateSceneRuleStatus_success() { + // 准备参数 + Long id = randomLongId(); + Integer status = CommonStatusEnum.DISABLE.getStatus(); + + // Mock 行为 + IotSceneRuleDO existingSceneRule = randomPojo(IotSceneRuleDO.class, o -> { + o.setId(id); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + }); + when(sceneRuleMapper.selectById(id)).thenReturn(existingSceneRule); + when(sceneRuleMapper.updateById(any(IotSceneRuleDO.class))).thenReturn(1); + + // 调用 + assertDoesNotThrow(() -> sceneRuleService.updateSceneRuleStatus(id, status)); + + // 验证 + verify(sceneRuleMapper, times(1)).selectById(id); + verify(sceneRuleMapper, times(1)).updateById(any(IotSceneRuleDO.class)); + } + + @Test + public void testExecuteSceneRuleByTimer_success() { + // 准备参数 + Long id = randomLongId(); + + // Mock 行为 + IotSceneRuleDO sceneRule = randomPojo(IotSceneRuleDO.class, o -> { + o.setId(id); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + }); + when(sceneRuleMapper.selectById(id)).thenReturn(sceneRule); + + // 调用 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(id)); + + // 验证 + verify(sceneRuleMapper, times(1)).selectById(id); + } + + @Test + public void testExecuteSceneRuleByTimer_notExists() { + // 准备参数 + Long id = randomLongId(); + + // Mock 行为 + when(sceneRuleMapper.selectById(id)).thenReturn(null); + + // 调用 - 不存在的场景规则应该不会抛异常,只是记录日志 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(id)); + + // 验证 + verify(sceneRuleMapper, times(1)).selectById(id); + } + + @Test + public void testExecuteSceneRuleByTimer_disabled() { + // 准备参数 + Long id = randomLongId(); + + // Mock 行为 + IotSceneRuleDO sceneRule = randomPojo(IotSceneRuleDO.class, o -> { + o.setId(id); + o.setStatus(CommonStatusEnum.DISABLE.getStatus()); + }); + when(sceneRuleMapper.selectById(id)).thenReturn(sceneRule); + + // 调用 - 禁用的场景规则应该不会执行,只是记录日志 + assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(id)); + + // 验证 + verify(sceneRuleMapper, times(1)).selectById(id); + } +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/CurrentTimeConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/CurrentTimeConditionMatcherTest.java new file mode 100644 index 0000000000..4b4bdfd029 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/CurrentTimeConditionMatcherTest.java @@ -0,0 +1,338 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link CurrentTimeConditionMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class CurrentTimeConditionMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private CurrentTimeConditionMatcher matcher; + + @Test + public void testGetSupportedConditionType() { + // 调用 + IotSceneRuleConditionTypeEnum result = matcher.getSupportedConditionType(); + + // 断言 + assertEquals(IotSceneRuleConditionTypeEnum.CURRENT_TIME, result); + } + + @Test + public void testGetPriority() { + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(40, result); + } + + @Test + public void testIsEnabled() { + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + // ========== 时间戳条件测试 ========== + + @Test + public void testMatches_DateTimeGreaterThan_success() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + long pastTimestamp = LocalDateTime.now().minusHours(1).toEpochSecond(ZoneOffset.of("+8")); + IotSceneRuleDO.TriggerCondition condition = createDateTimeCondition( + IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN.getOperator(), + String.valueOf(pastTimestamp) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_DateTimeGreaterThan_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + long futureTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); + IotSceneRuleDO.TriggerCondition condition = createDateTimeCondition( + IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN.getOperator(), + String.valueOf(futureTimestamp) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_DateTimeLessThan_success() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + long futureTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); + IotSceneRuleDO.TriggerCondition condition = createDateTimeCondition( + IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN.getOperator(), + String.valueOf(futureTimestamp) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_DateTimeBetween_success() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + long startTimestamp = LocalDateTime.now().minusHours(1).toEpochSecond(ZoneOffset.of("+8")); + long endTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); + IotSceneRuleDO.TriggerCondition condition = createDateTimeCondition( + IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN.getOperator(), + startTimestamp + "," + endTimestamp + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_DateTimeBetween_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + long startTimestamp = LocalDateTime.now().plusHours(1).toEpochSecond(ZoneOffset.of("+8")); + long endTimestamp = LocalDateTime.now().plusHours(2).toEpochSecond(ZoneOffset.of("+8")); + IotSceneRuleDO.TriggerCondition condition = createDateTimeCondition( + IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN.getOperator(), + startTimestamp + "," + endTimestamp + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + // ========== 当日时间条件测试 ========== + + @Test + public void testMatches_TimeGreaterThan_earlyMorning() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createTimeCondition( + IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN.getOperator(), + "06:00:00" // 早上6点 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + // 结果取决于当前时间,如果当前时间大于6点则为true + assertNotNull(result); + } + + @Test + public void testMatches_TimeLessThan_lateNight() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createTimeCondition( + IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN.getOperator(), + "23:59:59" // 晚上11点59分59秒 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + // 大部分情况下应该为true,除非在午夜前1秒运行测试 + assertNotNull(result); + } + + @Test + public void testMatches_TimeBetween_allDay() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createTimeCondition( + IotSceneRuleConditionOperatorEnum.TIME_BETWEEN.getOperator(), + "00:00:00,23:59:59" // 全天 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); // 全天范围应该总是匹配 + } + + @Test + public void testMatches_TimeBetween_workingHours() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createTimeCondition( + IotSceneRuleConditionOperatorEnum.TIME_BETWEEN.getOperator(), + "09:00:00,17:00:00" // 工作时间 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + // 结果取决于当前时间是否在工作时间内 + assertNotNull(result); + } + + // ========== 异常情况测试 ========== + + @Test + public void testMatches_nullCondition() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullConditionType() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(null); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidOperator() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.CURRENT_TIME.getType()); + condition.setOperator(randomString()); // 随机无效操作符 + condition.setParam("12:00:00"); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidTimeFormat() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createTimeCondition( + IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN.getOperator(), + randomString() // 随机无效时间格式 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidTimestampFormat() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createDateTimeCondition( + IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN.getOperator(), + randomString() // 随机无效时间戳格式 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidBetweenFormat() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.TriggerCondition condition = createTimeCondition( + IotSceneRuleConditionOperatorEnum.TIME_BETWEEN.getOperator(), + "09:00:00" // 缺少结束时间 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建设备消息 + */ + private IotDeviceMessage createDeviceMessage() { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + return message; + } + + /** + * 创建日期时间条件 + */ + private IotSceneRuleDO.TriggerCondition createDateTimeCondition(String operator, String param) { + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.CURRENT_TIME.getType()); + condition.setOperator(operator); + condition.setParam(param); + return condition; + } + + /** + * 创建当日时间条件 + */ + private IotSceneRuleDO.TriggerCondition createTimeCondition(String operator, String param) { + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.CURRENT_TIME.getType()); + condition.setOperator(operator); + condition.setParam(param); + return condition; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DevicePropertyConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DevicePropertyConditionMatcherTest.java new file mode 100644 index 0000000000..c4edf34361 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DevicePropertyConditionMatcherTest.java @@ -0,0 +1,424 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DevicePropertyConditionMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class DevicePropertyConditionMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private DevicePropertyConditionMatcher matcher; + + @Test + public void testGetSupportedConditionType() { + // 调用 + IotSceneRuleConditionTypeEnum result = matcher.getSupportedConditionType(); + + // 断言 + assertEquals(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY, result); + } + + @Test + public void testGetPriority() { + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(20, result); + } + + @Test + public void testIsEnabled() { + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_temperatureEquals_success() { + // 准备参数 + String propertyName = "temperature"; + Double propertyValue = 25.5; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + String.valueOf(propertyValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_humidityGreaterThan_success() { + // 准备参数 + String propertyName = "humidity"; + Integer propertyValue = 75; + Integer compareValue = 70; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_pressureLessThan_success() { + // 准备参数 + String propertyName = "pressure"; + Double propertyValue = 1010.5; + Integer compareValue = 1020; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_statusNotEquals_success() { + // 准备参数 + String propertyName = "status"; + String propertyValue = "active"; + String compareValue = "inactive"; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.NOT_EQUALS.getOperator(), + compareValue + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_propertyMismatch_fail() { + // 准备参数 + String propertyName = "temperature"; + Double propertyValue = 15.0; + Integer compareValue = 20; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_propertyNotFound_fail() { + // 准备参数 + Map properties = MapUtil.of("temperature", 25.5); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + randomString(), // 随机不存在的属性名 + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + "50" + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullCondition_fail() { + // 准备参数 + Map properties = MapUtil.of("temperature", 25.5); + IotDeviceMessage message = createDeviceMessage(properties); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullConditionType_fail() { + // 准备参数 + Map properties = MapUtil.of("temperature", 25.5); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(null); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingIdentifier_fail() { + // 准备参数 + Map properties = MapUtil.of("temperature", 25.5); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType()); + condition.setIdentifier(null); // 缺少标识符 + condition.setOperator(IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator()); + condition.setParam("20"); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingOperator_fail() { + // 准备参数 + Map properties = MapUtil.of("temperature", 25.5); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType()); + condition.setIdentifier("temperature"); + condition.setOperator(null); // 缺少操作符 + condition.setParam("20"); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingParam_fail() { + // 准备参数 + Map properties = MapUtil.of("temperature", 25.5); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType()); + condition.setIdentifier("temperature"); + condition.setOperator(IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator()); + condition.setParam(null); // 缺少参数 + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullMessage_fail() { + // 准备参数 + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + "temperature", + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + "20" + ); + + // 调用 + boolean result = matcher.matches(null, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullDeviceProperties_fail() { + // 准备参数 + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(null); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + "temperature", + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + "20" + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_voltageGreaterThanOrEquals_success() { + // 准备参数 + String propertyName = "voltage"; + Double propertyValue = 12.0; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN_OR_EQUALS.getOperator(), + String.valueOf(propertyValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_currentLessThanOrEquals_success() { + // 准备参数 + String propertyName = "current"; + Double propertyValue = 2.5; + Double compareValue = 3.0; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.LESS_THAN_OR_EQUALS.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_stringProperty_success() { + // 准备参数 + String propertyName = "mode"; + String propertyValue = "auto"; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + propertyValue + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_booleanProperty_success() { + // 准备参数 + String propertyName = "enabled"; + Boolean propertyValue = true; + Map properties = MapUtil.of(propertyName, propertyValue); + IotDeviceMessage message = createDeviceMessage(properties); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + propertyName, + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + String.valueOf(propertyValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_multipleProperties_success() { + // 准备参数 + Map properties = MapUtil.builder(new HashMap()) + .put("temperature", 25.5) + .put("humidity", 60) + .put("status", "active") + .put("enabled", true) + .build(); + IotDeviceMessage message = createDeviceMessage(properties); + String targetProperty = "humidity"; + Integer targetValue = 60; + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + targetProperty, + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + String.valueOf(targetValue) + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建设备消息 + */ + private IotDeviceMessage createDeviceMessage(Map properties) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setParams(properties); + return message; + } + + /** + * 创建有效的条件 + */ + private IotSceneRuleDO.TriggerCondition createValidCondition(String identifier, String operator, String param) { + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType()); + condition.setIdentifier(identifier); + condition.setOperator(operator); + condition.setParam(param); + return condition; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DeviceStateConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DeviceStateConditionMatcherTest.java new file mode 100644 index 0000000000..25ea571528 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/DeviceStateConditionMatcherTest.java @@ -0,0 +1,356 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DeviceStateConditionMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class DeviceStateConditionMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private DeviceStateConditionMatcher matcher; + + @Test + public void testGetSupportedConditionType() { + // 调用 + IotSceneRuleConditionTypeEnum result = matcher.getSupportedConditionType(); + + // 断言 + assertEquals(IotSceneRuleConditionTypeEnum.DEVICE_STATE, result); + } + + @Test + public void testGetPriority() { + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(30, result); + } + + @Test + public void testIsEnabled() { + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_onlineState_success() { + // 准备参数 + IotDeviceStateEnum deviceState = IotDeviceStateEnum.ONLINE; + IotDeviceMessage message = createDeviceMessage(deviceState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + deviceState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_offlineState_success() { + // 准备参数 + IotDeviceStateEnum deviceState = IotDeviceStateEnum.OFFLINE; + IotDeviceMessage message = createDeviceMessage(deviceState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + deviceState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_inactiveState_success() { + // 准备参数 + IotDeviceStateEnum deviceState = IotDeviceStateEnum.INACTIVE; + IotDeviceMessage message = createDeviceMessage(deviceState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + deviceState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_stateMismatch_fail() { + // 准备参数 + IotDeviceStateEnum actualState = IotDeviceStateEnum.ONLINE; + IotDeviceStateEnum expectedState = IotDeviceStateEnum.OFFLINE; + IotDeviceMessage message = createDeviceMessage(actualState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + expectedState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_notEqualsOperator_success() { + // 准备参数 + IotDeviceStateEnum actualState = IotDeviceStateEnum.ONLINE; + IotDeviceStateEnum compareState = IotDeviceStateEnum.OFFLINE; + IotDeviceMessage message = createDeviceMessage(actualState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.NOT_EQUALS.getOperator(), + compareState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_greaterThanOperator_success() { + // 准备参数 + IotDeviceStateEnum actualState = IotDeviceStateEnum.OFFLINE; // 状态值为 2 + IotDeviceStateEnum compareState = IotDeviceStateEnum.ONLINE; // 状态值为 1 + IotDeviceMessage message = createDeviceMessage(actualState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + compareState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_lessThanOperator_success() { + // 准备参数 + IotDeviceStateEnum actualState = IotDeviceStateEnum.INACTIVE; // 状态值为 0 + IotDeviceStateEnum compareState = IotDeviceStateEnum.ONLINE; // 状态值为 1 + IotDeviceMessage message = createDeviceMessage(actualState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), + compareState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_nullCondition_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(IotDeviceStateEnum.ONLINE.getState()); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullConditionType_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(null); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingOperator_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType()); + condition.setOperator(null); + condition.setParam(IotDeviceStateEnum.ONLINE.getState().toString()); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingParam_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType()); + condition.setOperator(IotSceneRuleConditionOperatorEnum.EQUALS.getOperator()); + condition.setParam(null); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullMessage_fail() { + // 准备参数 + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.ONLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(null, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullDeviceState_fail() { + // 准备参数 + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(null); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.ONLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_greaterThanOrEqualsOperator_success() { + // 准备参数 + IotDeviceStateEnum deviceState = IotDeviceStateEnum.ONLINE; // 状态值为 1 + IotDeviceMessage message = createDeviceMessage(deviceState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.GREATER_THAN_OR_EQUALS.getOperator(), + deviceState.getState().toString() // 比较值也为 1 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_lessThanOrEqualsOperator_success() { + // 准备参数 + IotDeviceStateEnum actualState = IotDeviceStateEnum.ONLINE; // 状态值为 1 + IotDeviceStateEnum compareState = IotDeviceStateEnum.OFFLINE; // 状态值为 2 + IotDeviceMessage message = createDeviceMessage(actualState.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.LESS_THAN_OR_EQUALS.getOperator(), + compareState.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_invalidOperator_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType()); + condition.setOperator(randomString()); // 随机无效操作符 + condition.setParam(IotDeviceStateEnum.ONLINE.getState().toString()); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidParamFormat_fail() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.TriggerCondition condition = createValidCondition( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + randomString() // 随机无效状态值 + ); + + // 调用 + boolean result = matcher.matches(message, condition); + + // 断言 + assertFalse(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建设备消息 + */ + private IotDeviceMessage createDeviceMessage(Integer deviceState) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setParams(deviceState); + return message; + } + + /** + * 创建有效的条件 + */ + private IotSceneRuleDO.TriggerCondition createValidCondition(String operator, String param) { + IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition(); + condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType()); + condition.setOperator(operator); + condition.setParam(param); + return condition; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceEventPostTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceEventPostTriggerMatcherTest.java new file mode 100644 index 0000000000..1ed8f1c48f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceEventPostTriggerMatcherTest.java @@ -0,0 +1,376 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.util.HashMap; +import java.util.Map; + +import static cn.hutool.core.util.RandomUtil.randomInt; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DeviceEventPostTriggerMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class DeviceEventPostTriggerMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private DeviceEventPostTriggerMatcher matcher; + + @Test + public void testGetSupportedTriggerType_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + IotSceneRuleTriggerTypeEnum result = matcher.getSupportedTriggerType(); + + // 断言 + assertEquals(IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST, result); + } + + @Test + public void testGetPriority_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(30, result); + } + + @Test + public void testIsEnabled_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_alarmEventSuccess() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .put("message", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_errorEventSuccess() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("code", randomInt()) + .put("description", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_infoEventSuccess() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("status", randomString()) + .put("timestamp", System.currentTimeMillis()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_eventIdentifierMismatch() { + // 准备参数 + String messageIdentifier = randomString(); + String triggerIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", messageIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(triggerIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_wrongMessageMethod() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) + .build(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); // 错误的方法 + message.setParams(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerIdentifier() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType()); + trigger.setIdentifier(null); // 缺少标识符 + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullMessageParams() { + // 准备参数 + String eventIdentifier = randomString(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()); + message.setParams(null); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidMessageParams() { + // 准备参数 + String eventIdentifier = randomString(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()); + message.setParams(randomString()); // 不是 Map 类型 + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingEventIdentifierInParams() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) // 缺少 identifier 字段 + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTrigger() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerType() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(null); + trigger.setIdentifier(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_complexEventValueSuccess() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("type", randomString()) + .put("duration", randomInt()) + .put("components", new String[]{randomString(), randomString()}) + .put("priority", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_emptyEventValueSuccess() { + // 准备参数 + String eventIdentifier = randomString(); + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.ofEntries()) // 空的事件值 + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(eventIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_caseSensitiveIdentifierMismatch() { + // 准备参数 + String eventIdentifier = randomString().toUpperCase(); // 大写 + String triggerIdentifier = eventIdentifier.toLowerCase(); // 小写 + Map eventParams = MapUtil.builder(new HashMap()) + .put("identifier", eventIdentifier) + .put("value", MapUtil.builder(new HashMap()) + .put("level", randomString()) + .build()) + .build(); + IotDeviceMessage message = createEventPostMessage(eventParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(triggerIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + // 根据实际实现,这里可能需要调整期望结果 + // 如果实现是大小写敏感的,则应该为 false + assertFalse(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建事件上报消息 + */ + private IotDeviceMessage createEventPostMessage(Map eventParams) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.EVENT_POST.getMethod()); + message.setParams(eventParams); + return message; + } + + /** + * 创建有效的触发器 + */ + private IotSceneRuleDO.Trigger createValidTrigger(String identifier) { + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_EVENT_POST.getType()); + trigger.setIdentifier(identifier); + return trigger; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DevicePropertyPostTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DevicePropertyPostTriggerMatcherTest.java new file mode 100644 index 0000000000..2bed7fa631 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DevicePropertyPostTriggerMatcherTest.java @@ -0,0 +1,340 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.util.HashMap; +import java.util.Map; + +import static cn.hutool.core.util.RandomUtil.randomDouble; +import static cn.hutool.core.util.RandomUtil.randomInt; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DevicePropertyPostTriggerMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class DevicePropertyPostTriggerMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private DevicePropertyPostTriggerMatcher matcher; + + @Test + public void testGetSupportedTriggerType_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + IotSceneRuleTriggerTypeEnum result = matcher.getSupportedTriggerType(); + + // 断言 + assertEquals(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST, result); + } + + @Test + public void testGetPriority_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(20, result); + } + + @Test + public void testIsEnabled_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_numericPropertyGreaterThanSuccess() { + // 准备参数 + String propertyName = randomString(); + Double propertyValue = 25.5; + Integer compareValue = 20; + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, propertyValue) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_integerPropertyEqualsSuccess() { + // 准备参数 + String propertyName = randomString(); + Integer propertyValue = randomInt(); + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, propertyValue) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + String.valueOf(propertyValue) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_propertyValueNotMeetCondition() { + // 准备参数 + String propertyName = randomString(); + Double propertyValue = 15.0; + Integer compareValue = 20; + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, propertyValue) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_propertyNotFound() { + // 准备参数 + String existingProperty = randomString(); + String missingProperty = randomString(); + Map properties = MapUtil.builder(new HashMap()) + .put(existingProperty, randomDouble()) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + missingProperty, // 不存在的属性 + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(randomInt()) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_wrongMessageMethod() { + // 准备参数 + String propertyName = randomString(); + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, randomDouble()) + .build(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()); + message.setParams(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(randomInt()) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerIdentifier() { + // 准备参数 + String propertyName = randomString(); + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, randomDouble()) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType()); + trigger.setIdentifier(null); // 缺少标识符 + trigger.setOperator(IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator()); + trigger.setValue(String.valueOf(randomInt())); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullMessageParams() { + // 准备参数 + String propertyName = randomString(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + message.setParams(null); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(randomInt()) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidMessageParams() { + // 准备参数 + String propertyName = randomString(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + message.setParams(randomString()); // 不是 Map 类型 + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + String.valueOf(randomInt()) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_lessThanOperatorSuccess() { + // 准备参数 + String propertyName = randomString(); + Double propertyValue = 15.0; + Integer compareValue = 20; + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, propertyValue) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), + String.valueOf(compareValue) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_notEqualsOperatorSuccess() { + // 准备参数 + String propertyName = randomString(); + String propertyValue = randomString(); + String compareValue = randomString(); + Map properties = MapUtil.builder(new HashMap()) + .put(propertyName, propertyValue) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + propertyName, + IotSceneRuleConditionOperatorEnum.NOT_EQUALS.getOperator(), + compareValue + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_multiplePropertiesTargetPropertySuccess() { + // 准备参数 + String targetProperty = randomString(); + Integer targetValue = randomInt(); + Map properties = MapUtil.builder(new HashMap()) + .put(randomString(), randomDouble()) + .put(targetProperty, targetValue) + .put(randomString(), randomString()) + .build(); + IotDeviceMessage message = createPropertyPostMessage(properties); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + targetProperty, + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + String.valueOf(targetValue) + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建属性上报消息 + */ + private IotDeviceMessage createPropertyPostMessage(Map properties) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + message.setParams(properties); + return message; + } + + /** + * 创建有效的触发器 + */ + private IotSceneRuleDO.Trigger createValidTrigger(String identifier, String operator, String value) { + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType()); + trigger.setIdentifier(identifier); + trigger.setOperator(operator); + trigger.setValue(value); + return trigger; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceServiceInvokeTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceServiceInvokeTriggerMatcherTest.java new file mode 100644 index 0000000000..a9348456f4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceServiceInvokeTriggerMatcherTest.java @@ -0,0 +1,398 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.util.HashMap; +import java.util.Map; + +import static cn.hutool.core.util.RandomUtil.*; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DeviceServiceInvokeTriggerMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class DeviceServiceInvokeTriggerMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private DeviceServiceInvokeTriggerMatcher matcher; + + @Test + public void testGetSupportedTriggerType_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + IotSceneRuleTriggerTypeEnum result = matcher.getSupportedTriggerType(); + + // 断言 + assertEquals(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE, result); + } + + @Test + public void testGetPriority_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(40, result); + } + + @Test + public void testIsEnabled_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_serviceInvokeSuccess() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_configServiceSuccess() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("interval", randomInt()) + .put("enabled", randomBoolean()) + .put("threshold", randomDouble()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_updateServiceSuccess() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("version", randomString()) + .put("url", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_serviceIdentifierMismatch() { + // 准备参数 + String messageIdentifier = randomString(); + String triggerIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", messageIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(triggerIdentifier); // 不匹配的服务标识符 + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_wrongMessageMethod() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); // 错误的方法 + message.setParams(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerIdentifier() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(null); // 缺少标识符 + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullMessageParams() { + // 准备参数 + String serviceIdentifier = randomString(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); + message.setParams(null); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_invalidMessageParams() { + // 准备参数 + String serviceIdentifier = randomString(); + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); + message.setParams(randomString()); // 不是 Map 类型 + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_missingServiceIdentifierInParams() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) // 缺少 identifier 字段 + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTrigger() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerType() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(null); + trigger.setIdentifier(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_emptyInputDataSuccess() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.ofEntries()) // 空的输入数据 + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_noInputDataSuccess() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + // 没有 inputData 字段 + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_complexInputDataSuccess() { + // 准备参数 + String serviceIdentifier = randomString(); + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("sensors", new String[]{randomString(), randomString(), randomString()}) + .put("precision", randomDouble()) + .put("duration", randomInt()) + .put("autoSave", randomBoolean()) + .put("config", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .put("level", randomString()) + .build()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(serviceIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_caseSensitiveIdentifierMismatch() { + // 准备参数 + String serviceIdentifier = randomString().toUpperCase(); // 大写 + String triggerIdentifier = serviceIdentifier.toLowerCase(); // 小写 + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", randomString()) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = createValidTrigger(triggerIdentifier); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + // 根据实际实现,这里可能需要调整期望结果 + // 如果实现是大小写敏感的,则应该为 false + assertFalse(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建服务调用消息 + */ + private IotDeviceMessage createServiceInvokeMessage(Map serviceParams) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); + message.setParams(serviceParams); + return message; + } + + /** + * 创建有效的触发器 + */ + private IotSceneRuleDO.Trigger createValidTrigger(String identifier) { + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(identifier); + return trigger; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceStateUpdateTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceStateUpdateTriggerMatcherTest.java new file mode 100644 index 0000000000..b1e095ea3b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/DeviceStateUpdateTriggerMatcherTest.java @@ -0,0 +1,262 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DeviceStateUpdateTriggerMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class DeviceStateUpdateTriggerMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private DeviceStateUpdateTriggerMatcher matcher; + + @Test + public void testGetSupportedTriggerType_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + IotSceneRuleTriggerTypeEnum result = matcher.getSupportedTriggerType(); + + // 断言 + assertEquals(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE, result); + } + + @Test + public void testGetPriority_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(10, result); + } + + @Test + public void testIsEnabled_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_onlineStateSuccess() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.ONLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_offlineStateSuccess() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.OFFLINE.getState()); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.OFFLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_stateMismatch() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.OFFLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTrigger() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerType() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(null); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_wrongMessageMethod() { + // 准备参数 + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + message.setParams(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.ONLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerOperator() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType()); + trigger.setOperator(null); + trigger.setValue(IotDeviceStateEnum.ONLINE.getState().toString()); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerValue() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType()); + trigger.setOperator(IotSceneRuleConditionOperatorEnum.EQUALS.getOperator()); + trigger.setValue(null); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullMessageParams() { + // 准备参数 + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()); + message.setParams(null); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), + IotDeviceStateEnum.ONLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_greaterThanOperatorSuccess() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), + IotDeviceStateEnum.INACTIVE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_notEqualsOperatorSuccess() { + // 准备参数 + IotDeviceMessage message = createStateUpdateMessage(IotDeviceStateEnum.ONLINE.getState()); + IotSceneRuleDO.Trigger trigger = createValidTrigger( + IotSceneRuleConditionOperatorEnum.NOT_EQUALS.getOperator(), + IotDeviceStateEnum.OFFLINE.getState().toString() + ); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建设备状态更新消息 + */ + private IotDeviceMessage createStateUpdateMessage(Integer state) { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + message.setMethod(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod()); + message.setParams(state); + return message; + } + + /** + * 创建有效的触发器 + */ + private IotSceneRuleDO.Trigger createValidTrigger(String operator, String value) { + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE.getType()); + trigger.setOperator(operator); + trigger.setValue(value); + return trigger; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/TimerTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/TimerTriggerMatcherTest.java new file mode 100644 index 0000000000..52ed5ec3de --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/TimerTriggerMatcherTest.java @@ -0,0 +1,276 @@ +package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; +import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link TimerTriggerMatcher} 的单元测试 + * + * @author HUIHUI + */ +public class TimerTriggerMatcherTest extends BaseMockitoUnitTest { + + @InjectMocks + private TimerTriggerMatcher matcher; + + @Test + public void testGetSupportedTriggerType_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + IotSceneRuleTriggerTypeEnum result = matcher.getSupportedTriggerType(); + + // 断言 + assertEquals(IotSceneRuleTriggerTypeEnum.TIMER, result); + } + + @Test + public void testGetPriority_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + int result = matcher.getPriority(); + + // 断言 + assertEquals(50, result); + } + + @Test + public void testIsEnabled_success() { + // 准备参数 + // 无需准备参数 + + // 调用 + boolean result = matcher.isEnabled(); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_validCronExpressionSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 0 12 * * ?"; // 每天中午12点 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_everyMinuteCronSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 * * * * ?"; // 每分钟 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_weekdaysCronSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 0 9 ? * MON-FRI"; // 工作日上午9点 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_invalidCronExpression() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = randomString(); // 随机无效的 cron 表达式 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_emptyCronExpression() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = ""; // 空的 cron 表达式 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullCronExpression() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.TIMER.getType()); + trigger.setCronExpression(null); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTrigger() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + + // 调用 + boolean result = matcher.matches(message, null); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_nullTriggerType() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(null); + trigger.setCronExpression("0 0 12 * * ?"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_complexCronExpressionSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 15 10 ? * 6#3"; // 每月第三个星期五上午10:15 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_incorrectCronFormat() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 0 12 * *"; // 缺少字段的 cron 表达式 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_specificDateCronSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 0 0 1 1 ? 2025"; // 2025年1月1日午夜 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_everySecondCronSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "* * * * * ?"; // 每秒执行 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + @Test + public void testMatches_invalidCharactersCron() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + String cronExpression = "0 0 12 * * @ #"; // 包含无效字符的 cron 表达式 + IotSceneRuleDO.Trigger trigger = createValidTrigger(cronExpression); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + @Test + public void testMatches_rangeCronSuccess() { + // 准备参数 + IotDeviceMessage message = createDeviceMessage(); + IotSceneRuleDO.Trigger trigger = createValidTrigger("0 0 9-17 * * MON-FRI"); // 工作日9-17点 + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + // ========== 辅助方法 ========== + + /** + * 创建设备消息 + */ + private IotDeviceMessage createDeviceMessage() { + IotDeviceMessage message = new IotDeviceMessage(); + message.setDeviceId(randomLongId()); + return message; + } + + /** + * 创建有效的定时触发器 + */ + private IotSceneRuleDO.Trigger createValidTrigger(String cronExpression) { + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.TIMER.getType()); + trigger.setCronExpression(cronExpression); + return trigger; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/application-unit-test.yaml b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/application-unit-test.yaml new file mode 100644 index 0000000000..3966a274d4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/application-unit-test.yaml @@ -0,0 +1,52 @@ +spring: + main: + lazy-initialization: true # 开启懒加载,加快速度 + banner-mode: off # 单元测试,禁用 Banner + # 数据源配置项 + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 + initial-size: 1 # 单元测试,配置为 1,提升启动速度 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + +mybatis-plus: + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 + type-aliases-package: ${yudao.info.base-package}.module.*.dal.dataobject + +# 日志配置 +logging: + level: + cn.iocoder.yudao.module.iot.service.rule.scene.matcher: DEBUG + cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager: INFO + cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition: DEBUG + cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger: DEBUG + root: WARN + +--- #################### 定时任务相关配置 #################### + +--- #################### 配置中心相关配置 #################### + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项(单元测试,禁用 Lock4j) + +--- #################### 监控相关配置 #################### + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + info: + base-package: cn.iocoder.yudao + tenant: # 多租户相关配置项 + enable: true + xss: + enable: false + demo: false # 关闭演示模式 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/logback.xml b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/logback.xml new file mode 100644 index 0000000000..b68931dc1c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/logback.xml @@ -0,0 +1,37 @@ + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + UTF-8 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/clean.sql b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/clean.sql new file mode 100644 index 0000000000..ae1c5e5156 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/clean.sql @@ -0,0 +1,10 @@ +DELETE FROM "iot_scene_rule"; +DELETE FROM "iot_product"; +DELETE FROM "iot_device"; +DELETE FROM "iot_thing_model"; +DELETE FROM "iot_device_data"; +DELETE FROM "iot_alert_config"; +DELETE FROM "iot_alert_record"; +DELETE FROM "iot_ota_firmware"; +DELETE FROM "iot_ota_task"; +DELETE FROM "iot_ota_record"; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql new file mode 100644 index 0000000000..306c66b5e5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql @@ -0,0 +1,182 @@ +CREATE TABLE IF NOT EXISTS "iot_scene_rule" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL DEFAULT '', + "description" varchar(500) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT '0', + "triggers" text, + "actions" text, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 场景联动规则表'; + +CREATE TABLE IF NOT EXISTS "iot_product" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL DEFAULT '', + "product_key" varchar(100) NOT NULL DEFAULT '', + "protocol_type" tinyint NOT NULL DEFAULT '0', + "category_id" bigint DEFAULT NULL, + "description" varchar(500) DEFAULT NULL, + "data_format" tinyint NOT NULL DEFAULT '0', + "device_type" tinyint NOT NULL DEFAULT '0', + "net_type" tinyint NOT NULL DEFAULT '0', + "validate_type" tinyint NOT NULL DEFAULT '0', + "status" tinyint NOT NULL DEFAULT '0', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 产品表'; + +CREATE TABLE IF NOT EXISTS "iot_device" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "device_name" varchar(255) NOT NULL DEFAULT '', + "product_id" bigint NOT NULL, + "device_key" varchar(100) NOT NULL DEFAULT '', + "device_secret" varchar(100) NOT NULL DEFAULT '', + "nickname" varchar(255) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT '0', + "status_last_update_time" timestamp DEFAULT NULL, + "last_online_time" timestamp DEFAULT NULL, + "last_offline_time" timestamp DEFAULT NULL, + "active_time" timestamp DEFAULT NULL, + "ip" varchar(50) DEFAULT NULL, + "firmware_version" varchar(50) DEFAULT NULL, + "device_type" tinyint NOT NULL DEFAULT '0', + "gateway_id" bigint DEFAULT NULL, + "sub_device_count" int NOT NULL DEFAULT '0', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 设备表'; + +CREATE TABLE IF NOT EXISTS "iot_thing_model" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "product_id" bigint NOT NULL, + "identifier" varchar(100) NOT NULL DEFAULT '', + "name" varchar(255) NOT NULL DEFAULT '', + "description" varchar(500) DEFAULT NULL, + "type" tinyint NOT NULL DEFAULT '1', + "property" text, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 物模型表'; + +CREATE TABLE IF NOT EXISTS "iot_device_data" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "device_id" bigint NOT NULL, + "product_id" bigint NOT NULL, + "identifier" varchar(100) NOT NULL DEFAULT '', + "type" tinyint NOT NULL DEFAULT '1', + "data" text, + "ts" bigint NOT NULL DEFAULT '0', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("id") +) COMMENT 'IoT 设备数据表'; + +CREATE TABLE IF NOT EXISTS "iot_alert_config" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL DEFAULT '', + "product_id" bigint NOT NULL, + "device_id" bigint DEFAULT NULL, + "rule_id" bigint DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT '0', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 告警配置表'; + +CREATE TABLE IF NOT EXISTS "iot_alert_record" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "alert_config_id" bigint NOT NULL, + "alert_name" varchar(255) NOT NULL DEFAULT '', + "product_id" bigint NOT NULL, + "device_id" bigint DEFAULT NULL, + "rule_id" bigint DEFAULT NULL, + "alert_data" text, + "alert_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deal_status" tinyint NOT NULL DEFAULT '0', + "deal_time" timestamp DEFAULT NULL, + "deal_user_id" bigint DEFAULT NULL, + "deal_remark" varchar(500) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT 告警记录表'; + +CREATE TABLE IF NOT EXISTS "iot_ota_firmware" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL DEFAULT '', + "product_id" bigint NOT NULL, + "version" varchar(50) NOT NULL DEFAULT '', + "description" varchar(500) DEFAULT NULL, + "file_url" varchar(500) DEFAULT NULL, + "file_size" bigint NOT NULL DEFAULT '0', + "status" tinyint NOT NULL DEFAULT '0', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT OTA 固件表'; + +CREATE TABLE IF NOT EXISTS "iot_ota_task" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL DEFAULT '', + "firmware_id" bigint NOT NULL, + "product_id" bigint NOT NULL, + "upgrade_type" tinyint NOT NULL DEFAULT '0', + "status" tinyint NOT NULL DEFAULT '0', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT OTA 升级任务表'; + +CREATE TABLE IF NOT EXISTS "iot_ota_record" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "task_id" bigint NOT NULL, + "firmware_id" bigint NOT NULL, + "device_id" bigint NOT NULL, + "status" tinyint NOT NULL DEFAULT '0', + "progress" int NOT NULL DEFAULT '0', + "error_msg" varchar(500) DEFAULT NULL, + "start_time" timestamp DEFAULT NULL, + "end_time" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT '0', + PRIMARY KEY ("id") +) COMMENT 'IoT OTA 升级记录表'; diff --git a/yudao-module-iot/yudao-module-iot-core/pom.xml b/yudao-module-iot/yudao-module-iot-core/pom.xml new file mode 100644 index 0000000000..30ebc2de0c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/pom.xml @@ -0,0 +1,72 @@ + + + + yudao-module-iot + cn.iocoder.boot + ${revision} + + 4.0.0 + + yudao-module-iot-core + jar + + ${project.artifactId} + + iot 模块下,提供 iot-biz 和 iot-gateway 模块的核心功能。例如说: + 1. 消息总线:跨 iot-biz 和 iot-gateway 的设备消息。可选择使用 spring event、redis stream、rocketmq、kafka、rabbitmq 等。 + 2. 查询设备信息的通用 API + + + + + cn.iocoder.boot + yudao-common + + + + + org.springframework.boot + spring-boot-starter + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mq + + + + org.springframework.data + spring-data-redis + true + + + + org.apache.rocketmq + rocketmq-spring-boot-starter + true + + + + org.springframework.amqp + spring-rabbit + true + + + + org.springframework.kafka + spring-kafka + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java new file mode 100644 index 0000000000..29d540e73e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.core.biz; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; + +/** + * IoT 设备通用 API + * + * @author haohao + */ +public interface IotDeviceCommonApi { + + /** + * 设备认证 + * + * @param authReqDTO 认证请求 + * @return 认证结果 + */ + CommonResult authDevice(IotDeviceAuthReqDTO authReqDTO); + + /** + * 获取设备信息 + * + * @param infoReqDTO 设备信息请求 + * @return 设备信息 + */ + CommonResult getDevice(IotDeviceGetReqDTO infoReqDTO); + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceGetReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceGetReqDTO.java new file mode 100644 index 0000000000..981509dd6a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceGetReqDTO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import lombok.Data; + +/** + * IoT 设备信息查询 Request DTO + * + * @author 芋道源码 + */ +@Data +public class IotDeviceGetReqDTO { + + /** + * 设备编号 + */ + private Long id; + + /** + * 产品标识 + */ + private String productKey; + /** + * 设备名称 + */ + private String deviceName; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java new file mode 100644 index 0000000000..add1167801 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import lombok.Data; + +/** + * IoT 设备信息 Response DTO + * + * @author 芋道源码 + */ +@Data +public class IotDeviceRespDTO { + + /** + * 设备编号 + */ + private Long id; + /** + * 产品标识 + */ + private String productKey; + /** + * 设备名称 + */ + private String deviceName; + /** + * 租户编号 + */ + private Long tenantId; + + // ========== 产品相关字段 ========== + + /** + * 产品编号 + */ + private Long productId; + /** + * 编解码器类型 + */ + private String codecType; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java new file mode 100644 index 0000000000..047fe5ffcd --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.iot.core.enums; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import cn.iocoder.yudao.framework.common.util.collection.SetUtils; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Set; + +/** + * IoT 设备消息的方法枚举 + * + * @author haohao + */ +@Getter +@AllArgsConstructor +public enum IotDeviceMessageMethodEnum implements ArrayValuable { + + // ========== 设备状态 ========== + + STATE_UPDATE("thing.state.update", "设备状态更新", true), + + // TODO 芋艿:要不要加个 ping 消息; + + // ========== 设备属性 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services + + PROPERTY_POST("thing.property.post", "属性上报", true), + PROPERTY_SET("thing.property.set", "属性设置", false), + + // ========== 设备事件 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services + + EVENT_POST("thing.event.post", "事件上报", true), + + // ========== 设备服务调用 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services + + SERVICE_INVOKE("thing.service.invoke", "服务调用", false), + + // ========== 设备配置 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/remote-configuration-1 + + CONFIG_PUSH("thing.config.push", "配置推送", true), + + // ========== OTA 固件 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/perform-ota-updates + + OTA_UPGRADE("thing.ota.upgrade", "OTA 固定信息推送", false), + OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true), + ; + + public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) + .toArray(String[]::new); + + /** + * 不进行 reply 回复的方法集合 + */ + public static final Set REPLY_DISABLED = SetUtils.asSet( + STATE_UPDATE.getMethod(), + OTA_PROGRESS.getMethod() // 参考阿里云,OTA 升级进度上报,不进行回复 + ); + + private final String method; + + private final String name; + + private final Boolean upstream; + + @Override + public String[] array() { + return ARRAYS; + } + + public static IotDeviceMessageMethodEnum of(String method) { + return ArrayUtil.firstMatch(item -> item.getMethod().equals(method), + IotDeviceMessageMethodEnum.values()); + } + + public static boolean isReplyDisabled(String method) { + return REPLY_DISABLED.contains(method); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageTypeEnum.java new file mode 100644 index 0000000000..e2fe8be204 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageTypeEnum.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.iot.core.enums; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 设备消息类型枚举 + */ +@Getter +@RequiredArgsConstructor +public enum IotDeviceMessageTypeEnum implements ArrayValuable { + + STATE("state"), // 设备状态 +// PROPERTY("property"), // 设备属性:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 + EVENT("event"), // 设备事件:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 + SERVICE("service"), // 设备服务:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 + CONFIG("config"), // 设备配置:可参考 https://help.aliyun.com/zh/iot/user-guide/remote-configuration-1 远程配置 + OTA("ota"), // 设备 OTA:可参考 https://help.aliyun.com/zh/iot/user-guide/ota-update OTA 升级 + REGISTER("register"), // 设备注册:可参考 https://help.aliyun.com/zh/iot/user-guide/register-devices 设备身份注册 + TOPOLOGY("topology"),; // 设备拓扑:可参考 https://help.aliyun.com/zh/iot/user-guide/manage-topological-relationships 设备拓扑 + + public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageTypeEnum::getType).toArray(String[]::new); + + /** + * 属性 + */ + private final String type; + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/config/IotMessageBusAutoConfiguration.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/config/IotMessageBusAutoConfiguration.java new file mode 100644 index 0000000000..67ae67399c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/config/IotMessageBusAutoConfiguration.java @@ -0,0 +1,129 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.config; + +import cn.iocoder.yudao.framework.mq.redis.core.RedisMQTemplate; +import cn.iocoder.yudao.framework.mq.redis.core.job.RedisPendingMessageResendJob; +import cn.iocoder.yudao.framework.mq.redis.core.job.RedisStreamMessageCleanupJob; +import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessage; +import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.local.IotLocalMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.redis.IotRedisMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.rocketmq.IotRocketMQMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.autoconfigure.RocketMQProperties; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.redisson.api.RedissonClient; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * IoT 消息总线自动配置 + * + * @author 芋道源码 + */ +@AutoConfiguration +@EnableConfigurationProperties(IotMessageBusProperties.class) +@Slf4j +public class IotMessageBusAutoConfiguration { + + @Bean + public IotDeviceMessageProducer deviceMessageProducer(IotMessageBus messageBus) { + return new IotDeviceMessageProducer(messageBus); + } + + // ==================== Local 实现 ==================== + + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.message-bus", name = "type", havingValue = "local", matchIfMissing = true) + public static class IotLocalMessageBusConfiguration { + + @Bean + public IotLocalMessageBus iotLocalMessageBus(ApplicationContext applicationContext) { + log.info("[iotLocalMessageBus][创建 IoT Local 消息总线]"); + return new IotLocalMessageBus(applicationContext); + } + + } + + // ==================== RocketMQ 实现 ==================== + + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.message-bus", name = "type", havingValue = "rocketmq") + @ConditionalOnClass(RocketMQTemplate.class) + public static class IotRocketMQMessageBusConfiguration { + + @Bean + public IotRocketMQMessageBus iotRocketMQMessageBus(RocketMQProperties rocketMQProperties, + RocketMQTemplate rocketMQTemplate) { + log.info("[iotRocketMQMessageBus][创建 IoT RocketMQ 消息总线]"); + return new IotRocketMQMessageBus(rocketMQProperties, rocketMQTemplate); + } + + } + + // ==================== Redis 实现 ==================== + + /** + * 特殊:由于 YudaoRedisMQConsumerAutoConfiguration 关于 Redis stream 的消费是动态注册,所以这里只能拷贝相关的逻辑!!! + * + * @see cn.iocoder.yudao.framework.mq.redis.config.YudaoRedisMQConsumerAutoConfiguration + */ + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.message-bus", name = "type", havingValue = "redis") + @ConditionalOnClass(RedisTemplate.class) + public static class IotRedisMessageBusConfiguration { + + @Bean + public IotRedisMessageBus iotRedisMessageBus(StringRedisTemplate redisTemplate) { + log.info("[iotRedisMessageBus][创建 IoT Redis 消息总线]"); + return new IotRedisMessageBus(redisTemplate); + } + + /** + * 创建 Redis Stream 重新消费的任务 + */ + @Bean + public RedisPendingMessageResendJob iotRedisPendingMessageResendJob(IotRedisMessageBus messageBus, + RedisMQTemplate redisTemplate, + RedissonClient redissonClient) { + List> listeners = getListeners(messageBus); + return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient); + } + + /** + * 创建 Redis Stream 消息清理任务 + */ + @Bean + public RedisStreamMessageCleanupJob iotRedisStreamMessageCleanupJob(IotRedisMessageBus messageBus, + RedisMQTemplate redisTemplate, + RedissonClient redissonClient) { + List> listeners = getListeners(messageBus); + return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient); + } + + private List> getListeners(IotRedisMessageBus messageBus) { + return convertList(messageBus.getSubscribers(), subscriber -> + new AbstractRedisStreamMessageListener<>(subscriber.getTopic(), subscriber.getGroup()) { + + @Override + public void onMessage(AbstractRedisStreamMessage message) { + throw new UnsupportedOperationException("不应该调用!!!"); + } + }); + } + + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/config/IotMessageBusProperties.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/config/IotMessageBusProperties.java new file mode 100644 index 0000000000..501eb2b0d8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/config/IotMessageBusProperties.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +/** + * IoT 消息总线配置属性 + * + * @author 芋道源码 + */ +@ConfigurationProperties("yudao.iot.message-bus") +@Data +@Validated +public class IotMessageBusProperties { + + /** + * 消息总线类型 + * + * 可选值:local、redis、rocketmq、rabbitmq + */ + @NotNull(message = "IoT 消息总线类型不能为空") + private String type = "local"; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java new file mode 100644 index 0000000000..c621467610 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core; + +/** + * IoT 消息总线接口 + * + * 用于在 IoT 系统中发布和订阅消息,支持多种消息中间件实现 + * + * @author 芋道源码 + */ +public interface IotMessageBus { + + /** + * 发布消息到消息总线 + * + * @param topic 主题 + * @param message 消息内容 + */ + void post(String topic, Object message); + + /** + * 注册消息订阅者 + * + * @param subscriber 订阅者 + */ + void register(IotMessageSubscriber subscriber); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java new file mode 100644 index 0000000000..23a055325c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core; + +/** + * IoT 消息总线订阅者接口 + * + * 用于处理从消息总线接收到的消息 + * + * @author 芋道源码 + */ +public interface IotMessageSubscriber { + + /** + * @return 主题 + */ + String getTopic(); + + /** + * @return 分组 + */ + String getGroup(); + + /** + * 处理接收到的消息 + * + * @param message 消息内容 + */ + void onMessage(T message); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/IotLocalMessage.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/IotLocalMessage.java new file mode 100644 index 0000000000..5a9841a754 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/IotLocalMessage.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core.local; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class IotLocalMessage { + + private String topic; + + private Object message; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/IotLocalMessageBus.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/IotLocalMessageBus.java new file mode 100644 index 0000000000..1fc608bc50 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/IotLocalMessageBus.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core.local; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.EventListener; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 本地的 {@link IotMessageBus} 实现类 + * + * 注意:仅适用于单机场景!!! + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotLocalMessageBus implements IotMessageBus { + + private final ApplicationContext applicationContext; + + /** + * 订阅者映射表 + * Key: topic + */ + private final Map>> subscribers = new HashMap<>(); + + @Override + public void post(String topic, Object message) { + applicationContext.publishEvent(new IotLocalMessage(topic, message)); + } + + @Override + public void register(IotMessageSubscriber subscriber) { + String topic = subscriber.getTopic(); + List> topicSubscribers = subscribers.computeIfAbsent(topic, k -> new ArrayList<>()); + topicSubscribers.add(subscriber); + log.info("[register][topic({}/{}) 注册消费者({})成功]", + topic, subscriber.getGroup(), subscriber.getClass().getName()); + } + + @EventListener + @SuppressWarnings({"unchecked", "rawtypes"}) + public void onMessage(IotLocalMessage message) { + String topic = message.getTopic(); + List> topicSubscribers = subscribers.get(topic); + if (CollUtil.isEmpty(topicSubscribers)) { + return; + } + for (IotMessageSubscriber subscriber : topicSubscribers) { + try { + subscriber.onMessage(message.getMessage()); + } catch (Exception ex) { + log.error("[onMessage][topic({}/{}) message({}) 消费者({}) 处理异常]", + subscriber.getTopic(), subscriber.getGroup(), message.getMessage(), subscriber.getClass().getName(), ex); + } + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/redis/IotRedisMessageBus.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/redis/IotRedisMessageBus.java new file mode 100644 index 0000000000..fcaed5a87b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/redis/IotRedisMessageBus.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core.redis; + +import cn.hutool.core.util.TypeUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import static cn.iocoder.yudao.framework.mq.redis.config.YudaoRedisMQConsumerAutoConfiguration.buildConsumerName; +import static cn.iocoder.yudao.framework.mq.redis.config.YudaoRedisMQConsumerAutoConfiguration.checkRedisVersion; + +/** + * Redis 的 {@link IotMessageBus} 实现类 + * + * @author 芋道源码 + */ +@Slf4j +public class IotRedisMessageBus implements IotMessageBus { + + private final RedisTemplate redisTemplate; + + private final StreamMessageListenerContainer> redisStreamMessageListenerContainer; + + @Getter + private final List> subscribers = new ArrayList<>(); + + public IotRedisMessageBus(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + checkRedisVersion(redisTemplate); + // 创建 options 配置 + StreamMessageListenerContainer.StreamMessageListenerContainerOptions> containerOptions = + StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() + .batchSize(10) // 一次性最多拉取多少条消息 + .targetType(String.class) // 目标类型。统一使用 String,通过自己封装的 AbstractStreamMessageListener 去反序列化 + .build(); + // 创建 container 对象 + this.redisStreamMessageListenerContainer = + StreamMessageListenerContainer.create(redisTemplate.getRequiredConnectionFactory(), containerOptions); + } + + @PostConstruct + public void init() { + this.redisStreamMessageListenerContainer.start(); + } + + @PreDestroy + public void destroy() { + this.redisStreamMessageListenerContainer.stop(); + } + + @Override + public void post(String topic, Object message) { + redisTemplate.opsForStream().add(StreamRecords.newRecord() + .ofObject(JsonUtils.toJsonString(message)) // 设置内容 + .withStreamKey(topic)); // 设置 stream key + } + + @Override + public void register(IotMessageSubscriber subscriber) { + Type type = TypeUtil.getTypeArgument(subscriber.getClass(), 0); + if (type == null) { + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); + } + + // 创建 listener 对应的消费者分组 + try { + redisTemplate.opsForStream().createGroup(subscriber.getTopic(), subscriber.getGroup()); + } catch (Exception ignore) { + } + // 创建 Consumer 对象 + String consumerName = buildConsumerName(); + Consumer consumer = Consumer.from(subscriber.getGroup(), consumerName); + // 设置 Consumer 消费进度,以最小消费进度为准 + StreamOffset streamOffset = StreamOffset.create(subscriber.getTopic(), ReadOffset.lastConsumed()); + // 设置 Consumer 监听 + StreamMessageListenerContainer.StreamReadRequestBuilder builder = StreamMessageListenerContainer.StreamReadRequest + .builder(streamOffset).consumer(consumer) + .autoAcknowledge(false) // 不自动 ack + .cancelOnError(throwable -> false); // 默认配置,发生异常就取消消费,显然不符合预期;因此,我们设置为 false + redisStreamMessageListenerContainer.register(builder.build(), message -> { + // 消费消息 + subscriber.onMessage(JsonUtils.parseObject(message.getValue(), type)); + // ack 消息消费完成 + redisTemplate.opsForStream().acknowledge(subscriber.getGroup(), message); + }); + this.subscribers.add(subscriber); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/rocketmq/IotRocketMQMessageBus.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/rocketmq/IotRocketMQMessageBus.java new file mode 100644 index 0000000000..48218b2519 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/rocketmq/IotRocketMQMessageBus.java @@ -0,0 +1,98 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core.rocketmq; + +import cn.hutool.core.util.TypeUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; +import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus; +import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently; +import org.apache.rocketmq.client.producer.SendResult; +import org.apache.rocketmq.common.message.MessageExt; +import org.apache.rocketmq.spring.autoconfigure.RocketMQProperties; +import org.apache.rocketmq.spring.core.RocketMQTemplate; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * 基于 RocketMQ 的 {@link IotMessageBus} 实现类 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotRocketMQMessageBus implements IotMessageBus { + + private final RocketMQProperties rocketMQProperties; + + private final RocketMQTemplate rocketMQTemplate; + + /** + * 主题对应的消费者映射 + */ + private final List topicConsumers = new ArrayList<>(); + + /** + * 销毁时关闭所有消费者 + */ + @PreDestroy + public void destroy() { + for (DefaultMQPushConsumer consumer : topicConsumers) { + try { + consumer.shutdown(); + log.info("[destroy][关闭 group({}) 的消费者成功]", consumer.getConsumerGroup()); + } catch (Exception e) { + log.error("[destroy]关闭 group({}) 的消费者异常]", consumer.getConsumerGroup(), e); + } + } + } + + @Override + public void post(String topic, Object message) { + // TODO @芋艿:需要 orderly! + SendResult result = rocketMQTemplate.syncSend(topic, JsonUtils.toJsonString(message)); + log.info("[post][topic({}) 发送消息({}) result({})]", topic, message, result); + } + + @Override + @SneakyThrows + public void register(IotMessageSubscriber subscriber) { + Type type = TypeUtil.getTypeArgument(subscriber.getClass(), 0); + if (type == null) { + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); + } + + // 1.1 创建 DefaultMQPushConsumer + DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(); + consumer.setNamesrvAddr(rocketMQProperties.getNameServer()); + consumer.setConsumerGroup(subscriber.getGroup()); + // 1.2 订阅主题 + consumer.subscribe(subscriber.getTopic(), "*"); + // 1.3 设置消息监听器 + consumer.setMessageListener((MessageListenerConcurrently) (messages, context) -> { + for (MessageExt messageExt : messages) { + try { + byte[] body = messageExt.getBody(); + subscriber.onMessage(JsonUtils.parseObject(body, type)); + } catch (Exception ex) { + log.error("[onMessage][topic({}/{}) message({}) 消费者({}) 处理异常]", + subscriber.getTopic(), subscriber.getGroup(), messageExt, subscriber.getClass().getName(), ex); + return ConsumeConcurrentlyStatus.RECONSUME_LATER; + } + } + return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; + }); + // 1.4 启动消费者 + consumer.start(); + + // 2. 保存消费者引用 + topicConsumers.add(consumer); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java new file mode 100644 index 0000000000..6821c0d160 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java @@ -0,0 +1,151 @@ +package cn.iocoder.yudao.module.iot.core.mq.message; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * IoT 设备消息 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IotDeviceMessage { + + /** + * 【消息总线】应用的设备消息 Topic,由 iot-gateway 发给 iot-biz 进行消费 + */ + public static final String MESSAGE_BUS_DEVICE_MESSAGE_TOPIC = "iot_device_message"; + + /** + * 【消息总线】设备消息 Topic,由 iot-biz 发送给 iot-gateway 的某个 "server"(protocol) 进行消费 + * + * 其中,%s 就是该"server"(protocol) 的标识 + */ + public static final String MESSAGE_BUS_GATEWAY_DEVICE_MESSAGE_TOPIC = MESSAGE_BUS_DEVICE_MESSAGE_TOPIC + "_%s"; + + /** + * 消息编号 + * + * 由后端生成,通过 {@link IotDeviceMessageUtils#generateMessageId()} + */ + private String id; + /** + * 上报时间 + * + * 由后端生成,当前时间 + */ + private LocalDateTime reportTime; + + /** + * 设备编号 + */ + private Long deviceId; + /** + * 租户编号 + */ + private Long tenantId; + + /** + * 服务编号,该消息由哪个 server 发送 + */ + private String serverId; + + // ========== codec(编解码)字段 ========== + + /** + * 请求编号 + * + * 由设备生成,对应阿里云 IoT 的 Alink 协议中的 id、华为云 IoTDA 协议的 request_id + */ + private String requestId; + /** + * 请求方法 + * + * 枚举 {@link IotDeviceMessageMethodEnum} + * 例如说:thing.property.report 属性上报 + */ + private String method; + /** + * 请求参数 + * + * 例如说:属性上报的 properties、事件上报的 params + */ + private Object params; + /** + * 响应结果 + */ + private Object data; + /** + * 响应错误码 + */ + private Integer code; + /** + * 返回结果信息 + */ + private String msg; + + // ========== 基础方法:只传递"codec(编解码)字段" ========== + + public static IotDeviceMessage requestOf(String method) { + return requestOf(null, method, null); + } + + public static IotDeviceMessage requestOf(String method, Object params) { + return requestOf(null, method, params); + } + + public static IotDeviceMessage requestOf(String requestId, String method, Object params) { + return of(requestId, method, params, null, null, null); + } + + public static IotDeviceMessage replyOf(String requestId, String method, + Object data, Integer code, String msg) { + if (code == null) { + code = GlobalErrorCodeConstants.SUCCESS.getCode(); + msg = GlobalErrorCodeConstants.SUCCESS.getMsg(); + } + return of(requestId, method, null, data, code, msg); + } + + public static IotDeviceMessage of(String requestId, String method, + Object params, Object data, Integer code, String msg) { + // 通用参数 + IotDeviceMessage message = new IotDeviceMessage() + .setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now()); + // 当前参数 + message.setRequestId(requestId).setMethod(method).setParams(params) + .setData(data).setCode(code).setMsg(msg); + return message; + } + + // ========== 核心方法:在 of 基础方法之上,添加对应 method ========== + + public static IotDeviceMessage buildStateUpdateOnline() { + return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(), + MapUtil.of("state", IotDeviceStateEnum.ONLINE.getState())); + } + + public static IotDeviceMessage buildStateOffline() { + return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(), + MapUtil.of("state", IotDeviceStateEnum.OFFLINE.getState())); + } + + public static IotDeviceMessage buildOtaUpgrade(String version, String fileUrl, Long fileSize, + String fileDigestAlgorithm, String fileDigestValue) { + return requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), MapUtil.builder() + .put("version", version).put("fileUrl", fileUrl).put("fileSize", fileSize) + .put("fileDigestAlgorithm", fileDigestAlgorithm).put("fileDigestValue", fileDigestValue) + .build()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/producer/IotDeviceMessageProducer.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/producer/IotDeviceMessageProducer.java new file mode 100644 index 0000000000..e152417230 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/producer/IotDeviceMessageProducer.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.iot.core.mq.producer; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import lombok.RequiredArgsConstructor; + +/** + * IoT 设备消息生产者 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class IotDeviceMessageProducer { + + private final IotMessageBus messageBus; + + /** + * 发送设备消息 + * + * @param message 设备消息 + */ + public void sendDeviceMessage(IotDeviceMessage message) { + messageBus.post(IotDeviceMessage.MESSAGE_BUS_DEVICE_MESSAGE_TOPIC, message); + } + + /** + * 发送网关设备消息 + * + * @param serverId 网关的 serverId 标识 + * @param message 设备消息 + */ + public void sendDeviceMessageToGateway(String serverId, IotDeviceMessage message) { + messageBus.post(IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(serverId), message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java new file mode 100644 index 0000000000..2bc4880070 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.iot.core.util; + +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.crypto.digest.HmacAlgorithm; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备【认证】的工具类,参考阿里云 + * + * @see 如何计算 MQTT 签名参数 + */ +public class IotDeviceAuthUtils { + + /** + * 认证信息 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class AuthInfo { + + /** + * 客户端 ID + */ + private String clientId; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + } + + /** + * 设备信息 + */ + @Data + public static class DeviceInfo { + + private String productKey; + + private String deviceName; + + } + + public static AuthInfo getAuthInfo(String productKey, String deviceName, String deviceSecret) { + String clientId = buildClientId(productKey, deviceName); + String username = buildUsername(productKey, deviceName); + String content = "clientId" + clientId + + "deviceName" + deviceName + + "deviceSecret" + deviceSecret + + "productKey" + productKey; + String password = buildPassword(deviceSecret, content); + return new AuthInfo(clientId, username, password); + } + + private static String buildClientId(String productKey, String deviceName) { + return String.format("%s.%s", productKey, deviceName); + } + + private static String buildUsername(String productKey, String deviceName) { + return String.format("%s&%s", deviceName, productKey); + } + + private static String buildPassword(String deviceSecret, String content) { + return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, deviceSecret.getBytes()) + .digestHex(content); + } + + public static DeviceInfo parseUsername(String username) { + String[] usernameParts = username.split("&"); + if (usernameParts.length != 2) { + return null; + } + return new DeviceInfo().setProductKey(usernameParts[1]).setDeviceName(usernameParts[0]); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java new file mode 100644 index 0000000000..5b7778ea0c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.iot.core.util; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.system.SystemUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; + +import java.util.Map; + +/** + * IoT 设备【消息】的工具类 + * + * @author 芋道源码 + */ +public class IotDeviceMessageUtils { + + // ========== Message 相关 ========== + + public static String generateMessageId() { + return IdUtil.fastSimpleUUID(); + } + + /** + * 是否是上行消息:由设备发送 + * + * @param message 消息 + * @return 是否 + */ + @SuppressWarnings("SimplifiableConditionalExpression") + public static boolean isUpstreamMessage(IotDeviceMessage message) { + IotDeviceMessageMethodEnum methodEnum = IotDeviceMessageMethodEnum.of(message.getMethod()); + Assert.notNull(methodEnum, "无法识别的消息方法:" + message.getMethod()); + // 注意:回复消息时,需要取反 + return !isReplyMessage(message) ? methodEnum.getUpstream() : !methodEnum.getUpstream(); + } + + /** + * 是否是回复消息,通过 {@link IotDeviceMessage#getCode()} 非空进行识别 + * + * @param message 消息 + * @return 是否 + */ + public static boolean isReplyMessage(IotDeviceMessage message) { + return message.getCode() != null; + } + + /** + * 提取消息中的标识符 + * + * @param message 消息 + * @return 标识符 + */ + @SuppressWarnings("unchecked") + public static String getIdentifier(IotDeviceMessage message) { + if (message.getParams() == null) { + return null; + } + if (StrUtil.equalsAny(message.getMethod(), IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod())) { + Map params = (Map) message.getParams(); + return MapUtil.getStr(params, "identifier"); + } else if (StrUtil.equalsAny(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { + Map params = (Map) message.getParams(); + return MapUtil.getStr(params, "state"); + } + return null; + } + + // ========== Topic 相关 ========== + + public static String buildMessageBusGatewayDeviceMessageTopic(String serverId) { + return String.format(IotDeviceMessage.MESSAGE_BUS_GATEWAY_DEVICE_MESSAGE_TOPIC, serverId); + } + + /** + * 生成服务器编号 + * + * @param serverPort 服务器端口 + * @return 服务器编号 + */ + public static String generateServerId(Integer serverPort) { + String serverId = String.format("%s.%d", SystemUtil.getHostInfo().getAddress(), serverPort); + // 避免一些场景无法使用 . 符号,例如说 RocketMQ Topic + return serverId.replaceAll("\\.", "_"); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-module-iot/yudao-module-iot-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..4c183f8227 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.iocoder.yudao.module.iot.core.messagebus.config.IotMessageBusAutoConfiguration \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/TestMessage.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/TestMessage.java new file mode 100644 index 0000000000..e06c9ec04b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/TestMessage.java @@ -0,0 +1,12 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core; + +import lombok.Data; + +@Data +public class TestMessage { + + private String nickname; + + private Integer age; + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/LocalIotMessageBusIntegrationTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/LocalIotMessageBusIntegrationTest.java new file mode 100644 index 0000000000..b282bc89ea --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/local/LocalIotMessageBusIntegrationTest.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core.local; + +import cn.iocoder.yudao.module.iot.core.messagebus.config.IotMessageBusAutoConfiguration; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotLocalMessageBus} 集成测试 + * + * @author 芋道源码 + */ +@SpringBootTest(classes = LocalIotMessageBusIntegrationTest.class) +@Import(IotMessageBusAutoConfiguration.class) +@TestPropertySource(properties = { + "yudao.iot.message-bus.type=local" +}) +@Slf4j +public class LocalIotMessageBusIntegrationTest { + + @Resource + private IotMessageBus messageBus; + + /** + * 1 topic 2 subscriber + */ + @Test + public void testSendMessageWithTwoSubscribers() throws InterruptedException { + // 准备 + String topic = "test-topic"; + String testMessage = "Hello IoT Message Bus!"; + // 用于等待消息处理完成 + CountDownLatch latch = new CountDownLatch(2); + // 用于记录接收到的消息 + AtomicInteger subscriber1Count = new AtomicInteger(0); + AtomicInteger subscriber2Count = new AtomicInteger(0); + + // 创建第一个订阅者 + IotMessageSubscriber subscriber1 = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic; + } + + @Override + public String getGroup() { + return "group1"; + } + + @Override + public void onMessage(String message) { + log.info("[订阅者1] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriber1Count.incrementAndGet(); + assertEquals(testMessage, message); + latch.countDown(); + } + + }; + // 创建第二个订阅者 + IotMessageSubscriber subscriber2 = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic; + } + + @Override + public String getGroup() { + return "group2"; + } + + @Override + public void onMessage(String message) { + log.info("[订阅者2] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriber2Count.incrementAndGet(); + assertEquals(testMessage, message); + latch.countDown(); + } + + }; + // 注册订阅者 + messageBus.register(subscriber1); + messageBus.register(subscriber2); + + // 发送消息 + log.info("[测试] 发送消息 - Topic: {}, Message: {}", topic, testMessage); + messageBus.post(topic, testMessage); + // 等待消息处理完成(最多等待 10 秒) + boolean completed = latch.await(10, TimeUnit.SECONDS); + + // 验证结果 + assertTrue(completed, "消息处理超时"); + assertEquals(1, subscriber1Count.get(), "订阅者 1 应该收到 1 条消息"); + assertEquals(1, subscriber2Count.get(), "订阅者 2 应该收到 1 条消息"); + log.info("[测试] 测试完成 - 订阅者 1 收到{}条消息,订阅者 2 收到{}条消息", subscriber1Count.get(), subscriber2Count.get()); + } + + /** + * 2 topic 2 subscriber + */ + @Test + public void testMultipleTopics() throws InterruptedException { + // 准备 + String topic1 = "device-status"; + String topic2 = "device-data"; + String message1 = "设备在线"; + String message2 = "温度:25°C"; + CountDownLatch latch = new CountDownLatch(2); + + // 创建订阅者 1 - 只订阅设备状态 + IotMessageSubscriber statusSubscriber = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic1; + } + + @Override + public String getGroup() { + return "status-group"; + } + + @Override + public void onMessage(String message) { + log.info("[状态订阅者] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + assertEquals(message1, message); + latch.countDown(); + } + + }; + // 创建订阅者 2 - 只订阅设备数据 + IotMessageSubscriber dataSubscriber = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic2; + } + + @Override + public String getGroup() { + return "data-group"; + } + + @Override + public void onMessage(String message) { + log.info("[数据订阅者] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + assertEquals(message2, message); + latch.countDown(); + } + + }; + // 注册订阅者到不同主题 + messageBus.register(statusSubscriber); + messageBus.register(dataSubscriber); + + // 发送消息到不同主题 + messageBus.post(topic1, message1); + messageBus.post(topic2, message2); + // 等待消息处理完成 + boolean completed = latch.await(10, TimeUnit.SECONDS); + assertTrue(completed, "消息处理超时"); + log.info("[测试] 多主题测试完成"); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/rocketmq/RocketMQIotMessageBusTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/rocketmq/RocketMQIotMessageBusTest.java new file mode 100644 index 0000000000..b7270f2fe0 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/messagebus/core/rocketmq/RocketMQIotMessageBusTest.java @@ -0,0 +1,268 @@ +package cn.iocoder.yudao.module.iot.core.messagebus.core.rocketmq; + +import cn.hutool.core.util.IdUtil; +import cn.iocoder.yudao.module.iot.core.messagebus.config.IotMessageBusAutoConfiguration; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.messagebus.core.TestMessage; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotRocketMQMessageBus} 集成测试 + * + * @author 芋道源码 + */ +@SpringBootTest(classes = RocketMQIotMessageBusTest.class) +@Import({RocketMQAutoConfiguration.class, IotMessageBusAutoConfiguration.class}) +@TestPropertySource(properties = { + "yudao.iot.message-bus.type=rocketmq", + "rocketmq.name-server=127.0.0.1:9876", + "rocketmq.producer.group=test-rocketmq-group", + "rocketmq.producer.send-message-timeout=10000" +}) +@Slf4j +public class RocketMQIotMessageBusTest { + + @Resource + private IotMessageBus messageBus; + + /** + * 1 topic 1 subscriber(string) + */ + @Test + public void testSendMessageWithOneSubscriber() throws InterruptedException { + // 准备 + String topic = "test-topic-" + IdUtil.simpleUUID(); +// String topic = "test-topic-pojo"; + String testMessage = "Hello IoT Message Bus!"; + // 用于等待消息处理完成 + CountDownLatch latch = new CountDownLatch(1); + // 用于记录接收到的消息 + AtomicInteger subscriberCount = new AtomicInteger(0); + AtomicReference subscriberMessageRef = new AtomicReference<>(); + + // 发送消息(需要提前发,保证 RocketMQ 路由的创建) + log.info("[测试] 发送消息 - Topic: {}, Message: {}", topic, testMessage); + messageBus.post(topic, testMessage); + + // 创建订阅者 + IotMessageSubscriber subscriber1 = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic; + } + + @Override + public String getGroup() { + return "test-topic-" + IdUtil.simpleUUID() + "-consumer"; +// return "test-topic-consumer-01"; + } + + @Override + public void onMessage(String message) { + log.info("[订阅者] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriberCount.incrementAndGet(); + subscriberMessageRef.set(message); + assertEquals(testMessage, message); + latch.countDown(); + } + + }; + // 注册订阅者 + messageBus.register(subscriber1); + + // 等待消息处理完成(最多等待 5 秒) + boolean completed = latch.await(10, TimeUnit.SECONDS); + + // 验证结果 + assertTrue(completed, "消息处理超时"); + assertEquals(1, subscriberCount.get(), "订阅者应该收到 1 条消息"); + log.info("[测试] 测试完成 - 订阅者收到{}条消息", subscriberCount.get()); + assertEquals(testMessage, subscriberMessageRef.get(), "接收到的消息内容不匹配"); + } + + /** + * 1 topic 2 subscriber(pojo) + */ + @Test + public void testSendMessageWithTwoSubscribers() throws InterruptedException { + // 准备 + String topic = "test-topic-" + IdUtil.simpleUUID(); +// String topic = "test-topic-pojo"; + TestMessage testMessage = new TestMessage().setNickname("yunai").setAge(18); + // 用于等待消息处理完成 + CountDownLatch latch = new CountDownLatch(2); + // 用于记录接收到的消息 + AtomicInteger subscriber1Count = new AtomicInteger(0); + AtomicReference subscriber1MessageRef = new AtomicReference<>(); + AtomicInteger subscriber2Count = new AtomicInteger(0); + AtomicReference subscriber2MessageRef = new AtomicReference<>(); + + // 发送消息(需要提前发,保证 RocketMQ 路由的创建) + log.info("[测试] 发送消息 - Topic: {}, Message: {}", topic, testMessage); + messageBus.post(topic, testMessage); + + // 创建第一个订阅者 + IotMessageSubscriber subscriber1 = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic; + } + + @Override + public String getGroup() { + return "test-topic-" + IdUtil.simpleUUID() + "-consumer"; +// return "test-topic-consumer-01"; + } + + @Override + public void onMessage(TestMessage message) { + log.info("[订阅者1] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriber1Count.incrementAndGet(); + subscriber1MessageRef.set(message); + assertEquals(testMessage, message); + latch.countDown(); + } + + }; + // 创建第二个订阅者 + IotMessageSubscriber subscriber2 = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic; + } + + @Override + public String getGroup() { + return "test-topic-" + IdUtil.simpleUUID() + "-consumer"; +// return "test-topic-consumer-02"; + } + + @Override + public void onMessage(TestMessage message) { + log.info("[订阅者2] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriber2Count.incrementAndGet(); + subscriber2MessageRef.set(message); + assertEquals(testMessage, message); + latch.countDown(); + } + + }; + // 注册订阅者 + messageBus.register(subscriber1); + messageBus.register(subscriber2); + + // 等待消息处理完成(最多等待 5 秒) + boolean completed = latch.await(10, TimeUnit.SECONDS); + + // 验证结果 + assertTrue(completed, "消息处理超时"); + assertEquals(1, subscriber1Count.get(), "订阅者 1 应该收到 1 条消息"); + assertEquals(1, subscriber2Count.get(), "订阅者 2 应该收到 1 条消息"); + log.info("[测试] 测试完成 - 订阅者 1 收到{}条消息,订阅者2收到{}条消息", subscriber1Count.get(), subscriber2Count.get()); + assertEquals(testMessage, subscriber1MessageRef.get(), "接收到的消息内容不匹配"); + assertEquals(testMessage, subscriber2MessageRef.get(), "接收到的消息内容不匹配"); + } + + /** + * 2 topic 2 subscriber + */ + @Test + public void testMultipleTopics() throws InterruptedException { + // 准备 + String topic1 = "device-status-" + IdUtil.simpleUUID(); + String topic2 = "device-data-" + IdUtil.simpleUUID(); + String message1 = "设备在线"; + String message2 = "温度:25°C"; + CountDownLatch latch = new CountDownLatch(2); + AtomicInteger subscriber1Count = new AtomicInteger(0); + AtomicReference subscriber1MessageRef = new AtomicReference<>(); + AtomicInteger subscriber2Count = new AtomicInteger(0); + AtomicReference subscriber2MessageRef = new AtomicReference<>(); + + + // 发送消息到不同主题(需要提前发,保证 RocketMQ 路由的创建) + log.info("[测试] 发送消息 - Topic1: {}, Message1: {}", topic1, message1); + messageBus.post(topic1, message1); + log.info("[测试] 发送消息 - Topic2: {}, Message2: {}", topic2, message2); + messageBus.post(topic2, message2); + + // 创建订阅者 1 - 只订阅设备状态 + IotMessageSubscriber statusSubscriber = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic1; + } + + @Override + public String getGroup() { + return "status-group-" + IdUtil.simpleUUID(); + } + + @Override + public void onMessage(String message) { + log.info("[状态订阅者] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriber1Count.incrementAndGet(); + subscriber1MessageRef.set(message); + assertEquals(message1, message); + latch.countDown(); + } + + }; + // 创建订阅者 2 - 只订阅设备数据 + IotMessageSubscriber dataSubscriber = new IotMessageSubscriber<>() { + + @Override + public String getTopic() { + return topic2; + } + + @Override + public String getGroup() { + return "data-group-" + IdUtil.simpleUUID(); + } + + @Override + public void onMessage(String message) { + log.info("[数据订阅者] 收到消息 - Topic: {}, Message: {}", getTopic(), message); + subscriber2Count.incrementAndGet(); + subscriber2MessageRef.set(message); + assertEquals(message2, message); + latch.countDown(); + } + + }; + // 注册订阅者到不同主题 + messageBus.register(statusSubscriber); + messageBus.register(dataSubscriber); + + // 等待消息处理完成 + boolean completed = latch.await(10, TimeUnit.SECONDS); + + // 验证结果 + assertTrue(completed, "消息处理超时"); + assertEquals(1, subscriber1Count.get(), "状态订阅者应该收到 1 条消息"); + assertEquals(message1, subscriber1MessageRef.get(), "状态订阅者接收到的消息内容不匹配"); + assertEquals(1, subscriber2Count.get(), "数据订阅者应该收到 1 条消息"); + assertEquals(message2, subscriber2MessageRef.get(), "数据订阅者接收到的消息内容不匹配"); + log.info("[测试] 多主题测试完成 - 状态订阅者收到{}条消息,数据订阅者收到{}条消息", subscriber1Count.get(), subscriber2Count.get()); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/pom.xml b/yudao-module-iot/yudao-module-iot-gateway/pom.xml new file mode 100644 index 0000000000..3c2b1fc642 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/pom.xml @@ -0,0 +1,78 @@ + + + yudao-module-iot + cn.iocoder.boot + ${revision} + + 4.0.0 + jar + yudao-module-iot-gateway + + ${project.artifactId} + + iot 模块下,设备网关: + ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息网关,提供给 iot-biz 进行处理 + ② 功能二:接收来自消息网关的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 + + + + + cn.iocoder.boot + yudao-module-iot-core + ${revision} + + + + org.springframework + spring-web + + + + + org.apache.rocketmq + rocketmq-spring-boot-starter + + + + + + + io.vertx + vertx-web + + + + + io.vertx + vertx-mqtt + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + test + + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + + diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/IotGatewayServerApplication.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/IotGatewayServerApplication.java new file mode 100644 index 0000000000..e9c4578850 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/IotGatewayServerApplication.java @@ -0,0 +1,13 @@ +package cn.iocoder.yudao.module.iot.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class IotGatewayServerApplication { + + public static void main(String[] args) { + SpringApplication.run(IotGatewayServerApplication.class, args); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/IotDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/IotDeviceMessageCodec.java new file mode 100644 index 0000000000..94dd309dd1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/IotDeviceMessageCodec.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.iot.gateway.codec; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; + +/** + * {@link cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage} 的编解码器 + * + * @author 芋道源码 + */ +public interface IotDeviceMessageCodec { + + /** + * 编码消息 + * + * @param message 消息 + * @return 编码后的消息内容 + */ + byte[] encode(IotDeviceMessage message); + + /** + * 解码消息 + * + * @param bytes 消息内容 + * @return 解码后的消息内容 + */ + IotDeviceMessage decode(byte[] bytes); + + /** + * @return 数据格式(编码器类型) + */ + String type(); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java new file mode 100644 index 0000000000..9086480d3f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.iot.gateway.codec.alink; + +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 阿里云 Alink {@link IotDeviceMessage} 的编解码器 + * + * @author 芋道源码 + */ +@Component +public class IotAlinkDeviceMessageCodec implements IotDeviceMessageCodec { + + private static final String TYPE = "Alink"; + + @Data + @NoArgsConstructor + @AllArgsConstructor + private static class AlinkMessage { + + public static final String VERSION_1 = "1.0"; + + /** + * 消息 ID,且每个消息 ID 在当前设备具有唯一性 + */ + private String id; + + /** + * 版本号 + */ + private String version; + + /** + * 请求方法 + */ + private String method; + + /** + * 请求参数 + */ + private Object params; + + /** + * 响应结果 + */ + private Object data; + /** + * 响应错误码 + */ + private Integer code; + /** + * 响应提示 + * + * 特殊:这里阿里云是 message,为了保持和项目的 {@link CommonResult#getMsg()} 一致。 + */ + private String msg; + + } + + @Override + public String type() { + return TYPE; + } + + @Override + public byte[] encode(IotDeviceMessage message) { + AlinkMessage alinkMessage = new AlinkMessage(message.getRequestId(), AlinkMessage.VERSION_1, + message.getMethod(), message.getParams(), message.getData(), message.getCode(), message.getMsg()); + return JsonUtils.toJsonByte(alinkMessage); + } + + @Override + @SuppressWarnings("DataFlowIssue") + public IotDeviceMessage decode(byte[] bytes) { + AlinkMessage alinkMessage = JsonUtils.parseObject(bytes, AlinkMessage.class); + Assert.notNull(alinkMessage, "消息不能为空"); + Assert.equals(alinkMessage.getVersion(), AlinkMessage.VERSION_1, "消息版本号必须是 1.0"); + return IotDeviceMessage.of(alinkMessage.getId(), alinkMessage.getMethod(), alinkMessage.getParams(), + alinkMessage.getData(), alinkMessage.getCode(), alinkMessage.getMsg()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/package-info.java new file mode 100644 index 0000000000..e1dae7707a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/package-info.java @@ -0,0 +1,4 @@ +/** + * 提供设备接入的各种数据(请求、响应)的编解码 + */ +package cn.iocoder.yudao.module.iot.gateway.codec; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/simple/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/simple/package-info.java new file mode 100644 index 0000000000..5bd676ad1a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/simple/package-info.java @@ -0,0 +1,4 @@ +/** + * TODO @芋艿:实现一个 alink 的 xml 版本 + */ +package cn.iocoder.yudao.module.iot.gateway.codec.simple; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java new file mode 100644 index 0000000000..4f42a8c2f6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java @@ -0,0 +1,286 @@ +package cn.iocoder.yudao.module.iot.gateway.codec.tcp; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; +import io.vertx.core.buffer.Buffer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +/** + * TCP 二进制格式 {@link IotDeviceMessage} 编解码器 + *

+ * 二进制协议格式(所有数值使用大端序): + * + *

+ * +--------+--------+--------+---------------------------+--------+--------+
+ * | 魔术字 | 版本号 | 消息类型|         消息长度(4 字节)          |
+ * +--------+--------+--------+---------------------------+--------+--------+
+ * |           消息 ID 长度(2 字节)        |      消息 ID (变长字符串)         |
+ * +--------+--------+--------+--------+--------+--------+--------+--------+
+ * |           方法名长度(2 字节)        |      方法名(变长字符串)         |
+ * +--------+--------+--------+--------+--------+--------+--------+--------+
+ * |                        消息体数据(变长)                              |
+ * +--------+--------+--------+--------+--------+--------+--------+--------+
+ * 
+ *

+ * 消息体格式: + * - 请求消息:params 数据(JSON) + * - 响应消息:code (4字节) + msg 长度(2字节) + msg 字符串 + data 数据(JSON) + *

+ * 注意:deviceId 不包含在协议中,由服务器根据连接上下文自动设置 + * + * @author 芋道源码 + */ +@Slf4j +@Component +public class IotTcpBinaryDeviceMessageCodec implements IotDeviceMessageCodec { + + public static final String TYPE = "TCP_BINARY"; + + /** + * 协议魔术字,用于协议识别 + */ + private static final byte MAGIC_NUMBER = (byte) 0x7E; + + /** + * 协议版本号 + */ + private static final byte PROTOCOL_VERSION = (byte) 0x01; + + /** + * 请求消息类型 + */ + private static final byte REQUEST = (byte) 0x01; + + /** + * 响应消息类型 + */ + private static final byte RESPONSE = (byte) 0x02; + + /** + * 协议头部固定长度(魔术字 + 版本号 + 消息类型 + 消息长度) + */ + private static final int HEADER_FIXED_LENGTH = 7; + + /** + * 最小消息长度(头部 + 消息ID长度 + 方法名长度) + */ + private static final int MIN_MESSAGE_LENGTH = HEADER_FIXED_LENGTH + 4; + + @Override + public String type() { + return TYPE; + } + + @Override + public byte[] encode(IotDeviceMessage message) { + Assert.notNull(message, "消息不能为空"); + Assert.notBlank(message.getMethod(), "消息方法不能为空"); + try { + // 1. 确定消息类型 + byte messageType = determineMessageType(message); + // 2. 构建消息体 + byte[] bodyData = buildMessageBody(message, messageType); + // 3. 构建完整消息 + return buildCompleteMessage(message, messageType, bodyData); + } catch (Exception e) { + log.error("[encode][TCP 二进制消息编码失败,消息: {}]", message, e); + throw new RuntimeException("TCP 二进制消息编码失败: " + e.getMessage(), e); + } + } + + @Override + public IotDeviceMessage decode(byte[] bytes) { + Assert.notNull(bytes, "待解码数据不能为空"); + Assert.isTrue(bytes.length >= MIN_MESSAGE_LENGTH, "数据包长度不足"); + try { + Buffer buffer = Buffer.buffer(bytes); + // 解析协议头部和消息内容 + int index = 0; + // 1. 验证魔术字 + byte magic = buffer.getByte(index++); + Assert.isTrue(magic == MAGIC_NUMBER, "无效的协议魔术字: " + magic); + + // 2. 验证版本号 + byte version = buffer.getByte(index++); + Assert.isTrue(version == PROTOCOL_VERSION, "不支持的协议版本: " + version); + + // 3. 读取消息类型 + byte messageType = buffer.getByte(index++); + // 直接验证消息类型,无需抽取方法 + Assert.isTrue(messageType == REQUEST || messageType == RESPONSE, + "无效的消息类型: " + messageType); + + // 4. 读取消息长度 + int messageLength = buffer.getInt(index); + index += 4; + Assert.isTrue(messageLength == buffer.length(), + "消息长度不匹配,期望: " + messageLength + ", 实际: " + buffer.length()); + + // 5. 读取消息 ID + short messageIdLength = buffer.getShort(index); + index += 2; + String messageId = buffer.getString(index, index + messageIdLength, StandardCharsets.UTF_8.name()); + index += messageIdLength; + + // 6. 读取方法名 + short methodLength = buffer.getShort(index); + index += 2; + String method = buffer.getString(index, index + methodLength, StandardCharsets.UTF_8.name()); + index += methodLength; + + // 7. 解析消息体 + return parseMessageBody(buffer, index, messageType, messageId, method); + } catch (Exception e) { + log.error("[decode][TCP 二进制消息解码失败,数据长度: {}]", bytes.length, e); + throw new RuntimeException("TCP 二进制消息解码失败: " + e.getMessage(), e); + } + } + + /** + * 确定消息类型 + * 优化后的判断逻辑:有响应字段就是响应消息,否则就是请求消息 + */ + private byte determineMessageType(IotDeviceMessage message) { + // 判断是否为响应消息:有响应码或响应消息时为响应 + if (message.getCode() != null) { + return RESPONSE; + } + // 默认为请求消息 + return REQUEST; + } + + /** + * 构建消息体 + */ + private byte[] buildMessageBody(IotDeviceMessage message, byte messageType) { + Buffer bodyBuffer = Buffer.buffer(); + if (messageType == RESPONSE) { + // code + bodyBuffer.appendInt(message.getCode() != null ? message.getCode() : 0); + // msg + String msg = message.getMsg() != null ? message.getMsg() : ""; + byte[] msgBytes = StrUtil.utf8Bytes(msg); + bodyBuffer.appendShort((short) msgBytes.length); + bodyBuffer.appendBytes(msgBytes); + // data + if (message.getData() != null) { + bodyBuffer.appendBytes(JsonUtils.toJsonByte(message.getData())); + } + } else { + // 请求消息只处理 params 参数 + // TODO @haohao:如果为空,是不是得写个长度 0 哈? + if (message.getParams() != null) { + bodyBuffer.appendBytes(JsonUtils.toJsonByte(message.getParams())); + } + } + return bodyBuffer.getBytes(); + } + + /** + * 构建完整消息 + */ + private byte[] buildCompleteMessage(IotDeviceMessage message, byte messageType, byte[] bodyData) { + Buffer buffer = Buffer.buffer(); + // 1. 写入协议头部 + buffer.appendByte(MAGIC_NUMBER); + buffer.appendByte(PROTOCOL_VERSION); + buffer.appendByte(messageType); + // 2. 预留消息长度位置(在 5. 更新消息长度) + int lengthPosition = buffer.length(); + buffer.appendInt(0); + // 3. 写入消息 ID + String messageId = StrUtil.isNotBlank(message.getRequestId()) ? message.getRequestId() + : IotDeviceMessageUtils.generateMessageId(); + byte[] messageIdBytes = StrUtil.utf8Bytes(messageId); + buffer.appendShort((short) messageIdBytes.length); + buffer.appendBytes(messageIdBytes); + // 4. 写入方法名 + byte[] methodBytes = StrUtil.utf8Bytes(message.getMethod()); + buffer.appendShort((short) methodBytes.length); + buffer.appendBytes(methodBytes); + // 5. 写入消息体 + buffer.appendBytes(bodyData); + // 6. 更新消息长度 + buffer.setInt(lengthPosition, buffer.length()); + return buffer.getBytes(); + } + + /** + * 解析消息体 + */ + private IotDeviceMessage parseMessageBody(Buffer buffer, int startIndex, byte messageType, + String messageId, String method) { + if (startIndex >= buffer.length()) { + // 空消息体 + return IotDeviceMessage.of(messageId, method, null, null, null, null); + } + + if (messageType == RESPONSE) { + // 响应消息:解析 code + msg + data + return parseResponseMessage(buffer, startIndex, messageId, method); + } else { + // 请求消息:解析 payload + Object payload = parseJsonData(buffer, startIndex, buffer.length()); + return IotDeviceMessage.of(messageId, method, payload, null, null, null); + } + } + + /** + * 解析响应消息 + */ + private IotDeviceMessage parseResponseMessage(Buffer buffer, int startIndex, String messageId, String method) { + int index = startIndex; + + // 1. 读取响应码 + Integer code = buffer.getInt(index); + index += 4; + + // 2. 读取响应消息 + short msgLength = buffer.getShort(index); + index += 2; + String msg = msgLength > 0 ? buffer.getString(index, index + msgLength, StandardCharsets.UTF_8.name()) : null; + index += msgLength; + + // 3. 读取响应数据 + Object data = null; + if (index < buffer.length()) { + data = parseJsonData(buffer, index, buffer.length()); + } + + return IotDeviceMessage.of(messageId, method, null, data, code, msg); + } + + /** + * 解析 JSON 数据 + */ + private Object parseJsonData(Buffer buffer, int startIndex, int endIndex) { + if (startIndex >= endIndex) { + return null; + } + try { + String jsonStr = buffer.getString(startIndex, endIndex, StandardCharsets.UTF_8.name()); + return JsonUtils.parseObject(jsonStr, Object.class); + } catch (Exception e) { + log.warn("[parseJsonData][JSON 解析失败,返回原始字符串]", e); + return buffer.getString(startIndex, endIndex, StandardCharsets.UTF_8.name()); + } + } + + /** + * 快速检测是否为二进制格式 + * + * @param data 数据 + * @return 是否为二进制格式 + */ + public static boolean isBinaryFormatQuick(byte[] data) { + return data != null && data.length >= 1 && data[0] == MAGIC_NUMBER; + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java new file mode 100644 index 0000000000..10ffbdf5c6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.iot.gateway.codec.tcp; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * TCP JSON 格式 {@link IotDeviceMessage} 编解码器 + * + * 采用纯 JSON 格式传输,格式如下: + * { + * "id": "消息 ID", + * "method": "消息方法", + * "params": {...}, // 请求参数 + * "data": {...}, // 响应结果 + * "code": 200, // 响应错误码 + * "msg": "success", // 响应提示 + * "timestamp": 时间戳 + * } + * + * @author 芋道源码 + */ +@Component +public class IotTcpJsonDeviceMessageCodec implements IotDeviceMessageCodec { + + public static final String TYPE = "TCP_JSON"; + + @Data + @NoArgsConstructor + @AllArgsConstructor + private static class TcpJsonMessage { + + /** + * 消息 ID,且每个消息 ID 在当前设备具有唯一性 + */ + private String id; + + /** + * 请求方法 + */ + private String method; + + /** + * 请求参数 + */ + private Object params; + + /** + * 响应结果 + */ + private Object data; + + /** + * 响应错误码 + */ + private Integer code; + + /** + * 响应提示 + */ + private String msg; + + /** + * 时间戳 + */ + private Long timestamp; + + } + + @Override + public String type() { + return TYPE; + } + + @Override + public byte[] encode(IotDeviceMessage message) { + TcpJsonMessage tcpJsonMessage = new TcpJsonMessage( + message.getRequestId(), + message.getMethod(), + message.getParams(), + message.getData(), + message.getCode(), + message.getMsg(), + System.currentTimeMillis()); + return JsonUtils.toJsonByte(tcpJsonMessage); + } + + @Override + @SuppressWarnings("DataFlowIssue") + public IotDeviceMessage decode(byte[] bytes) { + String jsonStr = StrUtil.utf8Str(bytes).trim(); + TcpJsonMessage tcpJsonMessage = JsonUtils.parseObject(jsonStr, TcpJsonMessage.class); + Assert.notNull(tcpJsonMessage, "消息不能为空"); + Assert.notBlank(tcpJsonMessage.getMethod(), "消息方法不能为空"); + return IotDeviceMessage.of( + tcpJsonMessage.getId(), + tcpJsonMessage.getMethod(), + tcpJsonMessage.getParams(), + tcpJsonMessage.getData(), + tcpJsonMessage.getCode(), + tcpJsonMessage.getMsg()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java new file mode 100644 index 0000000000..4b9c3af32c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java @@ -0,0 +1,154 @@ +package cn.iocoder.yudao.module.iot.gateway.config; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxAuthEventProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Vertx; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(IotGatewayProperties.class) +@Slf4j +public class IotGatewayConfiguration { + + /** + * IoT 网关 HTTP 协议配置类 + */ + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.http", name = "enabled", havingValue = "true") + @Slf4j + public static class HttpProtocolConfiguration { + + @Bean + public IotHttpUpstreamProtocol iotHttpUpstreamProtocol(IotGatewayProperties gatewayProperties) { + return new IotHttpUpstreamProtocol(gatewayProperties.getProtocol().getHttp()); + } + + @Bean + public IotHttpDownstreamSubscriber iotHttpDownstreamSubscriber(IotHttpUpstreamProtocol httpUpstreamProtocol, + IotMessageBus messageBus) { + return new IotHttpDownstreamSubscriber(httpUpstreamProtocol, messageBus); + } + } + + /** + * IoT 网关 EMQX 协议配置类 + */ + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.emqx", name = "enabled", havingValue = "true") + @Slf4j + public static class EmqxProtocolConfiguration { + + @Bean(destroyMethod = "close") + public Vertx emqxVertx() { + return Vertx.vertx(); + } + + @Bean + public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties, + Vertx emqxVertx) { + return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); + } + + @Bean + public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties, + Vertx emqxVertx) { + return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); + } + + @Bean + public IotEmqxDownstreamSubscriber iotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol mqttUpstreamProtocol, + IotMessageBus messageBus) { + return new IotEmqxDownstreamSubscriber(mqttUpstreamProtocol, messageBus); + } + } + + /** + * IoT 网关 TCP 协议配置类 + */ + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.tcp", name = "enabled", havingValue = "true") + @Slf4j + public static class TcpProtocolConfiguration { + + @Bean(destroyMethod = "close") + public Vertx tcpVertx() { + return Vertx.vertx(); + } + + @Bean + public IotTcpUpstreamProtocol iotTcpUpstreamProtocol(IotGatewayProperties gatewayProperties, + IotDeviceService deviceService, + IotDeviceMessageService messageService, + IotTcpConnectionManager connectionManager, + Vertx tcpVertx) { + return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(), + deviceService, messageService, connectionManager, tcpVertx); + } + + @Bean + public IotTcpDownstreamSubscriber iotTcpDownstreamSubscriber(IotTcpUpstreamProtocol protocolHandler, + IotDeviceMessageService messageService, + IotDeviceService deviceService, + IotTcpConnectionManager connectionManager, + IotMessageBus messageBus) { + return new IotTcpDownstreamSubscriber(protocolHandler, messageService, deviceService, connectionManager, + messageBus); + } + + } + + /** + * IoT 网关 MQTT 协议配置类 + */ + @Configuration + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.mqtt", name = "enabled", havingValue = "true") + @Slf4j + public static class MqttProtocolConfiguration { + + @Bean(destroyMethod = "close") + public Vertx mqttVertx() { + return Vertx.vertx(); + } + + @Bean + public IotMqttUpstreamProtocol iotMqttUpstreamProtocol(IotGatewayProperties gatewayProperties, + IotDeviceMessageService messageService, + IotMqttConnectionManager connectionManager, + Vertx mqttVertx) { + return new IotMqttUpstreamProtocol(gatewayProperties.getProtocol().getMqtt(), messageService, + connectionManager, mqttVertx); + } + + @Bean + public IotMqttDownstreamHandler iotMqttDownstreamHandler(IotDeviceMessageService messageService, + IotMqttConnectionManager connectionManager) { + return new IotMqttDownstreamHandler(messageService, connectionManager); + } + + @Bean + public IotMqttDownstreamSubscriber iotMqttDownstreamSubscriber(IotMqttUpstreamProtocol mqttUpstreamProtocol, + IotMqttDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + return new IotMqttDownstreamSubscriber(mqttUpstreamProtocol, downstreamHandler, messageBus); + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java new file mode 100644 index 0000000000..2c2000fd1f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java @@ -0,0 +1,405 @@ +package cn.iocoder.yudao.module.iot.gateway.config; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; +import java.util.List; + +@ConfigurationProperties(prefix = "yudao.iot.gateway") +@Validated +@Data +public class IotGatewayProperties { + + /** + * 设备 RPC 服务配置 + */ + private RpcProperties rpc; + /** + * Token 配置 + */ + private TokenProperties token; + + /** + * 协议配置 + */ + private ProtocolProperties protocol; + + @Data + public static class RpcProperties { + + /** + * 主程序 API 地址 + */ + @NotEmpty(message = "主程序 API 地址不能为空") + private String url; + /** + * 连接超时时间 + */ + @NotNull(message = "连接超时时间不能为空") + private Duration connectTimeout; + /** + * 读取超时时间 + */ + @NotNull(message = "读取超时时间不能为空") + private Duration readTimeout; + + } + + @Data + public static class TokenProperties { + + /** + * 密钥 + */ + @NotEmpty(message = "密钥不能为空") + private String secret; + /** + * 令牌有效期 + */ + @NotNull(message = "令牌有效期不能为空") + private Duration expiration; + + } + + @Data + public static class ProtocolProperties { + + /** + * HTTP 组件配置 + */ + private HttpProperties http; + + /** + * EMQX 组件配置 + */ + private EmqxProperties emqx; + + /** + * TCP 组件配置 + */ + private TcpProperties tcp; + + /** + * MQTT 组件配置 + */ + private MqttProperties mqtt; + + } + + @Data + public static class HttpProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enabled; + /** + * 服务端口 + */ + private Integer serverPort; + + /** + * 是否开启 SSL + */ + @NotNull(message = "是否开启 SSL 不能为空") + private Boolean sslEnabled = false; + + /** + * SSL 证书路径 + */ + private String sslKeyPath; + /** + * SSL 证书路径 + */ + private String sslCertPath; + + } + + @Data + public static class EmqxProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enabled; + + /** + * HTTP 服务端口(默认:8090) + */ + private Integer httpPort = 8090; + + /** + * MQTT 服务器地址 + */ + @NotEmpty(message = "MQTT 服务器地址不能为空") + private String mqttHost; + + /** + * MQTT 服务器端口(默认:1883) + */ + @NotNull(message = "MQTT 服务器端口不能为空") + private Integer mqttPort = 1883; + + /** + * MQTT 用户名 + */ + @NotEmpty(message = "MQTT 用户名不能为空") + private String mqttUsername; + + /** + * MQTT 密码 + */ + @NotEmpty(message = "MQTT 密码不能为空") + private String mqttPassword; + + /** + * MQTT 客户端的 SSL 开关 + */ + @NotNull(message = "MQTT 是否开启 SSL 不能为空") + private Boolean mqttSsl = false; + + /** + * MQTT 客户端 ID(如果为空,系统将自动生成) + */ + @NotEmpty(message = "MQTT 客户端 ID 不能为空") + private String mqttClientId; + + /** + * MQTT 订阅的主题 + */ + @NotEmpty(message = "MQTT 主题不能为空") + private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics; + + /** + * 默认 QoS 级别 + *

+ * 0 - 最多一次 + * 1 - 至少一次 + * 2 - 刚好一次 + */ + private Integer mqttQos = 1; + + /** + * 连接超时时间(秒) + */ + private Integer connectTimeoutSeconds = 10; + + /** + * 重连延迟时间(毫秒) + */ + private Long reconnectDelayMs = 5000L; + + /** + * 是否启用 Clean Session (清理会话) + * true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。 + * 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。 + */ + private Boolean cleanSession = true; + + /** + * 心跳间隔(秒) + * 用于保持连接活性,及时发现网络中断。 + */ + private Integer keepAliveIntervalSeconds = 60; + + /** + * 最大未确认消息队列大小 + * 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。 + */ + private Integer maxInflightQueue = 10000; + + /** + * 是否信任所有 SSL 证书 + * 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用! + * 在生产环境中,应设置为 false,并配置正确的信任库。 + */ + private Boolean trustAll = false; + + /** + * 遗嘱消息配置 (用于网关异常下线时通知其他系统) + */ + private final Will will = new Will(); + + /** + * 高级 SSL/TLS 配置 (用于生产环境) + */ + private final Ssl sslOptions = new Ssl(); + + /** + * 遗嘱消息 (Last Will and Testament) + */ + @Data + public static class Will { + + /** + * 是否启用遗嘱消息 + */ + private boolean enabled = false; + /** + * 遗嘱消息主题 + */ + private String topic; + /** + * 遗嘱消息内容 + */ + private String payload; + /** + * 遗嘱消息 QoS 等级 + */ + private Integer qos = 1; + /** + * 遗嘱消息是否作为保留消息发布 + */ + private boolean retain = true; + + } + + /** + * 高级 SSL/TLS 配置 + */ + @Data + public static class Ssl { + + /** + * 密钥库(KeyStore)路径,例如:classpath:certs/client.jks + * 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。 + */ + private String keyStorePath; + /** + * 密钥库密码 + */ + private String keyStorePassword; + /** + * 信任库(TrustStore)路径,例如:classpath:certs/trust.jks + * 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。 + */ + private String trustStorePath; + /** + * 信任库密码 + */ + private String trustStorePassword; + + } + + } + + @Data + public static class TcpProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enabled; + + /** + * 服务器端口 + */ + private Integer port = 8091; + + /** + * 心跳超时时间(毫秒) + */ + private Long keepAliveTimeoutMs = 30000L; + + /** + * 最大连接数 + */ + private Integer maxConnections = 1000; + + /** + * 是否启用SSL + */ + private Boolean sslEnabled = false; + + /** + * SSL证书路径 + */ + private String sslCertPath; + + /** + * SSL私钥路径 + */ + private String sslKeyPath; + + } + + @Data + public static class MqttProperties { + + /** + * 是否开启 + */ + @NotNull(message = "是否开启不能为空") + private Boolean enabled; + + /** + * 服务器端口 + */ + private Integer port = 1883; + + /** + * 最大消息大小(字节) + */ + private Integer maxMessageSize = 8192; + + /** + * 连接超时时间(秒) + */ + private Integer connectTimeoutSeconds = 60; + /** + * 保持连接超时时间(秒) + */ + private Integer keepAliveTimeoutSeconds = 300; + + /** + * 是否启用 SSL + */ + private Boolean sslEnabled = false; + /** + * SSL 配置 + */ + private SslOptions sslOptions = new SslOptions(); + + /** + * SSL 配置选项 + */ + @Data + public static class SslOptions { + + /** + * 密钥证书选项 + */ + private io.vertx.core.net.KeyCertOptions keyCertOptions; + /** + * 信任选项 + */ + private io.vertx.core.net.TrustOptions trustOptions; + /** + * SSL 证书路径 + */ + private String certPath; + /** + * SSL 私钥路径 + */ + private String keyPath; + /** + * 信任存储路径 + */ + private String trustStorePath; + /** + * 信任存储密码 + */ + private String trustStorePassword; + + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java new file mode 100644 index 0000000000..90afda224e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.iot.gateway.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * iot gateway 错误码枚举类 + *

+ * iot 系统,使用 1-051-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 设备认证 1-050-001-000 ============ + ErrorCode DEVICE_AUTH_FAIL = new ErrorCode(1_051_001_000, "设备鉴权失败"); // 对应阿里云 20000 + ErrorCode DEVICE_TOKEN_EXPIRED = new ErrorCode(1_051_001_002, "token 失效。需重新调用 auth 进行鉴权,获取token"); // 对应阿里云 20001 + + // ========== 设备信息 1-050-002-000 ============ + ErrorCode DEVICE_NOT_EXISTS = new ErrorCode(1_051_002_001, "设备({}/{}) 不存在"); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxAuthEventProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxAuthEventProtocol.java new file mode 100644 index 0000000000..ce10cf76d9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxAuthEventProtocol.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; + +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxAuthEventHandler; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.BodyHandler; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 EMQX 认证事件协议服务 + *

+ * 为 EMQX 提供 HTTP 接口服务,包括: + * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 + * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 + * + * @author 芋道源码 + */ +@Slf4j +public class IotEmqxAuthEventProtocol { + + private final IotGatewayProperties.EmqxProperties emqxProperties; + + private final String serverId; + + private final Vertx vertx; + + private HttpServer httpServer; + + public IotEmqxAuthEventProtocol(IotGatewayProperties.EmqxProperties emqxProperties, + Vertx vertx) { + this.emqxProperties = emqxProperties; + this.vertx = vertx; + this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort()); + } + + @PostConstruct + public void start() { + try { + startHttpServer(); + log.info("[start][IoT 网关 EMQX 认证事件协议服务启动成功, 端口: {}]", emqxProperties.getHttpPort()); + } catch (Exception e) { + log.error("[start][IoT 网关 EMQX 认证事件协议服务启动失败]", e); + throw e; + } + } + + @PreDestroy + public void stop() { + stopHttpServer(); + log.info("[stop][IoT 网关 EMQX 认证事件协议服务已停止]"); + } + + /** + * 启动 HTTP 服务器 + */ + private void startHttpServer() { + int port = emqxProperties.getHttpPort(); + + // 1. 创建路由 + Router router = Router.router(vertx); + router.route().handler(BodyHandler.create()); + + // 2. 创建处理器,传入 serverId + IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId); + router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth); + router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent); + // TODO @haohao:/mqtt/acl 需要处理么? + // TODO @芋艿:已在 EMQX 处理,如果是“设备直连”模式需要处理 + + // 3. 启动 HTTP 服务器 + try { + httpServer = vertx.createHttpServer() + .requestHandler(router) + .listen(port) + .result(); + } catch (Exception e) { + log.error("[startHttpServer][HTTP 服务器启动失败, 端口: {}]", port, e); + throw e; + } + } + + /** + * 停止 HTTP 服务器 + */ + private void stopHttpServer() { + if (httpServer == null) { + return; + } + + try { + httpServer.close().result(); + log.info("[stopHttpServer][HTTP 服务器已停止]"); + } catch (Exception e) { + log.error("[stopHttpServer][HTTP 服务器停止失败]", e); + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java new file mode 100644 index 0000000000..61bf12376b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxDownstreamHandler; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 EMQX 订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotEmqxDownstreamSubscriber implements IotMessageSubscriber { + + private final IotEmqxDownstreamHandler downstreamHandler; + + private final IotMessageBus messageBus; + + private final IotEmqxUpstreamProtocol protocol; + + public IotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol protocol, IotMessageBus messageBus) { + this.protocol = protocol; + this.messageBus = messageBus; + this.downstreamHandler = new IotEmqxDownstreamHandler(protocol); + } + + @PostConstruct + public void init() { + messageBus.register(this); + } + + @Override + public String getTopic() { + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); + } + + @Override + public String getGroup() { + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group + return getTopic(); + } + + @Override + public void onMessage(IotDeviceMessage message) { + log.debug("[onMessage][接收到下行消息, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); + try { + // 1. 校验 + String method = message.getMethod(); + if (method == null) { + log.warn("[onMessage][消息方法为空, messageId: {}, deviceId: {}]", + message.getId(), message.getDeviceId()); + return; + } + + // 2. 处理下行消息 + downstreamHandler.handle(message); + } catch (Exception e) { + log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId(), e); + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java new file mode 100644 index 0000000000..a888158746 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java @@ -0,0 +1,365 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxUpstreamHandler; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.JksOptions; +import io.vertx.mqtt.MqttClient; +import io.vertx.mqtt.MqttClientOptions; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * IoT 网关 EMQX 协议:接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotEmqxUpstreamProtocol { + + private final IotGatewayProperties.EmqxProperties emqxProperties; + + private volatile boolean isRunning = false; + + private final Vertx vertx; + + @Getter + private final String serverId; + + private MqttClient mqttClient; + + private IotEmqxUpstreamHandler upstreamHandler; + + public IotEmqxUpstreamProtocol(IotGatewayProperties.EmqxProperties emqxProperties, + Vertx vertx) { + this.emqxProperties = emqxProperties; + this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort()); + this.vertx = vertx; + } + + @PostConstruct + public void start() { + if (isRunning) { + return; + } + + try { + // 1. 启动 MQTT 客户端 + startMqttClient(); + + // 2. 标记服务为运行状态 + isRunning = true; + log.info("[start][IoT 网关 EMQX 协议启动成功]"); + } catch (Exception e) { + log.error("[start][IoT 网关 EMQX 协议服务启动失败,应用将关闭]", e); + stop(); + + // 异步关闭应用 + Thread shutdownThread = new Thread(() -> { + try { + // 确保日志输出完成,使用更优雅的方式 + log.error("[start][由于 MQTT 连接失败,正在关闭应用]"); + // 等待日志输出完成 + Thread.sleep(1000); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.warn("[start][应用关闭被中断]"); + } + System.exit(1); + }); + shutdownThread.setDaemon(true); + shutdownThread.setName("emergency-shutdown"); + shutdownThread.start(); + + throw e; + } + } + + @PreDestroy + public void stop() { + if (!isRunning) { + return; + } + + // 1. 停止 MQTT 客户端 + stopMqttClient(); + + // 2. 标记服务为停止状态 + isRunning = false; + log.info("[stop][IoT 网关 MQTT 协议服务已停止]"); + } + + /** + * 启动 MQTT 客户端 + */ + private void startMqttClient() { + try { + // 1. 初始化消息处理器 + this.upstreamHandler = new IotEmqxUpstreamHandler(this); + + // 2. 创建 MQTT 客户端 + createMqttClient(); + + // 3. 同步连接 MQTT Broker + connectMqttSync(); + } catch (Exception e) { + log.error("[startMqttClient][MQTT 客户端启动失败]", e); + throw new RuntimeException("MQTT 客户端启动失败: " + e.getMessage(), e); + } + } + + /** + * 同步连接 MQTT Broker + */ + private void connectMqttSync() { + String host = emqxProperties.getMqttHost(); + int port = emqxProperties.getMqttPort(); + // 1. 连接 MQTT Broker + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean success = new AtomicBoolean(false); + mqttClient.connect(port, host, connectResult -> { + if (connectResult.succeeded()) { + log.info("[connectMqttSync][MQTT 客户端连接成功, host: {}, port: {}]", host, port); + setupMqttHandlers(); + subscribeToTopics(); + success.set(true); + } else { + log.error("[connectMqttSync][连接 MQTT Broker 失败, host: {}, port: {}]", + host, port, connectResult.cause()); + } + latch.countDown(); + }); + + // 2. 等待连接结果 + try { + // 应用层超时控制:防止启动过程无限阻塞,与MQTT客户端的网络超时是不同层次的控制 + boolean awaitResult = latch.await(10, java.util.concurrent.TimeUnit.SECONDS); + if (!awaitResult) { + log.error("[connectMqttSync][等待连接结果超时]"); + throw new RuntimeException("连接 MQTT Broker 超时"); + } + if (!success.get()) { + throw new RuntimeException(String.format("首次连接 MQTT Broker 失败,地址: %s, 端口: %d", host, port)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[connectMqttSync][等待连接结果被中断]", e); + throw new RuntimeException("连接 MQTT Broker 被中断", e); + } + } + + /** + * 异步连接 MQTT Broker + */ + private void connectMqttAsync() { + String host = emqxProperties.getMqttHost(); + int port = emqxProperties.getMqttPort(); + mqttClient.connect(port, host, connectResult -> { + if (connectResult.succeeded()) { + log.info("[connectMqttAsync][MQTT 客户端重连成功]"); + setupMqttHandlers(); + subscribeToTopics(); + } else { + log.error("[connectMqttAsync][连接 MQTT Broker 失败, host: {}, port: {}]", + host, port, connectResult.cause()); + log.warn("[connectMqttAsync][重连失败,将再次尝试]"); + reconnectWithDelay(); + } + }); + } + + /** + * 延迟重连 + */ + private void reconnectWithDelay() { + if (!isRunning) { + return; + } + if (mqttClient != null && mqttClient.isConnected()) { + return; + } + + long delay = emqxProperties.getReconnectDelayMs(); + log.info("[reconnectWithDelay][将在 {} 毫秒后尝试重连 MQTT Broker]", delay); + vertx.setTimer(delay, timerId -> { + if (!isRunning) { + return; + } + if (mqttClient != null && mqttClient.isConnected()) { + return; + } + + log.info("[reconnectWithDelay][开始重连 MQTT Broker]"); + try { + createMqttClient(); + connectMqttAsync(); + } catch (Exception e) { + log.error("[reconnectWithDelay][重连过程中发生异常]", e); + vertx.setTimer(delay, t -> reconnectWithDelay()); + } + }); + } + + /** + * 停止 MQTT 客户端 + */ + private void stopMqttClient() { + if (mqttClient == null) { + return; + } + try { + if (mqttClient.isConnected()) { + // 1. 取消订阅所有主题 + List topicList = emqxProperties.getMqttTopics(); + for (String topic : topicList) { + try { + mqttClient.unsubscribe(topic); + } catch (Exception e) { + log.warn("[stopMqttClient][取消订阅主题({})异常]", topic, e); + } + } + + // 2. 断开 MQTT 客户端连接 + try { + CountDownLatch disconnectLatch = new CountDownLatch(1); + mqttClient.disconnect(ar -> disconnectLatch.countDown()); + if (!disconnectLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)) { + log.warn("[stopMqttClient][断开 MQTT 连接超时]"); + } + } catch (Exception e) { + log.warn("[stopMqttClient][关闭 MQTT 客户端异常]", e); + } + } + } catch (Exception e) { + log.warn("[stopMqttClient][停止 MQTT 客户端过程中发生异常]", e); + } finally { + mqttClient = null; + } + } + + /** + * 创建 MQTT 客户端 + */ + private void createMqttClient() { + // 1.1 创建基础配置 + MqttClientOptions options = (MqttClientOptions) new MqttClientOptions() + .setClientId(emqxProperties.getMqttClientId()) + .setUsername(emqxProperties.getMqttUsername()) + .setPassword(emqxProperties.getMqttPassword()) + .setSsl(emqxProperties.getMqttSsl()) + .setCleanSession(emqxProperties.getCleanSession()) + .setKeepAliveInterval(emqxProperties.getKeepAliveIntervalSeconds()) + .setMaxInflightQueue(emqxProperties.getMaxInflightQueue()) + .setConnectTimeout(emqxProperties.getConnectTimeoutSeconds() * 1000) // Vert.x 需要毫秒 + .setTrustAll(emqxProperties.getTrustAll()); + // 1.2 配置遗嘱消息 + IotGatewayProperties.EmqxProperties.Will will = emqxProperties.getWill(); + if (will.isEnabled()) { + Assert.notBlank(will.getTopic(), "遗嘱消息主题(will.topic)不能为空"); + Assert.notNull(will.getPayload(), "遗嘱消息内容(will.payload)不能为空"); + options.setWillFlag(true) + .setWillTopic(will.getTopic()) + .setWillMessageBytes(Buffer.buffer(will.getPayload())) + .setWillQoS(will.getQos()) + .setWillRetain(will.isRetain()); + } + // 1.3 配置高级 SSL/TLS (仅在启用 SSL 且不信任所有证书时生效) + if (Boolean.TRUE.equals(emqxProperties.getMqttSsl()) && !Boolean.TRUE.equals(emqxProperties.getTrustAll())) { + IotGatewayProperties.EmqxProperties.Ssl sslOptions = emqxProperties.getSslOptions(); + if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) { + options.setTrustStoreOptions(new JksOptions() + .setPath(sslOptions.getTrustStorePath()) + .setPassword(sslOptions.getTrustStorePassword())); + } + if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) { + options.setKeyStoreOptions(new JksOptions() + .setPath(sslOptions.getKeyStorePath()) + .setPassword(sslOptions.getKeyStorePassword())); + } + } + // 1.4 安全警告日志 + if (Boolean.TRUE.equals(emqxProperties.getTrustAll())) { + log.warn("[createMqttClient][安全警告:当前配置信任所有 SSL 证书(trustAll=true),这在生产环境中存在严重安全风险!]"); + } + + // 2. 创建客户端实例 + this.mqttClient = MqttClient.create(vertx, options); + } + + /** + * 设置 MQTT 处理器 + */ + private void setupMqttHandlers() { + // 1. 设置断开重连监听器 + mqttClient.closeHandler(closeEvent -> { + if (!isRunning) { + return; + } + log.warn("[closeHandler][MQTT 连接已断开, 准备重连]"); + reconnectWithDelay(); + }); + + // 2. 设置异常处理器 + mqttClient.exceptionHandler(exception -> + log.error("[exceptionHandler][MQTT 客户端异常]", exception)); + + // 3. 设置消息处理器 + mqttClient.publishHandler(upstreamHandler::handle); + } + + /** + * 订阅设备上行消息主题 + */ + private void subscribeToTopics() { + // 1. 校验 MQTT 客户端是否连接 + List topicList = emqxProperties.getMqttTopics(); + if (mqttClient == null || !mqttClient.isConnected()) { + log.warn("[subscribeToTopics][MQTT 客户端未连接, 跳过订阅]"); + return; + } + + // 2. 批量订阅所有主题 + Map topics = new HashMap<>(); + int qos = emqxProperties.getMqttQos(); + for (String topic : topicList) { + topics.put(topic, qos); + } + mqttClient.subscribe(topics, subscribeResult -> { + if (subscribeResult.succeeded()) { + log.info("[subscribeToTopics][订阅主题成功, 共 {} 个主题]", topicList.size()); + } else { + log.error("[subscribeToTopics][订阅主题失败, 共 {} 个主题, 原因: {}]", + topicList.size(), subscribeResult.cause().getMessage(), subscribeResult.cause()); + } + }); + } + + /** + * 发布消息到 MQTT Broker + * + * @param topic 主题 + * @param payload 消息内容 + */ + public void publishMessage(String topic, byte[] payload) { + if (mqttClient == null || !mqttClient.isConnected()) { + log.warn("[publishMessage][MQTT 客户端未连接, 无法发布消息]"); + return; + } + MqttQoS qos = MqttQoS.valueOf(emqxProperties.getMqttQos()); + mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java new file mode 100644 index 0000000000..d6957bd52f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java @@ -0,0 +1,248 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router; + +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 EMQX 认证事件处理器 + *

+ * 为 EMQX 提供 HTTP 接口服务,包括: + * 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 + * 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 + * + * @author 芋道源码 + */ +@Slf4j +public class IotEmqxAuthEventHandler { + + /** + * HTTP 成功状态码(EMQX 要求固定使用 200) + */ + private static final int SUCCESS_STATUS_CODE = 200; + + /** + * 认证允许结果 + */ + private static final String RESULT_ALLOW = "allow"; + /** + * 认证拒绝结果 + */ + private static final String RESULT_DENY = "deny"; + /** + * 认证忽略结果 + */ + private static final String RESULT_IGNORE = "ignore"; + + /** + * EMQX 事件类型常量 + */ + private static final String EVENT_CLIENT_CONNECTED = "client.connected"; + private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected"; + + private final String serverId; + + private final IotDeviceMessageService deviceMessageService; + + private final IotDeviceCommonApi deviceApi; + + public IotEmqxAuthEventHandler(String serverId) { + this.serverId = serverId; + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + } + + /** + * EMQX 认证接口 + */ + public void handleAuth(RoutingContext context) { + try { + // 1. 参数校验 + JsonObject body = parseRequestBody(context); + if (body == null) { + return; + } + String clientId = body.getString("clientid"); + String username = body.getString("username"); + String password = body.getString("password"); + log.debug("[handleAuth][设备认证请求: clientId={}, username={}]", clientId, username); + if (StrUtil.hasEmpty(clientId, username, password)) { + log.info("[handleAuth][认证参数不完整: clientId={}, username={}]", clientId, username); + sendAuthResponse(context, RESULT_DENY); + return; + } + + // 2. 执行认证 + boolean authResult = handleDeviceAuth(clientId, username, password); + log.info("[handleAuth][设备认证结果: {} -> {}]", username, authResult); + if (authResult) { + sendAuthResponse(context, RESULT_ALLOW); + } else { + sendAuthResponse(context, RESULT_DENY); + } + } catch (Exception e) { + log.error("[handleAuth][设备认证异常]", e); + sendAuthResponse(context, RESULT_IGNORE); + } + } + + /** + * EMQX 统一事件处理接口:根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件 + * 支持的事件类型:client.connected、client.disconnected 等 + */ + public void handleEvent(RoutingContext context) { + JsonObject body = null; + try { + // 1. 解析请求体 + body = parseRequestBody(context); + if (body == null) { + return; + } + String event = body.getString("event"); + String username = body.getString("username"); + log.debug("[handleEvent][收到事件: {} - {}]", event, username); + + // 2. 根据事件类型进行分发处理 + switch (event) { + case EVENT_CLIENT_CONNECTED: + handleClientConnected(body); + break; + case EVENT_CLIENT_DISCONNECTED: + handleClientDisconnected(body); + break; + default: + break; + } + + // EMQX Webhook 只需要 200 状态码,无需响应体 + context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); + } catch (Exception e) { + log.error("[handleEvent][事件处理失败][body={}]", body != null ? body.encode() : "null", e); + // 即使处理失败,也返回 200 避免EMQX重试 + context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); + } + } + + /** + * 处理客户端连接事件 + */ + private void handleClientConnected(JsonObject body) { + String username = body.getString("username"); + log.info("[handleClientConnected][设备上线: {}]", username); + handleDeviceStateChange(username, true); + } + + /** + * 处理客户端断开连接事件 + */ + private void handleClientDisconnected(JsonObject body) { + String username = body.getString("username"); + String reason = body.getString("reason"); + log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason); + handleDeviceStateChange(username, false); + } + + /** + * 解析请求体 + * + * @param context 路由上下文 + * @return 请求体JSON对象,解析失败时返回null + */ + private JsonObject parseRequestBody(RoutingContext context) { + try { + JsonObject body = context.body().asJsonObject(); + if (body == null) { + log.info("[parseRequestBody][请求体为空]"); + sendAuthResponse(context, RESULT_IGNORE); + return null; + } + return body; + } catch (Exception e) { + log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e); + sendAuthResponse(context, RESULT_IGNORE); + return null; + } + } + + /** + * 执行设备认证 + * + * @param clientId 客户端ID + * @param username 用户名 + * @param password 密码 + * @return 认证是否成功 + */ + private boolean handleDeviceAuth(String clientId, String username, String password) { + try { + CommonResult result = deviceApi.authDevice(new IotDeviceAuthReqDTO() + .setClientId(clientId).setUsername(username).setPassword(password)); + result.checkError(); + return BooleanUtil.isTrue(result.getData()); + } catch (Exception e) { + log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e); + throw e; + } + } + + /** + * 处理设备状态变化 + * + * @param username 用户名 + * @param online 是否在线 true 在线 false 离线 + */ + private void handleDeviceStateChange(String username, boolean online) { + // 1. 解析设备信息 + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); + if (deviceInfo == null) { + log.debug("[handleDeviceStateChange][跳过非设备({})连接]", username); + return; + } + + try { + // 2. 构建设备状态消息 + IotDeviceMessage message = online ? IotDeviceMessage.buildStateUpdateOnline() + : IotDeviceMessage.buildStateOffline(); + + // 3. 发送设备状态消息 + deviceMessageService.sendDeviceMessage(message, + deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId); + } catch (Exception e) { + log.error("[handleDeviceStateChange][发送设备状态消息失败: {}]", username, e); + } + } + + /** + * 发送 EMQX 认证响应 + * 根据 EMQX 官方文档要求,必须返回 JSON 格式响应 + * + * @param context 路由上下文 + * @param result 认证结果:allow、deny、ignore + */ + private void sendAuthResponse(RoutingContext context, String result) { + // 构建符合 EMQX 官方规范的响应 + JsonObject response = new JsonObject() + .put("result", result) + .put("is_superuser", false); + + // 可以根据业务需求添加客户端属性 + // response.put("client_attrs", new JsonObject().put("role", "device")); + + // 可以添加认证过期时间(可选) + // response.put("expire_at", System.currentTimeMillis() / 1000 + 3600); + + context.response() + .setStatusCode(SUCCESS_STATUS_CODE) + .putHeader("Content-Type", "application/json; charset=utf-8") + .end(response.encode()); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxDownstreamHandler.java new file mode 100644 index 0000000000..06632b3e8f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxDownstreamHandler.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 EMQX 下行消息处理器 + *

+ * 从消息总线接收到下行消息,然后发布到 MQTT Broker,从而被设备所接收 + * + * @author 芋道源码 + */ +@Slf4j +public class IotEmqxDownstreamHandler { + + private final IotEmqxUpstreamProtocol protocol; + + private final IotDeviceService deviceService; + + private final IotDeviceMessageService deviceMessageService; + + public IotEmqxDownstreamHandler(IotEmqxUpstreamProtocol protocol) { + this.protocol = protocol; + this.deviceService = SpringUtil.getBean(IotDeviceService.class); + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + } + + /** + * 处理下行消息 + * + * @param message 设备消息 + */ + public void handle(IotDeviceMessage message) { + // 1. 获取设备信息 + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); + if (deviceInfo == null) { + log.error("[handle][设备信息({})不存在]", message.getDeviceId()); + return; + } + + // 2.1 根据方法构建主题 + String topic = buildTopicByMethod(message, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + if (StrUtil.isBlank(topic)) { + log.warn("[handle][未知的消息方法: {}]", message.getMethod()); + return; + } + // 2.2 构建载荷 + byte[] payload = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), + deviceInfo.getDeviceName()); + // 2.3 发布消息 + protocol.publishMessage(topic, payload); + } + + /** + * 根据消息方法和回复状态构建主题 + * + * @param message 设备消息 + * @param productKey 产品标识 + * @param deviceName 设备名称 + * @return 构建的主题,如果方法不支持返回 null + */ + private String buildTopicByMethod(IotDeviceMessage message, String productKey, String deviceName) { + // 1. 判断是否为回复消息 + boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); + // 2. 根据消息方法类型构建对应的主题 + return IotMqttTopicUtils.buildTopicByMethod(message.getMethod(), productKey, deviceName, isReply); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxUpstreamHandler.java new file mode 100644 index 0000000000..81d8cbb13a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxUpstreamHandler.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.mqtt.messages.MqttPublishMessage; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 EMQX 上行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotEmqxUpstreamHandler { + + private final IotDeviceMessageService deviceMessageService; + + private final String serverId; + + public IotEmqxUpstreamHandler(IotEmqxUpstreamProtocol protocol) { + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.serverId = protocol.getServerId(); + } + + /** + * 处理 MQTT 发布消息 + */ + public void handle(MqttPublishMessage mqttMessage) { + log.info("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); + String topic = mqttMessage.topicName(); + byte[] payload = mqttMessage.payload().getBytes(); + try { + // 1. 解析主题,一次性获取所有信息 + String[] topicParts = topic.split("/"); + if (topicParts.length < 4 || StrUtil.hasBlank(topicParts[2], topicParts[3])) { + log.warn("[handle][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic); + return; + } + + String productKey = topicParts[2]; + String deviceName = topicParts[3]; + + // 3. 解码消息 + IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName); + if (message == null) { + log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); + return; + } + + // 4. 发送消息到队列 + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + } catch (Exception e) { + log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e); + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpDownstreamSubscriber.java new file mode 100644 index 0000000000..585bbdd30b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpDownstreamSubscriber.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 HTTP 订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotHttpDownstreamSubscriber implements IotMessageSubscriber { + + private final IotHttpUpstreamProtocol protocol; + + private final IotMessageBus messageBus; + + @PostConstruct + public void init() { + messageBus.register(this); + } + + @Override + public String getTopic() { + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); + } + + @Override + public String getGroup() { + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group + return getTopic(); + } + + @Override + public void onMessage(IotDeviceMessage message) { + log.info("[onMessage][IoT 网关 HTTP 协议不支持下行消息,忽略消息:{}]", message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java new file mode 100644 index 0000000000..eda59d13ff --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpAuthHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpUpstreamHandler; +import io.vertx.core.AbstractVerticle; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.net.PemKeyCertOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.BodyHandler; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 HTTP 协议:接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotHttpUpstreamProtocol extends AbstractVerticle { + + private final IotGatewayProperties.HttpProperties httpProperties; + + private HttpServer httpServer; + + @Getter + private final String serverId; + + public IotHttpUpstreamProtocol(IotGatewayProperties.HttpProperties httpProperties) { + this.httpProperties = httpProperties; + this.serverId = IotDeviceMessageUtils.generateServerId(httpProperties.getServerPort()); + } + + @Override + @PostConstruct + public void start() { + // 创建路由 + Vertx vertx = Vertx.vertx(); + Router router = Router.router(vertx); + router.route().handler(BodyHandler.create()); + + // 创建处理器,添加路由处理器 + IotHttpAuthHandler authHandler = new IotHttpAuthHandler(this); + router.post(IotHttpAuthHandler.PATH).handler(authHandler); + IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(this); + router.post(IotHttpUpstreamHandler.PATH).handler(upstreamHandler); + + // 启动 HTTP 服务器 + HttpServerOptions options = new HttpServerOptions() + .setPort(httpProperties.getServerPort()); + if (Boolean.TRUE.equals(httpProperties.getSslEnabled())) { + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions().setKeyPath(httpProperties.getSslKeyPath()) + .setCertPath(httpProperties.getSslCertPath()); + options = options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); + } + try { + httpServer = vertx.createHttpServer(options) + .requestHandler(router) + .listen() + .result(); + log.info("[start][IoT 网关 HTTP 协议启动成功,端口:{}]", httpProperties.getServerPort()); + } catch (Exception e) { + log.error("[start][IoT 网关 HTTP 协议启动失败]", e); + throw e; + } + } + + @Override + @PreDestroy + public void stop() { + if (httpServer != null) { + try { + httpServer.close().result(); + log.info("[stop][IoT 网关 HTTP 协议已停止]"); + } catch (Exception e) { + log.error("[stop][IoT 网关 HTTP 协议停止失败]", e); + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java new file mode 100644 index 0000000000..f5461c2c51 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.router; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * IoT 网关 HTTP 协议的处理器抽象基类:提供通用的前置处理(认证)、全局的异常捕获等 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public abstract class IotHttpAbstractHandler implements Handler { + + private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); + + @Override + public final void handle(RoutingContext context) { + try { + // 1. 前置处理 + beforeHandle(context); + + // 2. 执行逻辑 + CommonResult result = handle0(context); + writeResponse(context, result); + } catch (ServiceException e) { + writeResponse(context, CommonResult.error(e.getCode(), e.getMessage())); + } catch (Exception e) { + log.error("[handle][path({}) 处理异常]", context.request().path(), e); + writeResponse(context, CommonResult.error(INTERNAL_SERVER_ERROR)); + } + } + + protected abstract CommonResult handle0(RoutingContext context); + + private void beforeHandle(RoutingContext context) { + // 如果不需要认证,则不走前置处理 + String path = context.request().path(); + if (ObjUtil.equal(path, IotHttpAuthHandler.PATH)) { + return; + } + + // 解析参数 + String token = context.request().getHeader(HttpHeaders.AUTHORIZATION); + if (StrUtil.isEmpty(token)) { + throw invalidParamException("token 不能为空"); + } + String productKey = context.pathParam("productKey"); + if (StrUtil.isEmpty(productKey)) { + throw invalidParamException("productKey 不能为空"); + } + String deviceName = context.pathParam("deviceName"); + if (StrUtil.isEmpty(deviceName)) { + throw invalidParamException("deviceName 不能为空"); + } + + // 校验 token + IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.verifyToken(token); + Assert.notNull(deviceInfo, "设备信息不能为空"); + // 校验设备信息是否匹配 + if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey()) + || ObjUtil.notEqual(deviceName, deviceInfo.getDeviceName())) { + throw exception(FORBIDDEN); + } + } + + @SuppressWarnings("deprecation") + public static void writeResponse(RoutingContext context, Object data) { + context.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE) + .end(JsonUtils.toJsonString(data)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java new file mode 100644 index 0000000000..e6a52cdf0f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.router; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; + +/** + * IoT 网关 HTTP 协议的【认证】处理器 + * + * 参考 https://help.aliyun.com/zh/iot/user-guide/establish-connections-over-https + * + * @author 芋道源码 + */ +public class IotHttpAuthHandler extends IotHttpAbstractHandler { + + public static final String PATH = "/auth"; + + private final IotHttpUpstreamProtocol protocol; + + private final IotDeviceTokenService deviceTokenService; + + private final IotDeviceCommonApi deviceApi; + + private final IotDeviceMessageService deviceMessageService; + + public IotHttpAuthHandler(IotHttpUpstreamProtocol protocol) { + this.protocol = protocol; + this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + } + + @Override + public CommonResult handle0(RoutingContext context) { + // 1. 解析参数 + JsonObject body = context.body().asJsonObject(); + String clientId = body.getString("clientId"); + if (StrUtil.isEmpty(clientId)) { + throw invalidParamException("clientId 不能为空"); + } + String username = body.getString("username"); + if (StrUtil.isEmpty(username)) { + throw invalidParamException("username 不能为空"); + } + String password = body.getString("password"); + if (StrUtil.isEmpty(password)) { + throw invalidParamException("password 不能为空"); + } + + // 2.1 执行认证 + CommonResult result = deviceApi.authDevice(new IotDeviceAuthReqDTO() + .setClientId(clientId).setUsername(username).setPassword(password)); + result.checkError(); + if (!BooleanUtil.isTrue(result.getData())) { + throw exception(DEVICE_AUTH_FAIL); + } + // 2.2 生成 Token + IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.parseUsername(username); + Assert.notNull(deviceInfo, "设备信息不能为空"); + String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + Assert.notBlank(token, "生成 token 不能为空位"); + + // 3. 执行上线 + IotDeviceMessage message = IotDeviceMessage.buildStateUpdateOnline(); + deviceMessageService.sendDeviceMessage(message, + deviceInfo.getProductKey(), deviceInfo.getDeviceName(), protocol.getServerId()); + + // 构建响应数据 + return success(MapUtil.of("token", token)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java new file mode 100644 index 0000000000..d7d4d52ff2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.router; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.text.StrPool; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.ext.web.RoutingContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 HTTP 协议的【上行】处理器 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { + + public static final String PATH = "/topic/sys/:productKey/:deviceName/*"; + + private final IotHttpUpstreamProtocol protocol; + + private final IotDeviceMessageService deviceMessageService; + + public IotHttpUpstreamHandler(IotHttpUpstreamProtocol protocol) { + this.protocol = protocol; + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + } + + @Override + protected CommonResult handle0(RoutingContext context) { + // 1. 解析通用参数 + String productKey = context.pathParam("productKey"); + String deviceName = context.pathParam("deviceName"); + String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT); + + // 2.1 解析消息 + byte[] bytes = context.body().buffer().getBytes(); + IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(bytes, + productKey, deviceName); + Assert.equals(method, message.getMethod(), "method 不匹配"); + // 2.2 发送消息 + deviceMessageService.sendDeviceMessage(message, + productKey, deviceName, protocol.getServerId()); + + // 3. 返回结果 + return CommonResult.success(MapUtil.of("messageId", message.getId())); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java new file mode 100644 index 0000000000..3b62368fd9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 MQTT 协议:下行消息订阅器 + *

+ * 负责接收来自消息总线的下行消息,并委托给下行处理器进行业务处理 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttDownstreamSubscriber implements IotMessageSubscriber { + + private final IotMqttUpstreamProtocol upstreamProtocol; + + private final IotMqttDownstreamHandler downstreamHandler; + + private final IotMessageBus messageBus; + + public IotMqttDownstreamSubscriber(IotMqttUpstreamProtocol upstreamProtocol, + IotMqttDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + this.upstreamProtocol = upstreamProtocol; + this.downstreamHandler = downstreamHandler; + this.messageBus = messageBus; + } + + @PostConstruct + public void subscribe() { + messageBus.register(this); + log.info("[subscribe][MQTT 协议下行消息订阅成功,主题:{}]", getTopic()); + } + + @Override + public String getTopic() { + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(upstreamProtocol.getServerId()); + } + + @Override + public String getGroup() { + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group + return getTopic(); + } + + @Override + public void onMessage(IotDeviceMessage message) { + log.debug("[onMessage][接收到下行消息, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); + try { + // 1. 校验 + String method = message.getMethod(); + if (method == null) { + log.warn("[onMessage][消息方法为空, messageId: {}, deviceId: {}]", + message.getId(), message.getDeviceId()); + return; + } + + // 2. 委托给下行处理器处理业务逻辑 + boolean success = downstreamHandler.handleDownstreamMessage(message); + if (success) { + log.debug("[onMessage][下行消息处理成功, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); + } else { + log.warn("[onMessage][下行消息处理失败, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); + } + } catch (Exception e) { + log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId(), e); + } + } +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java new file mode 100644 index 0000000000..fc0b6672c1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt; + +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Vertx; +import io.vertx.mqtt.MqttServer; +import io.vertx.mqtt.MqttServerOptions; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 MQTT 协议:接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttUpstreamProtocol { + + private final IotGatewayProperties.MqttProperties mqttProperties; + + private final IotDeviceMessageService messageService; + + private final IotMqttConnectionManager connectionManager; + + private final Vertx vertx; + + @Getter + private final String serverId; + + private MqttServer mqttServer; + + public IotMqttUpstreamProtocol(IotGatewayProperties.MqttProperties mqttProperties, + IotDeviceMessageService messageService, + IotMqttConnectionManager connectionManager, + Vertx vertx) { + this.mqttProperties = mqttProperties; + this.messageService = messageService; + this.connectionManager = connectionManager; + this.vertx = vertx; + this.serverId = IotDeviceMessageUtils.generateServerId(mqttProperties.getPort()); + } + + // TODO @haohao:这里的编写,是不是和 tcp 对应的,风格保持一致哈; + @PostConstruct + public void start() { + // 创建服务器选项 + MqttServerOptions options = new MqttServerOptions() + .setPort(mqttProperties.getPort()) + .setMaxMessageSize(mqttProperties.getMaxMessageSize()) + .setTimeoutOnConnect(mqttProperties.getConnectTimeoutSeconds()); + + // 配置 SSL(如果启用) + if (Boolean.TRUE.equals(mqttProperties.getSslEnabled())) { + options.setSsl(true) + .setKeyCertOptions(mqttProperties.getSslOptions().getKeyCertOptions()) + .setTrustOptions(mqttProperties.getSslOptions().getTrustOptions()); + } + + // 创建服务器并设置连接处理器 + mqttServer = MqttServer.create(vertx, options); + mqttServer.endpointHandler(endpoint -> { + IotMqttUpstreamHandler handler = new IotMqttUpstreamHandler(this, messageService, connectionManager); + handler.handle(endpoint); + }); + + // 启动服务器 + try { + mqttServer.listen().result(); + log.info("[start][IoT 网关 MQTT 协议启动成功,端口:{}]", mqttProperties.getPort()); + } catch (Exception e) { + log.error("[start][IoT 网关 MQTT 协议启动失败]", e); + throw e; + } + } + + @PreDestroy + public void stop() { + if (mqttServer != null) { + try { + mqttServer.close().result(); + log.info("[stop][IoT 网关 MQTT 协议已停止]"); + } catch (Exception e) { + log.error("[stop][IoT 网关 MQTT 协议停止失败]", e); + } + } + } +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java new file mode 100644 index 0000000000..3fd1a3a041 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java @@ -0,0 +1,223 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager; + +import cn.hutool.core.util.StrUtil; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.mqtt.MqttEndpoint; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * IoT 网关 MQTT 连接管理器 + *

+ * 统一管理 MQTT 连接的认证状态、设备会话和消息发送功能: + * 1. 管理 MQTT 连接的认证状态 + * 2. 管理设备会话和在线状态 + * 3. 管理消息发送到设备 + * + * @author 芋道源码 + */ +@Slf4j +@Component +public class IotMqttConnectionManager { + + /** + * 未知地址常量(当获取端点地址失败时使用) + */ + private static final String UNKNOWN_ADDRESS = "unknown"; + + /** + * 连接信息映射:MqttEndpoint -> 连接信息 + */ + private final Map connectionMap = new ConcurrentHashMap<>(); + + /** + * 设备 ID -> MqttEndpoint 的映射 + */ + private final Map deviceEndpointMap = new ConcurrentHashMap<>(); + + /** + * 安全获取 endpoint 地址 + *

+ * 优先从缓存获取地址,缓存为空时再尝试实时获取 + * + * @param endpoint MQTT 连接端点 + * @return 地址字符串,获取失败时返回 "unknown" + */ + public String getEndpointAddress(MqttEndpoint endpoint) { + String realTimeAddress = UNKNOWN_ADDRESS; + if (endpoint == null) { + return realTimeAddress; + } + + // 1. 优先从缓存获取(避免连接关闭时的异常) + ConnectionInfo connectionInfo = connectionMap.get(endpoint); + if (connectionInfo != null && StrUtil.isNotBlank(connectionInfo.getRemoteAddress())) { + return connectionInfo.getRemoteAddress(); + } + + // 2. 缓存为空时尝试实时获取 + try { + realTimeAddress = endpoint.remoteAddress().toString(); + } catch (Exception ignored) { + // 连接已关闭,忽略异常 + } + + return realTimeAddress; + } + + /** + * 注册设备连接(包含认证信息) + * + * @param endpoint MQTT 连接端点 + * @param deviceId 设备 ID + * @param connectionInfo 连接信息 + */ + public void registerConnection(MqttEndpoint endpoint, Long deviceId, ConnectionInfo connectionInfo) { + // 如果设备已有其他连接,先清理旧连接 + MqttEndpoint oldEndpoint = deviceEndpointMap.get(deviceId); + if (oldEndpoint != null && oldEndpoint != endpoint) { + log.info("[registerConnection][设备已有其他连接,断开旧连接,设备 ID: {},旧连接: {}]", + deviceId, getEndpointAddress(oldEndpoint)); + oldEndpoint.close(); + // 清理旧连接的映射 + connectionMap.remove(oldEndpoint); + } + + connectionMap.put(endpoint, connectionInfo); + deviceEndpointMap.put(deviceId, endpoint); + + log.info("[registerConnection][注册设备连接,设备 ID: {},连接: {},product key: {},device name: {}]", + deviceId, getEndpointAddress(endpoint), connectionInfo.getProductKey(), connectionInfo.getDeviceName()); + } + + /** + * 注销设备连接 + * + * @param endpoint MQTT 连接端点 + */ + public void unregisterConnection(MqttEndpoint endpoint) { + ConnectionInfo connectionInfo = connectionMap.remove(endpoint); + if (connectionInfo != null) { + Long deviceId = connectionInfo.getDeviceId(); + deviceEndpointMap.remove(deviceId); + + log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", deviceId, + getEndpointAddress(endpoint)); + } + } + + /** + * 获取连接信息 + */ + public ConnectionInfo getConnectionInfo(MqttEndpoint endpoint) { + return connectionMap.get(endpoint); + } + + /** + * 根据设备 ID 获取连接信息 + * + * @param deviceId 设备 ID + * @return 连接信息 + */ + public IotMqttConnectionManager.ConnectionInfo getConnectionInfoByDeviceId(Long deviceId) { + // 通过设备 ID 获取连接端点 + var endpoint = getDeviceEndpoint(deviceId); + if (endpoint == null) { + return null; + } + + // 获取连接信息 + return getConnectionInfo(endpoint); + } + + /** + * 检查设备是否在线 + */ + public boolean isDeviceOnline(Long deviceId) { + return deviceEndpointMap.containsKey(deviceId); + } + + /** + * 检查设备是否离线 + */ + public boolean isDeviceOffline(Long deviceId) { + return !isDeviceOnline(deviceId); + } + + /** + * 发送消息到设备 + * + * @param deviceId 设备 ID + * @param topic 主题 + * @param payload 消息内容 + * @param qos 服务质量 + * @param retain 是否保留消息 + * @return 是否发送成功 + */ + public boolean sendToDevice(Long deviceId, String topic, byte[] payload, int qos, boolean retain) { + MqttEndpoint endpoint = deviceEndpointMap.get(deviceId); + if (endpoint == null) { + log.warn("[sendToDevice][设备离线,无法发送消息,设备 ID: {},主题: {}]", deviceId, topic); + return false; + } + + try { + endpoint.publish(topic, io.vertx.core.buffer.Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain); + log.debug("[sendToDevice][发送消息成功,设备 ID: {},主题: {},QoS: {}]", deviceId, topic, qos); + return true; + } catch (Exception e) { + log.error("[sendToDevice][发送消息失败,设备 ID: {},主题: {},错误: {}]", deviceId, topic, e.getMessage()); + return false; + } + } + + /** + * 获取设备连接端点 + */ + public MqttEndpoint getDeviceEndpoint(Long deviceId) { + return deviceEndpointMap.get(deviceId); + } + + /** + * 连接信息 + */ + @Data + public static class ConnectionInfo { + + /** + * 设备 ID + */ + private Long deviceId; + + /** + * 产品 Key + */ + private String productKey; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 客户端 ID + */ + private String clientId; + + /** + * 是否已认证 + */ + private boolean authenticated; + + /** + * 连接地址 + */ + private String remoteAddress; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/package-info.java new file mode 100644 index 0000000000..fabe79466e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/package-info.java @@ -0,0 +1,6 @@ +/** + * MQTT 协议实现包 + *

+ * 提供基于 Vert.x MQTT Server 的 IoT 设备连接和消息处理功能 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttDownstreamHandler.java new file mode 100644 index 0000000000..c848833f66 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttDownstreamHandler.java @@ -0,0 +1,132 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import io.netty.handler.codec.mqtt.MqttQoS; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 MQTT 协议:下行消息处理器 + *

+ * 专门处理下行消息的业务逻辑,包括: + * 1. 消息编码 + * 2. 主题构建 + * 3. 消息发送 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttDownstreamHandler { + + private final IotDeviceMessageService deviceMessageService; + + private final IotMqttConnectionManager connectionManager; + + public IotMqttDownstreamHandler(IotDeviceMessageService deviceMessageService, + IotMqttConnectionManager connectionManager) { + this.deviceMessageService = deviceMessageService; + this.connectionManager = connectionManager; + } + + /** + * 处理下行消息 + * + * @param message 设备消息 + * @return 是否处理成功 + */ + public boolean handleDownstreamMessage(IotDeviceMessage message) { + try { + // 1. 基础校验 + if (message == null || message.getDeviceId() == null) { + log.warn("[handleDownstreamMessage][消息或设备 ID 为空,忽略处理]"); + return false; + } + + // 2. 检查设备是否在线 + if (connectionManager.isDeviceOffline(message.getDeviceId())) { + log.warn("[handleDownstreamMessage][设备离线,无法发送消息,设备 ID:{}]", message.getDeviceId()); + return false; + } + + // 3. 获取连接信息 + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId(message.getDeviceId()); + if (connectionInfo == null) { + log.warn("[handleDownstreamMessage][连接信息不存在,设备 ID:{}]", message.getDeviceId()); + return false; + } + + // 4. 编码消息 + byte[] payload = deviceMessageService.encodeDeviceMessage(message, connectionInfo.getProductKey(), + connectionInfo.getDeviceName()); + if (payload == null || payload.length == 0) { + log.warn("[handleDownstreamMessage][消息编码失败,设备 ID:{}]", message.getDeviceId()); + return false; + } + + // 5. 发送消息到设备 + return sendMessageToDevice(message, connectionInfo, payload); + } catch (Exception e) { + if (message != null) { + log.error("[handleDownstreamMessage][处理下行消息异常,设备 ID:{},错误:{}]", + message.getDeviceId(), e.getMessage(), e); + } + return false; + } + } + + /** + * 发送消息到设备 + * + * @param message 设备消息 + * @param connectionInfo 连接信息 + * @param payload 消息负载 + * @return 是否发送成功 + */ + private boolean sendMessageToDevice(IotDeviceMessage message, + IotMqttConnectionManager.ConnectionInfo connectionInfo, + byte[] payload) { + // 1. 构建主题 + String topic = buildDownstreamTopic(message, connectionInfo); + if (StrUtil.isBlank(topic)) { + log.warn("[sendMessageToDevice][主题构建失败,设备 ID:{},方法:{}]", + message.getDeviceId(), message.getMethod()); + return false; + } + + // 2. 发送消息 + boolean success = connectionManager.sendToDevice(message.getDeviceId(), topic, payload, MqttQoS.AT_LEAST_ONCE.value(), false); + if (success) { + log.debug("[sendMessageToDevice][消息发送成功,设备 ID:{},主题:{},方法:{}]", + message.getDeviceId(), topic, message.getMethod()); + } else { + log.warn("[sendMessageToDevice][消息发送失败,设备 ID:{},主题:{},方法:{}]", + message.getDeviceId(), topic, message.getMethod()); + } + return success; + } + + /** + * 构建下行消息主题 + * + * @param message 设备消息 + * @param connectionInfo 连接信息 + * @return 主题 + */ + private String buildDownstreamTopic(IotDeviceMessage message, + IotMqttConnectionManager.ConnectionInfo connectionInfo) { + String method = message.getMethod(); + if (StrUtil.isBlank(method)) { + return null; + } + + // 使用工具类构建主题,支持回复消息处理 + boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); + return IotMqttTopicUtils.buildTopicByMethod(method, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), isReply); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java new file mode 100644 index 0000000000..c19053f144 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java @@ -0,0 +1,305 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router; + +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.mqtt.MqttEndpoint; +import io.vertx.mqtt.MqttTopicSubscription; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +/** + * MQTT 上行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMqttUpstreamHandler { + + private final IotDeviceMessageService deviceMessageService; + + private final IotMqttConnectionManager connectionManager; + + private final IotDeviceCommonApi deviceApi; + + private final String serverId; + + public IotMqttUpstreamHandler(IotMqttUpstreamProtocol protocol, + IotDeviceMessageService deviceMessageService, + IotMqttConnectionManager connectionManager) { + this.deviceMessageService = deviceMessageService; + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.connectionManager = connectionManager; + this.serverId = protocol.getServerId(); + } + + /** + * 处理 MQTT 连接 + * + * @param endpoint MQTT 连接端点 + */ + public void handle(MqttEndpoint endpoint) { + String clientId = endpoint.clientIdentifier(); + String username = endpoint.auth() != null ? endpoint.auth().getUsername() : null; + String password = endpoint.auth() != null ? endpoint.auth().getPassword() : null; + + log.debug("[handle][设备连接请求,客户端 ID: {},用户名: {},地址: {}]", + clientId, username, connectionManager.getEndpointAddress(endpoint)); + + // 1. 先进行认证 + if (!authenticateDevice(clientId, username, password, endpoint)) { + log.warn("[handle][设备认证失败,拒绝连接,客户端 ID: {},用户名: {}]", clientId, username); + endpoint.reject(MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD); + return; + } + + log.info("[handle][设备认证成功,建立连接,客户端 ID: {},用户名: {}]", clientId, username); + + // 2. 设置异常和关闭处理器 + endpoint.exceptionHandler(ex -> { + log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, connectionManager.getEndpointAddress(endpoint)); + cleanupConnection(endpoint); + }); + endpoint.closeHandler(v -> { + cleanupConnection(endpoint); + }); + + // 3. 设置消息处理器 + endpoint.publishHandler(message -> { + try { + processMessage(clientId, message.topicName(), message.payload().getBytes()); + + // 根据 QoS 级别发送相应的确认消息 + if (message.qosLevel() == MqttQoS.AT_LEAST_ONCE) { + // QoS 1: 发送 PUBACK 确认 + endpoint.publishAcknowledge(message.messageId()); + } else if (message.qosLevel() == MqttQoS.EXACTLY_ONCE) { + // QoS 2: 发送 PUBREC 确认 + endpoint.publishReceived(message.messageId()); + } + // QoS 0 无需确认 + + } catch (Exception e) { + log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]", + clientId, connectionManager.getEndpointAddress(endpoint), e.getMessage()); + cleanupConnection(endpoint); + endpoint.close(); + } + }); + + // 4. 设置订阅处理器 + endpoint.subscribeHandler(subscribe -> { + // 提取主题名称列表用于日志显示 + List topicNames = subscribe.topicSubscriptions().stream() + .map(MqttTopicSubscription::topicName) + .collect(java.util.stream.Collectors.toList()); + log.debug("[handle][设备订阅,客户端 ID: {},主题: {}]", clientId, topicNames); + + // 提取 QoS 列表 + List grantedQoSLevels = subscribe.topicSubscriptions().stream() + .map(MqttTopicSubscription::qualityOfService) + .collect(java.util.stream.Collectors.toList()); + endpoint.subscribeAcknowledge(subscribe.messageId(), grantedQoSLevels); + }); + + // 5. 设置取消订阅处理器 + endpoint.unsubscribeHandler(unsubscribe -> { + log.debug("[handle][设备取消订阅,客户端 ID: {},主题: {}]", clientId, unsubscribe.topics()); + endpoint.unsubscribeAcknowledge(unsubscribe.messageId()); + }); + + // 6. 设置 QoS 2消息的 PUBREL 处理器 + endpoint.publishReleaseHandler(endpoint::publishComplete); + + // 7. 设置断开连接处理器 + endpoint.disconnectHandler(v -> { + log.debug("[handle][设备断开连接,客户端 ID: {}]", clientId); + cleanupConnection(endpoint); + }); + + // 8. 接受连接 + endpoint.accept(false); + } + + /** + * 处理消息 + * + * @param clientId 客户端 ID + * @param topic 主题 + * @param payload 消息内容 + */ + private void processMessage(String clientId, String topic, byte[] payload) { + // 1. 基础检查 + if (payload == null || payload.length == 0) { + return; + } + + // 2. 解析主题,获取 productKey 和 deviceName + String[] topicParts = topic.split("/"); + if (topicParts.length < 4 || StrUtil.hasBlank(topicParts[2], topicParts[3])) { + log.warn("[processMessage][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic); + return; + } + + String productKey = topicParts[2]; + String deviceName = topicParts[3]; + + // 3. 解码消息(使用从 topic 解析的 productKey 和 deviceName) + try { + IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName); + if (message == null) { + log.warn("[processMessage][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); + return; + } + + log.info("[processMessage][收到设备消息,设备: {}.{}, 方法: {}]", + productKey, deviceName, message.getMethod()); + + // 4. 处理业务消息(认证已在连接时完成) + handleBusinessRequest(message, productKey, deviceName); + } catch (Exception e) { + log.error("[processMessage][消息处理异常,客户端 ID: {},主题: {},错误: {}]", + clientId, topic, e.getMessage(), e); + } + } + + /** + * 在 MQTT 连接时进行设备认证 + * + * @param clientId 客户端 ID + * @param username 用户名 + * @param password 密码 + * @param endpoint MQTT 连接端点 + * @return 认证是否成功 + */ + private boolean authenticateDevice(String clientId, String username, String password, MqttEndpoint endpoint) { + try { + // 1. 参数校验 + if (StrUtil.hasEmpty(clientId, username, password)) { + log.warn("[authenticateDevice][认证参数不完整,客户端 ID: {},用户名: {}]", clientId, username); + return false; + } + + // 2. 构建认证参数 + IotDeviceAuthReqDTO authParams = new IotDeviceAuthReqDTO() + .setClientId(clientId) + .setUsername(username) + .setPassword(password); + + // 3. 调用设备认证 API + CommonResult authResult = deviceApi.authDevice(authParams); + if (!authResult.isSuccess() || !BooleanUtil.isTrue(authResult.getData())) { + log.warn("[authenticateDevice][设备认证失败,客户端 ID: {},用户名: {},错误: {}]", + clientId, username, authResult.getMsg()); + return false; + } + + // 4. 获取设备信息 + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); + if (deviceInfo == null) { + log.warn("[authenticateDevice][用户名格式不正确,客户端 ID: {},用户名: {}]", clientId, username); + return false; + } + + IotDeviceGetReqDTO getReqDTO = new IotDeviceGetReqDTO() + .setProductKey(deviceInfo.getProductKey()) + .setDeviceName(deviceInfo.getDeviceName()); + + CommonResult deviceResult = deviceApi.getDevice(getReqDTO); + if (!deviceResult.isSuccess() || deviceResult.getData() == null) { + log.warn("[authenticateDevice][获取设备信息失败,客户端 ID: {},用户名: {},错误: {}]", + clientId, username, deviceResult.getMsg()); + return false; + } + + // 5. 注册连接 + IotDeviceRespDTO device = deviceResult.getData(); + registerConnection(endpoint, device, clientId); + + // 6. 发送设备上线消息 + sendOnlineMessage(device); + + return true; + } catch (Exception e) { + log.error("[authenticateDevice][设备认证异常,客户端 ID: {},用户名: {}]", clientId, username, e); + return false; + } + } + + /** + * 处理业务请求 + */ + private void handleBusinessRequest(IotDeviceMessage message, String productKey, String deviceName) { + // 发送消息到消息总线 + message.setServerId(serverId); + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + } + + /** + * 注册连接 + */ + private void registerConnection(MqttEndpoint endpoint, IotDeviceRespDTO device, + String clientId) { + + IotMqttConnectionManager.ConnectionInfo connectionInfo = new IotMqttConnectionManager.ConnectionInfo() + .setDeviceId(device.getId()) + .setProductKey(device.getProductKey()) + .setDeviceName(device.getDeviceName()) + .setClientId(clientId) + .setAuthenticated(true) + .setRemoteAddress(connectionManager.getEndpointAddress(endpoint)); + + connectionManager.registerConnection(endpoint, device.getId(), connectionInfo); + } + + /** + * 发送设备上线消息 + */ + private void sendOnlineMessage(IotDeviceRespDTO device) { + try { + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), + device.getDeviceName(), serverId); + log.info("[sendOnlineMessage][设备上线,设备 ID: {},设备名称: {}]", device.getId(), device.getDeviceName()); + } catch (Exception e) { + log.error("[sendOnlineMessage][发送设备上线消息失败,设备 ID: {},错误: {}]", device.getId(), e.getMessage()); + } + } + + /** + * 清理连接 + */ + private void cleanupConnection(MqttEndpoint endpoint) { + try { + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); + if (connectionInfo != null) { + // 发送设备离线消息 + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + log.info("[cleanupConnection][设备离线,设备 ID: {},设备名称: {}]", + connectionInfo.getDeviceId(), connectionInfo.getDeviceName()); + } + + // 注销连接 + connectionManager.unregisterConnection(endpoint); + } catch (Exception e) { + log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", + endpoint.clientIdentifier(), e.getMessage()); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/package-info.java new file mode 100644 index 0000000000..6eb414ee9f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/package-info.java @@ -0,0 +1,4 @@ +/** + * 提供设备接入的各种协议的实现 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java new file mode 100644 index 0000000000..e4d46b3af6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router.IotTcpDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 TCP 下游订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class IotTcpDownstreamSubscriber implements IotMessageSubscriber { + + private final IotTcpUpstreamProtocol protocol; + + private final IotDeviceMessageService messageService; + + private final IotDeviceService deviceService; + + private final IotTcpConnectionManager connectionManager; + + private final IotMessageBus messageBus; + + private IotTcpDownstreamHandler downstreamHandler; + + @PostConstruct + public void init() { + // 初始化下游处理器 + this.downstreamHandler = new IotTcpDownstreamHandler(messageService, deviceService, connectionManager); + + messageBus.register(this); + log.info("[init][TCP 下游订阅者初始化完成,服务器 ID: {},Topic: {}]", + protocol.getServerId(), getTopic()); + } + + @Override + public String getTopic() { + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); + } + + @Override + public String getGroup() { + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group + return getTopic(); + } + + @Override + public void onMessage(IotDeviceMessage message) { + try { + downstreamHandler.handle(message); + } catch (Exception e) { + log.error("[onMessage][处理下行消息失败,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId(), e); + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java new file mode 100644 index 0000000000..791c6cbfc2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; + +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router.IotTcpUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Vertx; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetServerOptions; +import io.vertx.core.net.PemKeyCertOptions; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 TCP 协议:接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpUpstreamProtocol { + + private final IotGatewayProperties.TcpProperties tcpProperties; + + private final IotDeviceService deviceService; + + private final IotDeviceMessageService messageService; + + private final IotTcpConnectionManager connectionManager; + + private final Vertx vertx; + + @Getter + private final String serverId; + + private NetServer tcpServer; + + public IotTcpUpstreamProtocol(IotGatewayProperties.TcpProperties tcpProperties, + IotDeviceService deviceService, + IotDeviceMessageService messageService, + IotTcpConnectionManager connectionManager, + Vertx vertx) { + this.tcpProperties = tcpProperties; + this.deviceService = deviceService; + this.messageService = messageService; + this.connectionManager = connectionManager; + this.vertx = vertx; + this.serverId = IotDeviceMessageUtils.generateServerId(tcpProperties.getPort()); + } + + @PostConstruct + public void start() { + // 创建服务器选项 + NetServerOptions options = new NetServerOptions() + .setPort(tcpProperties.getPort()) + .setTcpKeepAlive(true) + .setTcpNoDelay(true) + .setReuseAddress(true); + // 配置 SSL(如果启用) + if (Boolean.TRUE.equals(tcpProperties.getSslEnabled())) { + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() + .setKeyPath(tcpProperties.getSslKeyPath()) + .setCertPath(tcpProperties.getSslCertPath()); + options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); + } + + // 创建服务器并设置连接处理器 + tcpServer = vertx.createNetServer(options); + tcpServer.connectHandler(socket -> { + IotTcpUpstreamHandler handler = new IotTcpUpstreamHandler(this, messageService, deviceService, + connectionManager); + handler.handle(socket); + }); + + // 启动服务器 + try { + tcpServer.listen().result(); + log.info("[start][IoT 网关 TCP 协议启动成功,端口:{}]", tcpProperties.getPort()); + } catch (Exception e) { + log.error("[start][IoT 网关 TCP 协议启动失败]", e); + throw e; + } + } + + @PreDestroy + public void stop() { + if (tcpServer != null) { + try { + tcpServer.close().result(); + log.info("[stop][IoT 网关 TCP 协议已停止]"); + } catch (Exception e) { + log.error("[stop][IoT 网关 TCP 协议停止失败]", e); + } + } + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java new file mode 100644 index 0000000000..c0d209814e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager; + +import io.vertx.core.net.NetSocket; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * IoT 网关 TCP 连接管理器 + *

+ * 统一管理 TCP 连接的认证状态、设备会话和消息发送功能: + * 1. 管理 TCP 连接的认证状态 + * 2. 管理设备会话和在线状态 + * 3. 管理消息发送到设备 + * + * @author 芋道源码 + */ +@Slf4j +@Component +public class IotTcpConnectionManager { + + /** + * 连接信息映射:NetSocket -> 连接信息 + */ + private final Map connectionMap = new ConcurrentHashMap<>(); + + /** + * 设备 ID -> NetSocket 的映射 + */ + private final Map deviceSocketMap = new ConcurrentHashMap<>(); + + /** + * 注册设备连接(包含认证信息) + * + * @param socket TCP 连接 + * @param deviceId 设备 ID + * @param connectionInfo 连接信息 + */ + public void registerConnection(NetSocket socket, Long deviceId, ConnectionInfo connectionInfo) { + // 如果设备已有其他连接,先清理旧连接 + NetSocket oldSocket = deviceSocketMap.get(deviceId); + if (oldSocket != null && oldSocket != socket) { + log.info("[registerConnection][设备已有其他连接,断开旧连接,设备 ID: {},旧连接: {}]", + deviceId, oldSocket.remoteAddress()); + oldSocket.close(); + // 清理旧连接的映射 + connectionMap.remove(oldSocket); + } + + connectionMap.put(socket, connectionInfo); + deviceSocketMap.put(deviceId, socket); + + log.info("[registerConnection][注册设备连接,设备 ID: {},连接: {},product key: {},device name: {}]", + deviceId, socket.remoteAddress(), connectionInfo.getProductKey(), connectionInfo.getDeviceName()); + } + + /** + * 注销设备连接 + * + * @param socket TCP 连接 + */ + public void unregisterConnection(NetSocket socket) { + ConnectionInfo connectionInfo = connectionMap.remove(socket); + if (connectionInfo != null) { + Long deviceId = connectionInfo.getDeviceId(); + deviceSocketMap.remove(deviceId); + log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", + deviceId, socket.remoteAddress()); + } + } + + /** + * 检查连接是否已认证 + */ + public boolean isAuthenticated(NetSocket socket) { + ConnectionInfo info = connectionMap.get(socket); + return info != null && info.isAuthenticated(); + } + + /** + * 检查连接是否未认证 + */ + public boolean isNotAuthenticated(NetSocket socket) { + return !isAuthenticated(socket); + } + + /** + * 获取连接信息 + */ + public ConnectionInfo getConnectionInfo(NetSocket socket) { + return connectionMap.get(socket); + } + + /** + * 检查设备是否在线 + */ + public boolean isDeviceOnline(Long deviceId) { + return deviceSocketMap.containsKey(deviceId); + } + + /** + * 检查设备是否离线 + */ + public boolean isDeviceOffline(Long deviceId) { + return !isDeviceOnline(deviceId); + } + + /** + * 发送消息到设备 + */ + public boolean sendToDevice(Long deviceId, byte[] data) { + NetSocket socket = deviceSocketMap.get(deviceId); + if (socket == null) { + log.warn("[sendToDevice][设备未连接,设备 ID: {}]", deviceId); + return false; + } + + try { + socket.write(io.vertx.core.buffer.Buffer.buffer(data)); + log.debug("[sendToDevice][发送消息成功,设备 ID: {},数据长度: {} 字节]", deviceId, data.length); + return true; + } catch (Exception e) { + log.error("[sendToDevice][发送消息失败,设备 ID: {}]", deviceId, e); + // 发送失败时清理连接 + unregisterConnection(socket); + return false; + } + } + + /** + * 连接信息(包含认证信息) + */ + @Data + public static class ConnectionInfo { + + /** + * 设备 ID + */ + private Long deviceId; + /** + * 产品 Key + */ + private String productKey; + /** + * 设备名称 + */ + private String deviceName; + + /** + * 客户端 ID + */ + private String clientId; + /** + * 消息编解码类型(认证后确定) + */ + private String codecType; + // TODO @haohao:有没可能不要 authenticated 字段,通过 deviceId 或者其他的?进一步简化,想的是哈。 + /** + * 是否已认证 + */ + private boolean authenticated; + + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java new file mode 100644 index 0000000000..3ee31d82e4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router; + +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 TCP 下行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class IotTcpDownstreamHandler { + + private final IotDeviceMessageService deviceMessageService; + + private final IotDeviceService deviceService; + + private final IotTcpConnectionManager connectionManager; + + /** + * 处理下行消息 + */ + public void handle(IotDeviceMessage message) { + try { + log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + + // 1.1 获取设备信息 + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); + if (deviceInfo == null) { + log.error("[handle][设备不存在,设备 ID: {}]", message.getDeviceId()); + return; + } + // 1.2 检查设备是否在线 + if (connectionManager.isDeviceOffline(message.getDeviceId())) { + log.warn("[handle][设备不在线,设备 ID: {}]", message.getDeviceId()); + return; + } + + // 2. 根据产品 Key 和设备名称编码消息并发送到设备 + byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), + deviceInfo.getDeviceName()); + boolean success = connectionManager.sendToDevice(message.getDeviceId(), bytes); + if (success) { + log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]", + message.getDeviceId(), message.getMethod(), message.getId(), bytes.length); + } else { + log.error("[handle][下行消息发送失败,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + } + } catch (Exception e) { + log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]", + message.getDeviceId(), message.getMethod(), message, e); + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java new file mode 100644 index 0000000000..0aff8f72f2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java @@ -0,0 +1,408 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec; +import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import lombok.extern.slf4j.Slf4j; + +/** + * TCP 上行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpUpstreamHandler implements Handler { + + private static final String CODEC_TYPE_JSON = IotTcpJsonDeviceMessageCodec.TYPE; + private static final String CODEC_TYPE_BINARY = IotTcpBinaryDeviceMessageCodec.TYPE; + + private static final String AUTH_METHOD = "auth"; + + private final IotDeviceMessageService deviceMessageService; + + private final IotDeviceService deviceService; + + private final IotTcpConnectionManager connectionManager; + + private final IotDeviceCommonApi deviceApi; + + private final String serverId; + + public IotTcpUpstreamHandler(IotTcpUpstreamProtocol protocol, + IotDeviceMessageService deviceMessageService, + IotDeviceService deviceService, + IotTcpConnectionManager connectionManager) { + this.deviceMessageService = deviceMessageService; + this.deviceService = deviceService; + this.connectionManager = connectionManager; + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.serverId = protocol.getServerId(); + } + + @Override + public void handle(NetSocket socket) { + String clientId = IdUtil.simpleUUID(); + log.debug("[handle][设备连接,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); + + // 设置异常和关闭处理器 + socket.exceptionHandler(ex -> { + log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); + cleanupConnection(socket); + }); + socket.closeHandler(v -> { + log.debug("[handle][连接关闭,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); + cleanupConnection(socket); + }); + + // 设置消息处理器 + socket.handler(buffer -> { + try { + processMessage(clientId, buffer, socket); + } catch (Exception e) { + log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]", + clientId, socket.remoteAddress(), e.getMessage()); + cleanupConnection(socket); + socket.close(); + } + }); + } + + /** + * 处理消息 + * + * @param clientId 客户端 ID + * @param buffer 消息 + * @param socket 网络连接 + * @throws Exception 消息解码失败时抛出异常 + */ + private void processMessage(String clientId, Buffer buffer, NetSocket socket) throws Exception { + // 1. 基础检查 + if (buffer == null || buffer.length() == 0) { + return; + } + + // 2. 获取消息格式类型 + String codecType = getMessageCodecType(buffer, socket); + + // 3. 解码消息 + IotDeviceMessage message; + try { + message = deviceMessageService.decodeDeviceMessage(buffer.getBytes(), codecType); + if (message == null) { + throw new Exception("解码后消息为空"); + } + } catch (Exception e) { + // 消息格式错误时抛出异常,由上层处理连接断开 + throw new Exception("消息解码失败: " + e.getMessage(), e); + } + + // 4. 根据消息类型路由处理 + try { + if (AUTH_METHOD.equals(message.getMethod())) { + // 认证请求 + handleAuthenticationRequest(clientId, message, codecType, socket); + } else { + // 业务消息 + handleBusinessRequest(clientId, message, codecType, socket); + } + } catch (Exception e) { + log.error("[processMessage][处理消息失败,客户端 ID: {},消息方法: {}]", + clientId, message.getMethod(), e); + // 发送错误响应,避免客户端一直等待 + try { + sendErrorResponse(socket, message.getRequestId(), "消息处理失败", codecType); + } catch (Exception responseEx) { + log.error("[processMessage][发送错误响应失败,客户端 ID: {}]", clientId, responseEx); + } + } + } + + /** + * 处理认证请求 + * + * @param clientId 客户端 ID + * @param message 消息信息 + * @param codecType 消息编解码类型 + * @param socket 网络连接 + */ + private void handleAuthenticationRequest(String clientId, IotDeviceMessage message, String codecType, + NetSocket socket) { + try { + // 1.1 解析认证参数 + IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams()); + if (authParams == null) { + log.warn("[handleAuthenticationRequest][认证参数解析失败,客户端 ID: {}]", clientId); + sendErrorResponse(socket, message.getRequestId(), "认证参数不完整", codecType); + return; + } + // 1.2 执行认证 + if (!validateDeviceAuth(authParams)) { + log.warn("[handleAuthenticationRequest][认证失败,客户端 ID: {},username: {}]", + clientId, authParams.getUsername()); + sendErrorResponse(socket, message.getRequestId(), "认证失败", codecType); + return; + } + + // 2.1 解析设备信息 + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); + if (deviceInfo == null) { + sendErrorResponse(socket, message.getRequestId(), "解析设备信息失败", codecType); + return; + } + // 2.2 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), + deviceInfo.getDeviceName()); + if (device == null) { + sendErrorResponse(socket, message.getRequestId(), "设备不存在", codecType); + return; + } + + // 3.1 注册连接 + registerConnection(socket, device, clientId, codecType); + // 3.2 发送上线消息 + sendOnlineMessage(device); + // 3.3 发送成功响应 + sendSuccessResponse(socket, message.getRequestId(), "认证成功", codecType); + log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]", + device.getId(), device.getDeviceName()); + } catch (Exception e) { + log.error("[handleAuthenticationRequest][认证处理异常,客户端 ID: {}]", clientId, e); + sendErrorResponse(socket, message.getRequestId(), "认证处理异常", codecType); + } + } + + /** + * 处理业务请求 + * + * @param clientId 客户端 ID + * @param message 消息信息 + * @param codecType 消息编解码类型 + * @param socket 网络连接 + */ + private void handleBusinessRequest(String clientId, IotDeviceMessage message, String codecType, NetSocket socket) { + try { + // 1. 检查认证状态 + if (connectionManager.isNotAuthenticated(socket)) { + log.warn("[handleBusinessRequest][设备未认证,客户端 ID: {}]", clientId); + sendErrorResponse(socket, message.getRequestId(), "请先进行认证", codecType); + return; + } + + // 2. 获取认证信息并处理业务消息 + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + + // 3. 发送消息到消息总线 + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + log.info("[handleBusinessRequest][发送消息到消息总线,客户端 ID: {},消息: {}", + clientId, message.toString()); + } catch (Exception e) { + log.error("[handleBusinessRequest][业务请求处理异常,客户端 ID: {}]", clientId, e); + } + } + + /** + * 获取消息编解码类型 + * + * @param buffer 消息 + * @param socket 网络连接 + * @return 消息编解码类型 + */ + private String getMessageCodecType(Buffer buffer, NetSocket socket) { + // 1. 如果已认证,优先使用缓存的编解码类型 + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo != null && connectionInfo.isAuthenticated() && + StrUtil.isNotBlank(connectionInfo.getCodecType())) { + return connectionInfo.getCodecType(); + } + + // 2. 未认证时检测消息格式类型 + return IotTcpBinaryDeviceMessageCodec.isBinaryFormatQuick(buffer.getBytes()) ? CODEC_TYPE_BINARY + : CODEC_TYPE_JSON; + } + + /** + * 注册连接信息 + * + * @param socket 网络连接 + * @param device 设备 + * @param clientId 客户端 ID + * @param codecType 消息编解码类型 + */ + private void registerConnection(NetSocket socket, IotDeviceRespDTO device, + String clientId, String codecType) { + IotTcpConnectionManager.ConnectionInfo connectionInfo = new IotTcpConnectionManager.ConnectionInfo() + .setDeviceId(device.getId()) + .setProductKey(device.getProductKey()) + .setDeviceName(device.getDeviceName()) + .setClientId(clientId) + .setCodecType(codecType) + .setAuthenticated(true); + // 注册连接 + connectionManager.registerConnection(socket, device.getId(), connectionInfo); + } + + /** + * 发送设备上线消息 + * + * @param device 设备信息 + */ + private void sendOnlineMessage(IotDeviceRespDTO device) { + try { + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), + device.getDeviceName(), serverId); + } catch (Exception e) { + log.error("[sendOnlineMessage][发送上线消息失败,设备: {}]", device.getDeviceName(), e); + } + } + + /** + * 清理连接 + * + * @param socket 网络连接 + */ + private void cleanupConnection(NetSocket socket) { + try { + // 1. 发送离线消息(如果已认证) + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo != null) { + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + } + + // 2. 注销连接 + connectionManager.unregisterConnection(socket); + } catch (Exception e) { + log.error("[cleanupConnection][清理连接失败]", e); + } + } + + /** + * 发送响应消息 + * + * @param socket 网络连接 + * @param success 是否成功 + * @param message 消息 + * @param requestId 请求 ID + * @param codecType 消息编解码类型 + */ + private void sendResponse(NetSocket socket, boolean success, String message, String requestId, String codecType) { + try { + Object responseData = MapUtil.builder() + .put("success", success) + .put("message", message) + .build(); + + int code = success ? 0 : 401; + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, AUTH_METHOD, responseData, + code, message); + + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); + socket.write(Buffer.buffer(encodedData)); + + } catch (Exception e) { + log.error("[sendResponse][发送响应失败,requestId: {}]", requestId, e); + } + } + + /** + * 验证设备认证信息 + * + * @param authParams 认证参数 + * @return 是否认证成功 + */ + private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) { + try { + CommonResult result = deviceApi.authDevice(new IotDeviceAuthReqDTO() + .setClientId(authParams.getClientId()).setUsername(authParams.getUsername()) + .setPassword(authParams.getPassword())); + result.checkError(); + return BooleanUtil.isTrue(result.getData()); + } catch (Exception e) { + log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e); + return false; + } + } + + /** + * 发送错误响应 + * + * @param socket 网络连接 + * @param requestId 请求 ID + * @param errorMessage 错误消息 + * @param codecType 消息编解码类型 + */ + private void sendErrorResponse(NetSocket socket, String requestId, String errorMessage, String codecType) { + sendResponse(socket, false, errorMessage, requestId, codecType); + } + + /** + * 发送成功响应 + * + * @param socket 网络连接 + * @param requestId 请求 ID + * @param message 消息 + * @param codecType 消息编解码类型 + */ + @SuppressWarnings("SameParameterValue") + private void sendSuccessResponse(NetSocket socket, String requestId, String message, String codecType) { + sendResponse(socket, true, message, requestId, codecType); + } + + /** + * 解析认证参数 + * + * @param params 参数对象(通常为 Map 类型) + * @return 认证参数 DTO,解析失败时返回 null + */ + @SuppressWarnings("unchecked") + private IotDeviceAuthReqDTO parseAuthParams(Object params) { + if (params == null) { + return null; + } + + try { + // 参数默认为 Map 类型,直接转换 + if (params instanceof java.util.Map) { + java.util.Map paramMap = (java.util.Map) params; + return new IotDeviceAuthReqDTO() + .setClientId(MapUtil.getStr(paramMap, "clientId")) + .setUsername(MapUtil.getStr(paramMap, "username")) + .setPassword(MapUtil.getStr(paramMap, "password")); + } + + // 如果已经是目标类型,直接返回 + if (params instanceof IotDeviceAuthReqDTO) { + return (IotDeviceAuthReqDTO) params; + } + + // 其他情况尝试 JSON 转换 + String jsonStr = JsonUtils.toJsonString(params); + return JsonUtils.parseObject(jsonStr, IotDeviceAuthReqDTO.class); + } catch (Exception e) { + log.error("[parseAuthParams][解析认证参数({})失败]", params, e); + return null; + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java new file mode 100644 index 0000000000..9aab67236b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.iot.gateway.service.auth; + +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; + +/** + * IoT 设备 Token Service 接口 + * + * @author 芋道源码 + */ +public interface IotDeviceTokenService { + + /** + * 创建设备 Token + * + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @return 设备 Token + */ + String createToken(String productKey, String deviceName); + + /** + * 验证设备 Token + * + * @param token 设备 Token + * @return 设备信息 + */ + IotDeviceAuthUtils.DeviceInfo verifyToken(String token); + + /** + * 解析用户名 + * + * @param username 用户名 + * @return 设备信息 + */ + IotDeviceAuthUtils.DeviceInfo parseUsername(String username); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java new file mode 100644 index 0000000000..79ba4e77e7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.gateway.service.auth; + +import cn.hutool.core.lang.Assert; +import cn.hutool.json.JSONObject; +import cn.hutool.jwt.JWT; +import cn.hutool.jwt.JWTUtil; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_TOKEN_EXPIRED; + +/** + * IoT 设备 Token Service 实现类:调用远程的 device http 接口,进行设备 Token 生成、解析等逻辑 + * + * 注意:目前仅 HTTP 协议使用 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class IotDeviceTokenServiceImpl implements IotDeviceTokenService { + + @Resource + private IotGatewayProperties gatewayProperties; + + @Override + public String createToken(String productKey, String deviceName) { + Assert.notBlank(productKey, "productKey 不能为空"); + Assert.notBlank(deviceName, "deviceName 不能为空"); + // 构建 JWT payload + Map payload = new HashMap<>(); + payload.put("productKey", productKey); + payload.put("deviceName", deviceName); + LocalDateTime expireTime = LocalDateTimeUtils.addTime(gatewayProperties.getToken().getExpiration()); + payload.put("exp", LocalDateTimeUtils.toEpochSecond(expireTime)); // 过期时间(exp 是 JWT 规范推荐) + + // 生成 JWT Token + return JWTUtil.createToken(payload, gatewayProperties.getToken().getSecret().getBytes()); + } + + @Override + public IotDeviceAuthUtils.DeviceInfo verifyToken(String token) { + Assert.notBlank(token, "token 不能为空"); + // 校验 JWT Token + boolean verify = JWTUtil.verify(token, gatewayProperties.getToken().getSecret().getBytes()); + if (!verify) { + throw exception(DEVICE_TOKEN_EXPIRED); + } + + // 解析 Token + JWT jwt = JWTUtil.parseToken(token); + JSONObject payload = jwt.getPayloads(); + // 检查过期时间 + Long exp = payload.getLong("exp"); + if (exp == null || exp < System.currentTimeMillis() / 1000) { + throw exception(DEVICE_TOKEN_EXPIRED); + } + String productKey = payload.getStr("productKey"); + String deviceName = payload.getStr("deviceName"); + Assert.notBlank(productKey, "productKey 不能为空"); + Assert.notBlank(deviceName, "deviceName 不能为空"); + return new IotDeviceAuthUtils.DeviceInfo().setProductKey(productKey).setDeviceName(deviceName); + } + + @Override + public IotDeviceAuthUtils.DeviceInfo parseUsername(String username) { + return IotDeviceAuthUtils.parseUsername(username); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/IotDeviceService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/IotDeviceService.java new file mode 100644 index 0000000000..c0d4943dab --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/IotDeviceService.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device; + +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; + +/** + * IoT 设备信息 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDeviceService { + + /** + * 根据 productKey 和 deviceName 获取设备信息 + * + * @param productKey 产品标识 + * @param deviceName 设备名称 + * @return 设备信息 + */ + IotDeviceRespDTO getDeviceFromCache(String productKey, String deviceName); + + /** + * 根据 id 获取设备信息 + * + * @param id 设备编号 + * @return 设备信息 + */ + IotDeviceRespDTO getDeviceFromCache(Long id); + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/IotDeviceServiceImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/IotDeviceServiceImpl.java new file mode 100644 index 0000000000..fee48d10ec --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/IotDeviceServiceImpl.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device; + +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +import static cn.iocoder.yudao.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; + +/** + * IoT 设备信息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class IotDeviceServiceImpl implements IotDeviceService { + + private static final Duration CACHE_EXPIRE = Duration.ofMinutes(1); + + /** + * 通过 id 查询设备的缓存 + */ + private final LoadingCache deviceCaches = buildAsyncReloadingCache( + CACHE_EXPIRE, + new CacheLoader<>() { + + @Override + public IotDeviceRespDTO load(Long id) { + CommonResult result = deviceApi.getDevice(new IotDeviceGetReqDTO().setId(id)); + IotDeviceRespDTO device = result.getCheckedData(); + Assert.notNull(device, "设备({}) 不能为空", id); + // 相互缓存 + deviceCaches2.put(new KeyValue<>(device.getProductKey(), device.getDeviceName()), device); + return device; + } + + }); + + /** + * 通过 productKey + deviceName 查询设备的缓存 + */ + private final LoadingCache, IotDeviceRespDTO> deviceCaches2 = buildAsyncReloadingCache( + CACHE_EXPIRE, + new CacheLoader<>() { + + @Override + public IotDeviceRespDTO load(KeyValue kv) { + CommonResult result = deviceApi.getDevice(new IotDeviceGetReqDTO() + .setProductKey(kv.getKey()).setDeviceName(kv.getValue())); + IotDeviceRespDTO device = result.getCheckedData(); + Assert.notNull(device, "设备({}/{}) 不能为空", kv.getKey(), kv.getValue()); + // 相互缓存 + deviceCaches.put(device.getId(), device); + return device; + } + }); + + @Resource + private IotDeviceCommonApi deviceApi; + + @Override + public IotDeviceRespDTO getDeviceFromCache(String productKey, String deviceName) { + return deviceCaches2.getUnchecked(new KeyValue<>(productKey, deviceName)); + } + + @Override + public IotDeviceRespDTO getDeviceFromCache(Long id) { + return deviceCaches.getUnchecked(id); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java new file mode 100644 index 0000000000..c86fc0983d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device.message; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; + +/** + * IoT 设备消息 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDeviceMessageService { + + /** + * 编码消息 + * + * @param message 消息 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @return 编码后的消息内容 + */ + byte[] encodeDeviceMessage(IotDeviceMessage message, + String productKey, String deviceName); + + /** + * 编码消息 + * + * @param message 消息 + * @param codecType 编解码器类型 + * @return 编码后的消息内容 + */ + byte[] encodeDeviceMessage(IotDeviceMessage message, + String codecType); + + /** + * 解码消息 + * + * @param bytes 消息内容 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @return 解码后的消息内容 + */ + IotDeviceMessage decodeDeviceMessage(byte[] bytes, + String productKey, String deviceName); + + /** + * 解码消息 + * + * @param bytes 消息内容 + * @param codecType 编解码器类型 + * @return 解码后的消息内容 + */ + IotDeviceMessage decodeDeviceMessage(byte[] bytes, String codecType); + + /** + * 发送消息 + * + * @param message 消息 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param serverId 设备连接的 serverId + */ + void sendDeviceMessage(IotDeviceMessage message, + String productKey, String deviceName, String serverId); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java new file mode 100644 index 0000000000..014da9a5df --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device.message; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_NOT_EXISTS; + +/** + * IoT 设备消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { + + /** + * 编解码器 + */ + private final Map codes; + + @Resource + private IotDeviceService deviceService; + + @Resource + private IotDeviceMessageProducer deviceMessageProducer; + + public IotDeviceMessageServiceImpl(List codes) { + this.codes = CollectionUtils.convertMap(codes, IotDeviceMessageCodec::type); + } + + @Override + public byte[] encodeDeviceMessage(IotDeviceMessage message, + String productKey, String deviceName) { + // 1.1 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); + if (device == null) { + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); + } + // 1.2 获取编解码器 + IotDeviceMessageCodec codec = codes.get(device.getCodecType()); + if (codec == null) { + throw new IllegalArgumentException(StrUtil.format("编解码器({}) 不存在", device.getCodecType())); + } + + // 2. 编码消息 + return codec.encode(message); + } + + @Override + public byte[] encodeDeviceMessage(IotDeviceMessage message, + String codecType) { + // 1. 获取编解码器 + IotDeviceMessageCodec codec = codes.get(codecType); + if (codec == null) { + throw new IllegalArgumentException(StrUtil.format("编解码器({}) 不存在", codecType)); + } + + // 2. 编码消息 + return codec.encode(message); + } + + @Override + public IotDeviceMessage decodeDeviceMessage(byte[] bytes, + String productKey, String deviceName) { + // 1.1 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); + if (device == null) { + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); + } + // 1.2 获取编解码器 + IotDeviceMessageCodec codec = codes.get(device.getCodecType()); + if (codec == null) { + throw new IllegalArgumentException(StrUtil.format("编解码器({}) 不存在", device.getCodecType())); + } + + // 2. 解码消息 + return codec.decode(bytes); + } + + @Override + public IotDeviceMessage decodeDeviceMessage(byte[] bytes, String codecType) { + // 1. 获取编解码器 + IotDeviceMessageCodec codec = codes.get(codecType); + if (codec == null) { + throw new IllegalArgumentException(StrUtil.format("编解码器({}) 不存在", codecType)); + } + + // 2. 解码消息 + return codec.decode(bytes); + } + + @Override + public void sendDeviceMessage(IotDeviceMessage message, + String productKey, String deviceName, String serverId) { + // 1. 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); + if (device == null) { + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); + } + + // 2. 发送消息 + appendDeviceMessage(message, device, serverId); + deviceMessageProducer.sendDeviceMessage(message); + } + + /** + * 补充消息的后端字段 + * + * @param message 消息 + * @param device 设备信息 + * @param serverId 设备连接的 serverId + */ + private void appendDeviceMessage(IotDeviceMessage message, + IotDeviceRespDTO device, String serverId) { + message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now()) + .setDeviceId(device.getId()).setTenantId(device.getTenantId()).setServerId(serverId); + // 特殊:如果设备没有指定 requestId,则使用 messageId + if (StrUtil.isEmpty(message.getRequestId())) { + message.setRequestId(message.getId()); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java new file mode 100644 index 0000000000..b325103743 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device.remote; + +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; + +/** + * Iot 设备信息 Service 实现类:调用远程的 device http 接口,进行设备认证、设备获取等 + * + * @author 芋道源码 + */ +@Service +@Slf4j +public class IotDeviceApiImpl implements IotDeviceCommonApi { + + @Resource + private IotGatewayProperties gatewayProperties; + + private RestTemplate restTemplate; + + @PostConstruct + public void init() { + IotGatewayProperties.RpcProperties rpc = gatewayProperties.getRpc(); + restTemplate = new RestTemplateBuilder() + .rootUri(rpc.getUrl() + "/rpc-api/iot/device") + .readTimeout(rpc.getReadTimeout()) + .connectTimeout(rpc.getConnectTimeout()) + .build(); + } + + @Override + public CommonResult authDevice(IotDeviceAuthReqDTO authReqDTO) { + return doPost("/auth", authReqDTO, new ParameterizedTypeReference<>() { }); + } + + @Override + public CommonResult getDevice(IotDeviceGetReqDTO getReqDTO) { + return doPost("/get", getReqDTO, new ParameterizedTypeReference<>() { }); + } + + private CommonResult doPost(String url, T body, + ParameterizedTypeReference> responseType) { + try { + // 请求 + HttpEntity requestEntity = new HttpEntity<>(body); + ResponseEntity> response = restTemplate.exchange( + url, HttpMethod.POST, requestEntity, responseType); + // 响应 + CommonResult result = response.getBody(); + Assert.notNull(result, "请求结果不能为空"); + return result; + } catch (Exception e) { + log.error("[doPost][url({}) body({}) 发生异常]", url, body, e); + return CommonResult.error(INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java new file mode 100644 index 0000000000..7f72937efb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.iot.gateway.util; + +import cn.hutool.core.util.StrUtil; + +/** + * IoT 网关 MQTT 主题工具类 + *

+ * 用于统一管理 MQTT 协议中的主题常量,基于 Alink 协议规范 + * + * @author 芋道源码 + */ +public final class IotMqttTopicUtils { + + // ========== 静态常量 ========== + + /** + * 系统主题前缀 + */ + private static final String SYS_TOPIC_PREFIX = "/sys/"; + + /** + * 回复主题后缀 + */ + private static final String REPLY_TOPIC_SUFFIX = "_reply"; + + // ========== MQTT HTTP 接口路径常量 ========== + + /** + * MQTT 认证接口路径 + * 对应 EMQX HTTP 认证插件的认证请求接口 + */ + public static final String MQTT_AUTH_PATH = "/mqtt/auth"; + + /** + * MQTT 统一事件处理接口路径 + * 对应 EMQX Webhook 的统一事件处理接口,支持所有客户端事件 + * 包括:client.connected、client.disconnected、message.publish 等 + */ + public static final String MQTT_EVENT_PATH = "/mqtt/event"; + + // ========== 工具方法 ========== + + /** + * 根据消息方法构建对应的主题 + * + * @param method 消息方法,例如 thing.property.post + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param isReply 是否为回复消息 + * @return 完整的主题路径 + */ + public static String buildTopicByMethod(String method, String productKey, String deviceName, boolean isReply) { + if (StrUtil.isBlank(method)) { + return null; + } + // 1. 将点分隔符转换为斜杠 + String topicSuffix = method.replace('.', '/'); + // 2. 对于回复消息,添加 _reply 后缀 + if (isReply) { + topicSuffix += REPLY_TOPIC_SUFFIX; + } + // 3. 构建完整主题 + return SYS_TOPIC_PREFIX + productKey + "/" + deviceName + "/" + topicSuffix; + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml new file mode 100644 index 0000000000..322748d46d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml @@ -0,0 +1,130 @@ +spring: + application: + name: iot-gateway-server + profiles: + active: local # 默认激活本地开发环境 + + # Redis 配置 + data: + redis: + host: 127.0.0.1 # Redis 服务器地址 + port: 6379 # Redis 服务器端口 + database: 0 # Redis 数据库索引 + # password: # Redis 密码,如果有的话 + timeout: 30000ms # 连接超时时间 + +--- #################### 消息队列相关 #################### + +# rocketmq 配置项,对应 RocketMQProperties 配置类 +rocketmq: + name-server: 127.0.0.1:9876 # RocketMQ Namesrv + # Producer 配置项 + producer: + group: ${spring.application.name}_PRODUCER # 生产者分组 + +--- #################### IoT 网关相关配置 #################### + +yudao: + iot: + # 消息总线配置 + message-bus: + type: redis # 消息总线的类型 + + # 网关配置 + gateway: + # 设备 RPC 配置 + rpc: + url: http://127.0.0.1:48080 # 主程序 API 地址 + connect-timeout: 30s + read-timeout: 30s + # 设备 Token 配置 + token: + secret: yudaoIotGatewayTokenSecret123456789 # Token 密钥,至少32位 + expiration: 7d + + # 协议配置 + protocol: + # ==================================== + # 针对引入的 HTTP 组件的配置 + # ==================================== + http: + enabled: true + server-port: 8092 + # ==================================== + # 针对引入的 EMQX 组件的配置 + # ==================================== + emqx: + enabled: false + http-port: 8090 # MQTT HTTP 服务端口 + mqtt-host: 127.0.0.1 # MQTT Broker 地址 + mqtt-port: 1883 # MQTT Broker 端口 + mqtt-username: admin # MQTT 用户名 + mqtt-password: public # MQTT 密码 + mqtt-client-id: iot-gateway-mqtt # MQTT 客户端 ID + mqtt-ssl: false # 是否开启 SSL + mqtt-topics: + - "/sys/#" # 系统主题 + clean-session: true # 是否启用 Clean Session (默认: true) + keep-alive-interval-seconds: 60 # 心跳间隔,单位秒 (默认: 60) + max-inflight-queue: 10000 # 最大飞行消息队列,单位:条 + connect-timeout-seconds: 10 # 连接超时,单位:秒 + # 是否信任所有 SSL 证书 (默认: false)。警告:生产环境必须为 false! + # 仅在开发环境或内网测试时,如果使用了自签名证书,可以临时设置为 true + trust-all: true # 在 dev 环境可以设为 true + # 遗嘱消息配置 (用于网关异常下线时通知其他系统) + will: + enabled: true # 生产环境强烈建议开启 + topic: "gateway/status/${yudao.iot.gateway.emqx.mqtt-client-id}" # 遗嘱消息主题 + payload: "offline" # 遗嘱消息负载 + qos: 1 # 遗嘱消息 QoS + retain: true # 遗嘱消息是否保留 + # 高级 SSL/TLS 配置 (当 trust-all: false 且 mqtt-ssl: true 时生效) + ssl-options: + key-store-path: "classpath:certs/client.jks" # 客户端证书库路径 + key-store-password: "your-keystore-password" # 客户端证书库密码 + trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径 + trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码 + # ==================================== + # 针对引入的 TCP 组件的配置 + # ==================================== + tcp: + enabled: false + port: 8091 + keep-alive-timeout-ms: 30000 + max-connections: 1000 + ssl-enabled: false + ssl-cert-path: "classpath:certs/client.jks" + ssl-key-path: "classpath:certs/client.jks" + # ==================================== + # 针对引入的 MQTT 组件的配置 + # ==================================== + mqtt: + enabled: true + port: 1883 + max-message-size: 8192 + connect-timeout-seconds: 60 + keep-alive-timeout-seconds: 300 + ssl-enabled: false + +--- #################### 日志相关配置 #################### + +# 基础日志配置 +logging: + file: + name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径 + level: + # 应用基础日志级别 + cn.iocoder.yudao.module.iot.gateway: INFO + org.springframework.boot: INFO + # RocketMQ 日志 + org.apache.rocketmq: WARN + # MQTT 客户端日志 + # io.vertx.mqtt: DEBUG + # 开发环境详细日志 + cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG + cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG + cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG + # 根日志级别 + root: INFO + +debug: false diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-binary-packet-examples.md b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-binary-packet-examples.md new file mode 100644 index 0000000000..d6b2b3fdb5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-binary-packet-examples.md @@ -0,0 +1,193 @@ +# TCP 二进制协议数据包格式说明 + +## 1. 协议概述 + +TCP 二进制协议是一种高效的自定义协议格式,采用紧凑的二进制格式传输数据,适用于对带宽和性能要求较高的 IoT 场景。 + +### 1.1 协议特点 + +- **高效传输**:完全二进制格式,减少数据传输量 +- **版本控制**:内置协议版本号,支持协议升级 +- **类型安全**:明确的消息类型标识 +- **简洁设计**:去除冗余字段,协议更加精简 +- **兼容性**:与现有 `IotDeviceMessage` 接口完全兼容 + +## 2. 协议格式 + +### 2.1 整体结构 + +``` ++--------+--------+--------+---------------------------+--------+--------+ +| 魔术字 | 版本号 | 消息类型| 消息长度(4字节) | ++--------+--------+--------+---------------------------+--------+--------+ +| 消息 ID 长度(2字节) | 消息 ID (变长字符串) | ++--------+--------+--------+--------+--------+--------+--------+--------+ +| 方法名长度(2字节) | 方法名(变长字符串) | ++--------+--------+--------+--------+--------+--------+--------+--------+ +| 消息体数据(变长) | ++--------+--------+--------+--------+--------+--------+--------+--------+ +``` + +### 2.2 字段详细说明 + +| 字段 | 长度 | 类型 | 说明 | +|------|------|------|------| +| 魔术字 | 1字节 | byte | `0x7E` - 协议识别标识,用于数据同步 | +| 版本号 | 1字节 | byte | `0x01` - 协议版本号,支持版本控制 | +| 消息类型 | 1字节 | byte | `0x01`=请求, `0x02`=响应 | +| 消息长度 | 4字节 | int | 整个消息的总长度(包含头部) | +| 消息 ID 长度 | 2字节 | short | 消息 ID 字符串的字节长度 | +| 消息 ID | 变长 | string | 消息唯一标识符(UTF-8编码) | +| 方法名长度 | 2字节 | short | 方法名字符串的字节长度 | +| 方法名 | 变长 | string | 消息方法名(UTF-8编码) | +| 消息体 | 变长 | binary | 根据消息类型的不同数据格式 | + +**⚠️ 重要说明**:deviceId 不包含在协议中,由服务器根据连接上下文自动设置 + +### 2.3 协议常量定义 + +```java +// 协议标识 +private static final byte MAGIC_NUMBER = (byte) 0x7E; +private static final byte PROTOCOL_VERSION = (byte) 0x01; + +// 消息类型 +private static final byte REQUEST = (byte) 0x01; // 请求消息 +private static final byte RESPONSE = (byte) 0x02; // 响应消息 + +// 协议长度 +private static final int HEADER_FIXED_LENGTH = 7; // 固定头部长度 +private static final int MIN_MESSAGE_LENGTH = 11; // 最小消息长度 +``` + +## 3. 消息类型和格式 + +### 3.1 请求消息 (REQUEST - 0x01) + +请求消息用于设备向服务器发送数据或请求。 + +#### 3.1.1 消息体格式 +``` +消息体 = params 数据(JSON格式) +``` + +#### 3.1.2 示例:设备认证请求 + +**消息内容:** +- 消息 ID: `auth_1704067200000_123` +- 方法名: `auth` +- 参数: `{"clientId":"device_001","username":"productKey_deviceName","password":"device_password"}` + +**二进制数据包结构:** +``` +7E // 魔术字 (0x7E) +01 // 版本号 (0x01) +01 // 消息类型 (REQUEST) +00 00 00 89 // 消息长度 (137字节) +00 19 // 消息 ID 长度 (25字节) +61 75 74 68 5F 31 37 30 34 30 // 消息 ID: "auth_1704067200000_123" +36 37 32 30 30 30 30 30 5F 31 +32 33 +00 04 // 方法名长度 (4字节) +61 75 74 68 // 方法名: "auth" +7B 22 63 6C 69 65 6E 74 49 64 // JSON参数数据 +22 3A 22 64 65 76 69 63 65 5F // {"clientId":"device_001", +30 30 31 22 2C 22 75 73 65 72 // "username":"productKey_deviceName", +6E 61 6D 65 22 3A 22 70 72 6F // "password":"device_password"} +64 75 63 74 4B 65 79 5F 64 65 +76 69 63 65 4E 61 6D 65 22 2C +22 70 61 73 73 77 6F 72 64 22 +3A 22 64 65 76 69 63 65 5F 70 +61 73 73 77 6F 72 64 22 7D +``` + +#### 3.1.3 示例:属性数据上报 + +**消息内容:** +- 消息 ID: `property_1704067200000_456` +- 方法名: `thing.property.post` +- 参数: `{"temperature":25.5,"humidity":60.2,"pressure":1013.25}` + +### 3.2 响应消息 (RESPONSE - 0x02) + +响应消息用于服务器向设备回复请求结果。 + +#### 3.2.1 消息体格式 +``` +消息体 = 响应码(4字节) + 响应消息长度(2字节) + 响应消息(UTF-8) + 响应数据(JSON) +``` + +#### 3.2.2 字段说明 + +| 字段 | 长度 | 类型 | 说明 | +|------|------|------|------| +| 响应码 | 4字节 | int | HTTP状态码风格,0=成功,其他=错误 | +| 响应消息长度 | 2字节 | short | 响应消息字符串的字节长度 | +| 响应消息 | 变长 | string | 响应提示信息(UTF-8编码) | +| 响应数据 | 变长 | binary | JSON格式的响应数据(可选) | + +#### 3.2.3 示例:认证成功响应 + +**消息内容:** +- 消息 ID: `auth_response_1704067200000_123` +- 方法名: `auth` +- 响应码: `0` +- 响应消息: `认证成功` +- 响应数据: `{"success":true,"message":"认证成功"}` + +**二进制数据包结构:** +``` +7E // 魔术字 (0x7E) +01 // 版本号 (0x01) +02 // 消息类型 (RESPONSE) +00 00 00 A4 // 消息长度 (164字节) +00 22 // 消息 ID 长度 (34字节) +61 75 74 68 5F 72 65 73 70 6F // 消息 ID: "auth_response_1704067200000_123" +6E 73 65 5F 31 37 30 34 30 36 +37 32 30 30 30 30 30 5F 31 32 +33 +00 04 // 方法名长度 (4字节) +61 75 74 68 // 方法名: "auth" +00 00 00 00 // 响应码 (0 = 成功) +00 0C // 响应消息长度 (12字节) +E8 AE A4 E8 AF 81 E6 88 90 E5 // 响应消息: "认证成功" (UTF-8) +8A 9F +7B 22 73 75 63 63 65 73 73 22 // JSON响应数据 +3A 74 72 75 65 2C 22 6D 65 73 // {"success":true,"message":"认证成功"} +73 61 67 65 22 3A 22 E8 AE A4 +E8 AF 81 E6 88 90 E5 8A 9F 22 +7D +``` + +## 4. 编解码器标识 + +```java +public static final String TYPE = "TCP_BINARY"; +``` + +## 5. 协议优势 + +- **数据紧凑**:二进制格式,相比 JSON 减少 30-50% 的数据量 +- **解析高效**:直接二进制操作,减少字符串转换开销 +- **类型安全**:明确的消息类型和字段定义 +- **设计简洁**:去除冗余字段,协议更加精简高效 +- **版本控制**:内置版本号支持协议升级 + +## 6. 与 JSON 协议对比 + +| 特性 | 二进制协议 | JSON协议 | +|------|-------------|--------| +| 数据大小 | 小(节省30-50%) | 大 | +| 解析性能 | 高 | 中等 | +| 网络开销 | 低 | 高 | +| 可读性 | 差 | 优秀 | +| 调试难度 | 高 | 低 | +| 扩展性 | 良好 | 优秀 | + +**推荐场景**: +- ✅ **高频数据传输**:传感器数据实时上报 +- ✅ **带宽受限环境**:移动网络、卫星通信 +- ✅ **性能要求高**:需要低延迟、高吞吐的场景 +- ✅ **设备资源有限**:嵌入式设备、低功耗设备 +- ❌ **开发调试阶段**:调试困难,建议使用 JSON 协议 +- ❌ **快速原型开发**:开发效率低 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-json-packet-examples.md b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-json-packet-examples.md new file mode 100644 index 0000000000..09ef50cfe5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-json-packet-examples.md @@ -0,0 +1,191 @@ +# TCP JSON 格式协议说明 + +## 1. 协议概述 + +TCP JSON 格式协议采用纯 JSON 格式进行数据传输,具有以下特点: + +- **标准化**:使用标准 JSON 格式,易于解析和处理 +- **可读性**:人类可读,便于调试和维护 +- **扩展性**:可以轻松添加新字段,向后兼容 +- **跨平台**:JSON 格式支持所有主流编程语言 +- **安全优化**:移除冗余的 deviceId 字段,提高安全性 + +## 2. 消息格式 + +### 2.1 基础消息结构 + +```json +{ + "id": "消息唯一标识", + "method": "消息方法", + "params": { + // 请求参数 + }, + "data": { + // 响应数据 + }, + "code": 响应码, + "msg": "响应消息", + "timestamp": 时间戳 +} +``` + +**⚠️ 重要说明**: +- **不包含 deviceId 字段**:由服务器通过 TCP 连接上下文自动确定设备 ID +- **避免伪造攻击**:防止设备伪造其他设备的 ID 发送消息 + +### 2.2 字段详细说明 + +| 字段名 | 类型 | 必填 | 用途 | 说明 | +|--------|------|------|------|------| +| id | String | 是 | 所有消息 | 消息唯一标识 | +| method | String | 是 | 所有消息 | 消息方法,如 `auth`、`thing.property.post` | +| params | Object | 否 | 请求消息 | 请求参数,具体内容根据method而定 | +| data | Object | 否 | 响应消息 | 响应数据,服务器返回的结果数据 | +| code | Integer | 否 | 响应消息 | 响应码,0=成功,其他=错误 | +| msg | String | 否 | 响应消息 | 响应提示信息 | +| timestamp | Long | 是 | 所有消息 | 时间戳(毫秒),编码时自动生成 | + +### 2.3 消息分类 + +#### 2.3.1 请求消息(上行) +- **特征**:包含 `params` 字段,不包含 `code`、`msg` 字段 +- **方向**:设备 → 服务器 +- **用途**:设备认证、数据上报、状态更新等 + +#### 2.3.2 响应消息(下行) +- **特征**:包含 `code`、`msg` 字段,可能包含 `data` 字段 +- **方向**:服务器 → 设备 +- **用途**:认证结果、指令响应、错误提示等 + +## 3. 消息示例 + +### 3.1 设备认证 (auth) + +#### 认证请求格式 +**消息方向**:设备 → 服务器 + +```json +{ + "id": "auth_1704067200000_123", + "method": "auth", + "params": { + "clientId": "device_001", + "username": "productKey_deviceName", + "password": "设备密码" + }, + "timestamp": 1704067200000 +} +``` + +**认证参数说明:** + +| 字段名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| clientId | String | 是 | 客户端唯一标识,用于连接管理 | +| username | String | 是 | 设备用户名,格式为 `productKey_deviceName` | +| password | String | 是 | 设备密码,在设备管理平台配置 | + +#### 认证响应格式 +**消息方向**:服务器 → 设备 + +**认证成功响应:** +```json +{ + "id": "response_auth_1704067200000_123", + "method": "auth", + "data": { + "success": true, + "message": "认证成功" + }, + "code": 0, + "msg": "认证成功", + "timestamp": 1704067200001 +} +``` + +**认证失败响应:** +```json +{ + "id": "response_auth_1704067200000_123", + "method": "auth", + "data": { + "success": false, + "message": "认证失败:用户名或密码错误" + }, + "code": 401, + "msg": "认证失败", + "timestamp": 1704067200001 +} +``` + +### 3.2 属性数据上报 (thing.property.post) + +**消息方向**:设备 → 服务器 + +**示例:温度传感器数据上报** +```json +{ + "id": "property_1704067200000_456", + "method": "thing.property.post", + "params": { + "temperature": 25.5, + "humidity": 60.2, + "pressure": 1013.25, + "battery": 85, + "signal_strength": -65 + }, + "timestamp": 1704067200000 +} +``` + +### 3.3 设备状态更新 (thing.state.update) + +**消息方向**:设备 → 服务器 + +**示例:心跳请求** +```json +{ + "id": "heartbeat_1704067200000_321", + "method": "thing.state.update", + "params": { + "state": "online", + "uptime": 86400, + "memory_usage": 65.2, + "cpu_usage": 12.8 + }, + "timestamp": 1704067200000 +} +``` + +## 4. 编解码器标识 + +```java +public static final String TYPE = "TCP_JSON"; +``` + +## 5. 协议优势 + +- **开发效率高**:JSON 格式,开发和调试简单 +- **跨语言支持**:所有主流语言都支持 JSON +- **可读性优秀**:可以直接查看消息内容 +- **扩展性强**:可以轻松添加新字段 +- **安全性高**:移除 deviceId 字段,防止伪造攻击 + +## 6. 与二进制协议对比 + +| 特性 | JSON协议 | 二进制协议 | +|------|----------|------------| +| 开发难度 | 低 | 高 | +| 调试难度 | 低 | 高 | +| 可读性 | 优秀 | 差 | +| 数据大小 | 中等 | 小(节省30-50%) | +| 解析性能 | 中等 | 高 | +| 学习成本 | 低 | 高 | + +**推荐场景**: +- ✅ **开发调试阶段**:调试友好,开发效率高 +- ✅ **快速原型开发**:实现简单,快速迭代 +- ✅ **多语言集成**:广泛的语言支持 +- ❌ **高频数据传输**:建议使用二进制协议 +- ❌ **带宽受限环境**:建议使用二进制协议 \ No newline at end of file diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java index 0a8910e1c3..59bb505891 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java @@ -21,13 +21,15 @@ public class MailSendApiImpl implements MailSendApi { @Override public Long sendSingleMailToAdmin(MailSendSingleToUserReqDTO reqDTO) { - return mailSendService.sendSingleMailToAdmin(reqDTO.getMail(), reqDTO.getUserId(), + return mailSendService.sendSingleMailToAdmin(reqDTO.getUserId(), + reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams()); } @Override public Long sendSingleMailToMember(MailSendSingleToUserReqDTO reqDTO) { - return mailSendService.sendSingleMailToMember(reqDTO.getMail(), reqDTO.getUserId(), + return mailSendService.sendSingleMailToMember(reqDTO.getUserId(), + reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(), reqDTO.getTemplateCode(), reqDTO.getTemplateParams()); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java index 0481d59225..2d67a78087 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java @@ -4,6 +4,8 @@ import lombok.Data; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; + +import java.util.List; import java.util.Map; /** @@ -16,13 +18,24 @@ public class MailSendSingleToUserReqDTO { /** * 用户编号 + * + * 如果非空,则加载对应用户的邮箱,添加到 {@link #toMails} 中 */ private Long userId; + /** - * 邮箱 + * 收件邮箱 */ - @Email - private String mail; + private List<@Email String> toMails; + /** + * 抄送邮箱 + */ + private List<@Email String> ccMails; + /** + * 密送邮箱 + */ + private List<@Email String> bccMails; + /** * 邮件模板编号 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http index f42dfcd030..52a724bf35 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http @@ -11,6 +11,24 @@ tag: Yunai.local "code": "1024" } +### 请求 /login 接口【加密 AES】 => 成功 +POST {{baseUrl}}/system/auth/login +Content-Type: application/json +tenant-id: {{adminTenantId}} +tag: Yunai.local +X-API-ENCRYPT: true + +WvSX9MOrenyGfBhEM0g1/hHgq8ocktMZ9OwAJ6MOG5FUrzYF/rG5JF1eMptQM1wT73VgDS05l/37WeRtad+JrqChAul/sR/SdOsUKqjBhvvQx1JVhzxr6s8uUP67aKTSZ6Psv7O32ELxXrzSaQvG5CInzz3w6sLtbNNLd1kXe6Q= + +### 请求 /login 接口【加密 RSA】 => 成功 +POST {{baseUrl}}/system/auth/login +Content-Type: application/json +tenant-id: {{adminTenantId}} +tag: Yunai.local +X-API-ENCRYPT: true + +e7QZTork9ZV5CmgZvSd+cHZk3xdUxKtowLM02kOha+gxHK2H/daU8nVBYS3+bwuDRy5abf+Pz1QJJGVAEd27wwrXBmupOOA/bhpuzzDwcRuJRD+z+YgiNoEXFDRHERxPYlPqAe9zAHtihD0ceub1AjybQsEsROew4C3Q602XYW0= + ### 请求 /login 接口 => 成功(无验证码) POST {{baseUrl}}/system/auth/login Content-Type: application/json diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java index 7873d00f0a..7a243b778d 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java @@ -56,6 +56,15 @@ public class DeptController { return success(true); } + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除部门") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('system:dept:delete')") + public CommonResult deleteDeptList(@RequestParam("ids") List ids) { + deptService.deleteDeptList(ids); + return success(true); + } + @GetMapping("/list") @Operation(summary = "获取部门列表") @PreAuthorize("@ss.hasPermission('system:dept:query')") diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/MailTemplateController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/MailTemplateController.java index 9ebcda532f..52ac150875 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/MailTemplateController.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/MailTemplateController.java @@ -91,7 +91,8 @@ public class MailTemplateController { @Operation(summary = "发送短信") @PreAuthorize("@ss.hasPermission('system:mail-template:send-mail')") public CommonResult sendMail(@Valid @RequestBody MailTemplateSendReqVO sendReqVO) { - return success(mailSendService.sendSingleMailToAdmin(sendReqVO.getMail(), getLoginUserId(), + return success(mailSendService.sendSingleMailToAdmin(getLoginUserId(), + sendReqVO.getToMails(), sendReqVO.getCcMails(), sendReqVO.getBccMails(), sendReqVO.getTemplateCode(), sendReqVO.getTemplateParams())); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/log/MailLogRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/log/MailLogRespVO.java index 49edfffefb..8e67d1df73 100755 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/log/MailLogRespVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/log/MailLogRespVO.java @@ -2,13 +2,11 @@ package cn.iocoder.yudao.module.system.controller.admin.mail.vo.log; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDateTime; +import java.util.List; import java.util.Map; -import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; - @Schema(description = "管理后台 - 邮件日志 Response VO") @Data public class MailLogRespVO { @@ -22,8 +20,14 @@ public class MailLogRespVO { @Schema(description = "用户类型,参见 UserTypeEnum 枚举", example = "2") private Byte userType; - @Schema(description = "接收邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "76854@qq.com") - private String toMail; + @Schema(description = "接收邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user1@example.com, user2@example.com") + private List toMails; + + @Schema(description = "抄送邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user3@example.com, user4@example.com") + private List ccMails; + + @Schema(description = "密送邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user5@example.com, user6@example.com") + private List bccMails; @Schema(description = "邮箱账号编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "18107") private Long accountId; diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java index b76b7ffccd..f125d77e9a 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/mail/vo/template/MailTemplateSendReqVO.java @@ -5,15 +5,22 @@ import lombok.Data; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import java.util.List; import java.util.Map; @Schema(description = "管理后台 - 邮件发送 Req VO") @Data public class MailTemplateSendReqVO { - @Schema(description = "接收邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "7685413@qq.com") + @Schema(description = "接收邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user1@example.com, user2@example.com]") @NotEmpty(message = "接收邮箱不能为空") - private String mail; + private List toMails; + + @Schema(description = "抄送邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user3@example.com, user4@example.com]") + private List ccMails; + + @Schema(description = "密送邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "[user5@example.com, user6@example.com]") + private List bccMails; @Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "test_01") @NotNull(message = "模板编码不能为空") diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.http b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.http index f770ed91d7..cdcebbfb1c 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.http +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.http @@ -35,6 +35,14 @@ tenant-id: {{adminTenantId}} grant_type=password&username=admin&password=admin123&scope=user.read +### 请求 /system/oauth2/token + client_credentials 接口 => 成功 +POST {{baseUrl}}/system/oauth2/token +Content-Type: application/x-www-form-urlencoded +Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw== +tenant-id: {{adminTenantId}} + +grant_type=client_credentials&scope=user.read + ### 请求 /system/oauth2/token + refresh_token 接口 => 成功 POST {{baseUrl}}/system/oauth2/token Content-Type: application/x-www-form-urlencoded diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java index d06523e9c0..067ab8aaf1 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/oauth2/OAuth2OpenController.java @@ -94,6 +94,7 @@ public class OAuth2OpenController { @Parameter(name = "scope", example = "user_info"), @Parameter(name = "refresh_token", example = "123424233"), }) + @SuppressWarnings("EnhancedSwitchMigration") public CommonResult postAccessToken(HttpServletRequest request, @RequestParam("grant_type") String grantType, @RequestParam(value = "code", required = false) String code, // 授权码模式 @@ -119,15 +120,23 @@ public class OAuth2OpenController { grantType, scopes, redirectUri); // 2. 根据授权模式,获取访问令牌 - OAuth2AccessTokenDO accessTokenDO = switch (grantTypeEnum) { - // TODO @xingyu:这里改了,可能会影响 jdk8 版本哈; - case AUTHORIZATION_CODE -> - oauth2GrantService.grantAuthorizationCodeForAccessToken(client.getClientId(), code, redirectUri, state); - case PASSWORD -> oauth2GrantService.grantPassword(username, password, client.getClientId(), scopes); - case CLIENT_CREDENTIALS -> oauth2GrantService.grantClientCredentials(client.getClientId(), scopes); - case REFRESH_TOKEN -> oauth2GrantService.grantRefreshToken(refreshToken, client.getClientId()); - default -> throw new IllegalArgumentException("未知授权类型:" + grantType); - }; + OAuth2AccessTokenDO accessTokenDO; + switch (grantTypeEnum) { + case AUTHORIZATION_CODE: + accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken(client.getClientId(), code, redirectUri, state); + break; + case PASSWORD: + accessTokenDO = oauth2GrantService.grantPassword(username, password, client.getClientId(), scopes); + break; + case CLIENT_CREDENTIALS: + accessTokenDO = oauth2GrantService.grantClientCredentials(client.getClientId(), scopes); + break; + case REFRESH_TOKEN: + accessTokenDO = oauth2GrantService.grantRefreshToken(refreshToken, client.getClientId()); + break; + default: + throw new IllegalArgumentException("未知授权类型:" + grantType); + } Assert.notNull(accessTokenDO, "访问令牌不能为空"); // 防御性检查 return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO)); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java index 62ccb7c611..dd91812353 100755 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantRespVO.java @@ -1,14 +1,15 @@ package cn.iocoder.yudao.module.system.controller.admin.tenant.vo.tenant; +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; import cn.iocoder.yudao.module.system.enums.DictTypeConstants; -import cn.idev.excel.annotation.ExcelIgnoreUnannotated; -import cn.idev.excel.annotation.ExcelProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Schema(description = "管理后台 - 租户 Response VO") @Data @@ -36,8 +37,8 @@ public class TenantRespVO { @DictFormat(DictTypeConstants.COMMON_STATUS) private Integer status; - @Schema(description = "绑定域名", example = "https://www.iocoder.cn") - private String website; + @Schema(description = "绑定域名数组", example = "https://www.iocoder.cn") + private List websites; @Schema(description = "租户套餐编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") private Long packageId; diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java index 175afa34bb..1d96e5e50a 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/tenant/vo/tenant/TenantSaveReqVO.java @@ -3,14 +3,15 @@ package cn.iocoder.yudao.module.system.controller.admin.tenant.vo.tenant; import cn.hutool.core.util.ObjectUtil; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import org.hibernate.validator.constraints.Length; - import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + import java.time.LocalDateTime; +import java.util.List; @Schema(description = "管理后台 - 租户创建/修改 Request VO") @Data @@ -34,8 +35,8 @@ public class TenantSaveReqVO { @NotNull(message = "租户状态") private Integer status; - @Schema(description = "绑定域名", example = "https://www.iocoder.cn") - private String website; + @Schema(description = "绑定域名数组", example = "https://www.iocoder.cn") + private List websites; @Schema(description = "租户套餐编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") @NotNull(message = "租户套餐编号不能为空") diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserImportExcelVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserImportExcelVO.java index 0c7c5a3c33..f6400e19db 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserImportExcelVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserImportExcelVO.java @@ -1,14 +1,13 @@ package cn.iocoder.yudao.module.system.controller.admin.user.vo.user; +import cn.idev.excel.annotation.ExcelProperty; import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; import cn.iocoder.yudao.module.system.enums.DictTypeConstants; -import cn.idev.excel.annotation.ExcelProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; /** * 用户 Excel 导入 VO @@ -17,7 +16,6 @@ import lombok.experimental.Accessors; @Builder @AllArgsConstructor @NoArgsConstructor -@Accessors(chain = false) // 设置 chain = false,避免用户导入有问题 public class UserImportExcelVO { @ExcelProperty("登录名称") diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/app/tenant/AppTenantController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/app/tenant/AppTenantController.java new file mode 100644 index 0000000000..4655da1d98 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/app/tenant/AppTenantController.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.system.controller.app.tenant; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; +import cn.iocoder.yudao.module.system.controller.app.tenant.vo.AppTenantRespVO; +import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO; +import cn.iocoder.yudao.module.system.service.tenant.TenantService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "用户 App - 租户") +@RestController +@RequestMapping("/system/tenant") +public class AppTenantController { + + @Resource + private TenantService tenantService; + + @GetMapping("/get-by-website") + @PermitAll + @TenantIgnore + @Operation(summary = "使用域名,获得租户信息", description = "根据用户的域名,获得租户信息") + @Parameter(name = "website", description = "域名", required = true, example = "www.iocoder.cn") + public CommonResult getTenantByWebsite(@RequestParam("website") String website) { + TenantDO tenant = tenantService.getTenantByWebsite(website); + if (tenant == null || CommonStatusEnum.isDisable(tenant.getStatus())) { + return success(null); + } + return success(BeanUtils.toBean(tenant, AppTenantRespVO.class)); + } + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/app/tenant/vo/AppTenantRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/app/tenant/vo/AppTenantRespVO.java new file mode 100755 index 0000000000..2d0fed903e --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/app/tenant/vo/AppTenantRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.system.controller.app.tenant.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "用户 App - 租户 Response VO") +@Data +public class AppTenantRespVO { + + @Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "租户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") + private String name; + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/mail/MailLogDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/mail/MailLogDO.java index 2a0da51728..756dba2ad7 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/mail/MailLogDO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/mail/MailLogDO.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.dal.dataobject.mail; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.system.enums.mail.MailSendStatusEnum; import com.baomidou.mybatisplus.annotation.KeySequence; @@ -12,6 +13,7 @@ import lombok.*; import java.io.Serializable; import java.time.LocalDateTime; +import java.util.List; import java.util.Map; /** @@ -47,10 +49,22 @@ public class MailLogDO extends BaseDO implements Serializable { * 枚举 {@link UserTypeEnum} */ private Integer userType; + /** * 接收邮箱地址 */ - private String toMail; + @TableField(typeHandler = StringListTypeHandler.class) + private List toMails; + /** + * 接收邮箱地址 + */ + @TableField(typeHandler = StringListTypeHandler.class) + private List ccMails; + /** + * 密送邮箱地址 + */ + @TableField(typeHandler = StringListTypeHandler.class) + private List bccMails; /** * 邮箱账号编号 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2RefreshTokenDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2RefreshTokenDO.java index 99d153e8bf..802a06ebdd 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2RefreshTokenDO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/oauth2/OAuth2RefreshTokenDO.java @@ -7,8 +7,6 @@ import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.experimental.Accessors; import java.time.LocalDateTime; import java.util.List; @@ -22,8 +20,6 @@ import java.util.List; // 由于 Oracle 的 SEQ 的名字长度有限制,所以就先用 system_oauth2_access_token_seq 吧,反正也没啥问题 @KeySequence("system_oauth2_access_token_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 @Data -@EqualsAndHashCode(callSuper = true) -@Accessors(chain = true) public class OAuth2RefreshTokenDO extends TenantBaseDO { /** diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java index 6e60f614b5..b75a602516 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/tenant/TenantDO.java @@ -2,13 +2,16 @@ package cn.iocoder.yudao.module.system.dal.dataobject.tenant; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.*; import java.time.LocalDateTime; +import java.util.List; /** * 租户 DO @@ -60,9 +63,13 @@ public class TenantDO extends BaseDO { */ private Integer status; /** - * 绑定域名 + * 绑定域名列表 + * + * 1. 考虑到对微信小程序的兼容,也允许传递 appid + * 2. 为什么是数组,考虑到管理后台、会员前台都有独立的域名,又或者多个管理后台 */ - private String website; + @TableField(typeHandler = StringListTypeHandler.class) + private List websites; /** * 租户套餐编号 * diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/mail/MailLogMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/mail/MailLogMapper.java index 6b147cff62..44fab07a0d 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/mail/MailLogMapper.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/mail/MailLogMapper.java @@ -1,8 +1,10 @@ package cn.iocoder.yudao.module.system.dal.mysql.mail; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; import cn.iocoder.yudao.module.system.controller.admin.mail.vo.log.MailLogPageReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO; import org.apache.ibatis.annotations.Mapper; @@ -14,11 +16,12 @@ public interface MailLogMapper extends BaseMapperX { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(MailLogDO::getUserId, reqVO.getUserId()) .eqIfPresent(MailLogDO::getUserType, reqVO.getUserType()) - .likeIfPresent(MailLogDO::getToMail, reqVO.getToMail()) .eqIfPresent(MailLogDO::getAccountId, reqVO.getAccountId()) .eqIfPresent(MailLogDO::getTemplateId, reqVO.getTemplateId()) .eqIfPresent(MailLogDO::getSendStatus, reqVO.getSendStatus()) .betweenIfPresent(MailLogDO::getSendTime, reqVO.getSendTime()) + .apply(StrUtil.isNotBlank(reqVO.getToMail()), + MyBatisUtils.findInSet("to_mails", reqVO.getToMail())) .orderByDesc(MailLogDO::getId)); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/sms/SmsLogMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/sms/SmsLogMapper.java index f2388711ae..31245fd0db 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/sms/SmsLogMapper.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/sms/SmsLogMapper.java @@ -22,4 +22,8 @@ public interface SmsLogMapper extends BaseMapperX { .orderByDesc(SmsLogDO::getId)); } + default SmsLogDO selectByApiSerialNo(String apiSerialNo) { + return selectOne(SmsLogDO::getApiSerialNo, apiSerialNo); + } + } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantMapper.java index 8ddf06065a..a58b057de7 100755 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantMapper.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantMapper.java @@ -3,17 +3,13 @@ package cn.iocoder.yudao.module.system.dal.mysql.tenant; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; import cn.iocoder.yudao.module.system.controller.admin.tenant.vo.tenant.TenantPageReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO; import org.apache.ibatis.annotations.Mapper; import java.util.List; -/** - * 租户 Mapper - * - * @author 芋道源码 - */ @Mapper public interface TenantMapper extends BaseMapperX { @@ -31,8 +27,9 @@ public interface TenantMapper extends BaseMapperX { return selectOne(TenantDO::getName, name); } - default TenantDO selectByWebsite(String website) { - return selectOne(TenantDO::getWebsite, website); + default List selectListByWebsite(String website) { + return selectList(new LambdaQueryWrapperX() + .apply(MyBatisUtils.findInSet("websites", website))); } default Long selectCountByPackageId(Long packageId) { diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantPackageMapper.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantPackageMapper.java index 29186176c1..27003fe84b 100755 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantPackageMapper.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/tenant/TenantPackageMapper.java @@ -9,11 +9,6 @@ import org.apache.ibatis.annotations.Mapper; import java.util.List; -/** - * 租户套餐 Mapper - * - * @author 芋道源码 - */ @Mapper public interface TenantPackageMapper extends BaseMapperX { diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java new file mode 100644 index 0000000000..e5f7741907 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java @@ -0,0 +1,212 @@ +package cn.iocoder.yudao.module.system.framework.captcha.core; + +import com.anji.captcha.model.common.RepCodeEnum; +import com.anji.captcha.model.common.ResponseModel; +import com.anji.captcha.model.vo.CaptchaVO; +import com.anji.captcha.service.impl.AbstractCaptchaService; +import com.anji.captcha.service.impl.CaptchaServiceFactory; +import com.anji.captcha.util.AESUtil; +import com.anji.captcha.util.ImageUtils; +import com.anji.captcha.util.RandomUtils; +import cn.hutool.core.util.RandomUtil; +import org.apache.commons.lang3.Strings; + +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.util.Properties; + +/** + * 图片文字验证码 + * + * @author Tsui + * @since 2025/7/23 20:44 + */ +public class PictureWordCaptchaServiceImpl extends AbstractCaptchaService { + + /** + * 验证码的基础字符 + */ + private static final String CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + /** + * 验证码长度 + */ + private static final Integer LENGTH = 4; + + private static final int WIDTH = 120; + private static final int HEIGHT = 40; + private static final int LINES = 10; + + @Override + public void init(Properties config) { + super.init(config); + } + + @Override + public void destroy(Properties config) { + logger.info("start-clear-history-data-{}", captchaType()); + } + + @Override + public String captchaType() { + return "pictureWord"; + } + + @Override + public ResponseModel get(CaptchaVO captchaVO) { + String text = generateRandomText(LENGTH); + CaptchaVO imageData = getImageData(text); + // pointJson 不传到前端,只做后端校验,测试时放开 +// imageData.setPointJson(text); + return ResponseModel.successData(imageData); + } + + @Override + public ResponseModel check(CaptchaVO captchaVO) { + ResponseModel r = super.check(captchaVO); + if (!validatedReq(r)) { + return r; + } + + // 取出验证码 + String codeKey = String.format(REDIS_CAPTCHA_KEY, captchaVO.getToken()); + if (!CaptchaServiceFactory.getCache(cacheType).exists(codeKey)) { + return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_INVALID); + } + // 正确的验证码 + String codeValue = CaptchaServiceFactory.getCache(cacheType).get(codeKey); + String code = getCodeByCodeValue(codeValue); + String secretKey = getSecretKeyByCodeValue(codeValue); + // 验证码只用一次,即刻失效 + CaptchaServiceFactory.getCache(cacheType).delete(codeKey); + + // 用户输入的验证码(CaptchaVO 中 没有预留字段,暂时用 pointJson 无需加解密) + String userCode = captchaVO.getPointJson(); + if (!Strings.CI.equals(code, userCode)) { + afterValidateFail(captchaVO); + return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_COORDINATE_ERROR); + } + + // 校验成功,将信息存入缓存 + String value; + try { + value = AESUtil.aesEncrypt(captchaVO.getToken().concat("---").concat(userCode), secretKey); + } catch (Exception e) { + logger.error("AES 加密失败", e); + afterValidateFail(captchaVO); + return ResponseModel.errorMsg(e.getMessage()); + } + String secondKey = String.format(REDIS_SECOND_CAPTCHA_KEY, value); + CaptchaServiceFactory.getCache(cacheType).set(secondKey, captchaVO.getToken(), EXPIRESIN_THREE); + captchaVO.setResult(true); + captchaVO.resetClientFlag(); + return ResponseModel.successData(captchaVO); + } + + @Override + public ResponseModel verification(CaptchaVO captchaVO) { + ResponseModel r = super.verification(captchaVO); + if (!validatedReq(r)) { + return r; + } + try { + String codeKey = String.format(REDIS_SECOND_CAPTCHA_KEY, captchaVO.getCaptchaVerification()); + if (!CaptchaServiceFactory.getCache(cacheType).exists(codeKey)) { + return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_INVALID); + } + // 二次校验取值后,即刻失效 + CaptchaServiceFactory.getCache(cacheType).delete(codeKey); + } catch (Exception e) { + logger.error("验证码解析失败", e); + return ResponseModel.errorMsg(e.getMessage()); + } + return ResponseModel.success(); + } + + + private CaptchaVO getImageData(String text) { + CaptchaVO dataVO = new CaptchaVO(); + BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + + // 设置背景色 + g.setColor(getRandomColor(200, 250)); + g.fillRect(0, 0, WIDTH, HEIGHT); + // 绘制干扰线 + for (int i = 0; i < LINES; i++) { + g.setColor(getRandomColor(100, 200)); + int x1 = RandomUtil.randomInt(WIDTH); + int y1 = RandomUtil.randomInt(HEIGHT); + int x2 = RandomUtil.randomInt(WIDTH); + int y2 = RandomUtil.randomInt(HEIGHT); + g.drawLine(x1, y1, x2, y2); + } + // 设置字体 + g.setFont(new Font("Arial", Font.BOLD, 24)); + // 绘制验证码文本 + for (int i = 0; i < text.length(); i++) { + g.setColor(getRandomColor(20, 130)); + // 文字旋转 + AffineTransform affineTransform = new AffineTransform(); + int x = 20 + i * 20; + int y = 24 + RandomUtil.randomInt(8); + // 旋转范围 -45 ~ 45 + affineTransform.setToRotation(Math.toRadians(RandomUtil.randomInt(-45, 45)), x, y); + g.setTransform(affineTransform); + g.drawString(text.charAt(i) + "", x, y); + } + // 添加噪点 + for (int i = 0; i < 100; i++) { + int x = RandomUtil.randomInt(WIDTH); + int y = RandomUtil.randomInt(HEIGHT); + image.setRGB(x, y, getRandomColor(0, 255).getRGB()); + } + g.dispose(); + + String secretKey = null; + if (captchaAesStatus) { + secretKey = AESUtil.getKey(); + } + dataVO.setSecretKey(secretKey); + + dataVO.setOriginalImageBase64(ImageUtils.getImageToBase64Str(image).replaceAll("\r|\n", "")); + dataVO.setToken(RandomUtils.getUUID()); +// dataVO.setSecretKey(secretKey); + // 将坐标信息存入 redis 中 + String codeKey = String.format(REDIS_CAPTCHA_KEY, dataVO.getToken()); + CaptchaServiceFactory.getCache(cacheType).set(codeKey, getCodeValue(text, secretKey), EXPIRESIN_SECONDS); + return dataVO; + } + + private String getCodeValue(String text, String secretKey) { + return text + "," + secretKey; + } + + private String getCodeByCodeValue(String codeValue) { + return codeValue.split(",")[0]; + } + + private String getSecretKeyByCodeValue(String codeValue) { + return codeValue.split(",")[1]; + } + + private Color getRandomColor(int min, int max) { + int minVal = Math.min(min, max); + int maxVal = Math.max(min, max); + int r = RandomUtil.randomInt(minVal, maxVal); + int g = RandomUtil.randomInt(minVal, maxVal); + int b = RandomUtil.randomInt(minVal, maxVal); + return new Color(r, g, b); + } + + /** + * 生成指定长度的随机字符串 + * + * @param length 长度 + * @return {@link String} + */ + public static String generateRandomText(int length) { + return RandomUtil.randomString(CHARACTERS, length); + } + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java index 4ae8b78c6c..830b728ede 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java @@ -78,7 +78,6 @@ public class AuthRequestFactory { .keySet() .stream() .filter(x -> names.contains(x.toUpperCase())) - .map(String::toUpperCase) .collect(Collectors.toList()); } @@ -318,4 +317,4 @@ public class AuthRequestFactory { .proxy(new Proxy(Proxy.Type.valueOf(proxyConfig.getType()), new InetSocketAddress(proxyConfig.getHostname(), proxyConfig.getPort()))) .build()); } -} \ No newline at end of file +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java index 3dd12491a9..5d51020941 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java @@ -113,6 +113,7 @@ public class AliyunSmsClient extends AbstractSmsClient { } @VisibleForTesting + @SuppressWarnings("EnhancedSwitchMigration") Integer convertSmsTemplateAuditStatus(Integer templateStatus) { switch (templateStatus) { case 0: return SmsTemplateAuditStatusEnum.CHECKING.getStatus(); @@ -136,20 +137,25 @@ public class AliyunSmsClient extends AbstractSmsClient { .map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue()))) .collect(Collectors.joining("&")); - // 2.1 请求 Header + // 2. 请求 Body + String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空 + String hashedRequestPayload = DigestUtil.sha256Hex(requestBody); + + // 3.1 请求 Header TreeMap headers = new TreeMap<>(); headers.put("host", HOST); headers.put("x-acs-version", VERSION); headers.put("x-acs-action", apiName); headers.put("x-acs-date", FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT")).format(new Date())); headers.put("x-acs-signature-nonce", IdUtil.randomUUID()); + headers.put("x-acs-content-sha256", hashedRequestPayload); - // 2.2 构建签名 Header + // 3.2 构建签名 Header StringBuilder canonicalHeaders = new StringBuilder(); // 构造请求头,多个规范化消息头,按照消息头名称(小写)的字符代码顺序以升序排列后拼接在一起 StringBuilder signedHeadersBuilder = new StringBuilder(); // 已签名消息头列表,多个请求头名称(小写)按首字母升序排列并以英文分号(;)分隔 headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-") - || entry.getKey().equalsIgnoreCase("host") - || entry.getKey().equalsIgnoreCase("content-type")) + || "host".equalsIgnoreCase(entry.getKey()) + || "content-type".equalsIgnoreCase(entry.getKey())) .sorted(Map.Entry.comparingByKey()).forEach(entry -> { String lowerKey = entry.getKey().toLowerCase(); canonicalHeaders.append(lowerKey).append(":").append(String.valueOf(entry.getValue()).trim()).append("\n"); @@ -157,13 +163,13 @@ public class AliyunSmsClient extends AbstractSmsClient { }); String signedHeaders = signedHeadersBuilder.substring(0, signedHeadersBuilder.length() - 1); - // 3. 请求 Body - String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空。 - String hashedRequestBody = DigestUtil.sha256Hex(requestBody); - // 4. 构建 Authorization 签名 - String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n" - + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; + String canonicalRequest = "POST" + "\n" + + "/" + "\n" + + queryString + "\n" + + canonicalHeaders + "\n" + + signedHeaders + "\n" + + hashedRequestPayload; String hashedCanonicalRequest = DigestUtil.sha256Hex(canonicalRequest); String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest; String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名 @@ -190,4 +196,4 @@ public class AliyunSmsClient extends AbstractSmsClient { .replace("%7E", "~"); // 波浪号 "%7E" 被替换为 "~" } -} \ No newline at end of file +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java index 19cde8c262..653458f461 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java @@ -119,6 +119,7 @@ public class TencentSmsClient extends AbstractSmsClient { return new SmsReceiveRespDTO() .setSuccess("SUCCESS".equals(statusObj.getStr("report_status"))) // 是否接收成功 .setErrorCode(statusObj.getStr("errmsg")) // 状态报告编码 + .setErrorMsg(statusObj.getStr("description")) // 状态报告描述 .setMobile(statusObj.getStr("mobile")) // 手机号 .setReceiveTime(statusObj.getLocalDateTime("user_receive_time", null)) // 状态报告时间 .setSerialNo(statusObj.getStr("sid")); // 发送序列号 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java index 8d5af7c4cf..03a4b7f198 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java @@ -5,6 +5,9 @@ import lombok.Data; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import java.util.Collection; +import java.util.List; + /** * 邮箱发送消息 * @@ -21,8 +24,16 @@ public class MailSendMessage { /** * 接收邮件地址 */ - @NotNull(message = "接收邮件地址不能为空") - private String mail; + @NotEmpty(message = "接收邮件地址不能为空") + private Collection toMails; + /** + * 抄送邮件地址 + */ + private Collection ccMails; + /** + * 密送邮件地址 + */ + private Collection bccMails; /** * 邮件账号编号 */ diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java index 5a44218bb2..07aabb00a8 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java @@ -7,6 +7,11 @@ import org.springframework.stereotype.Component; import jakarta.annotation.Resource; +import java.util.Collection; +import java.util.List; + +import static java.util.Collections.singletonList; + /** * Mail 邮件相关消息的 Producer * @@ -24,17 +29,22 @@ public class MailProducer { * 发送 {@link MailSendMessage} 消息 * * @param sendLogId 发送日志编码 - * @param mail 接收邮件地址 + * @param toMails 接收邮件地址 + * @param ccMails 抄送邮件地址 + * @param bccMails 密送邮件地址 * @param accountId 邮件账号编号 - * @param nickname 邮件发件人 - * @param title 邮件标题 - * @param content 邮件内容 + * @param nickname 邮件发件人 + * @param title 邮件标题 + * @param content 邮件内容 */ - public void sendMailSendMessage(Long sendLogId, String mail, Long accountId, - String nickname, String title, String content) { + public void sendMailSendMessage(Long sendLogId, + Collection toMails, Collection ccMails, Collection bccMails, + Long accountId, String nickname, String title, String content) { MailSendMessage message = new MailSendMessage() - .setLogId(sendLogId).setMail(mail).setAccountId(accountId) - .setNickname(nickname).setTitle(title).setContent(content); + .setLogId(sendLogId) + .setToMails(toMails).setCcMails(ccMails).setBccMails(bccMails) + .setAccountId(accountId).setNickname(nickname) + .setTitle(title).setContent(content); applicationContext.publishEvent(message); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java index 94e589de5e..22d159a8ce 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils; import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission; import cn.iocoder.yudao.module.system.api.logger.dto.LoginLogCreateReqDTO; import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO; @@ -97,6 +98,7 @@ public class AdminAuthServiceImpl implements AdminAuthService { } @Override + @DataPermission(enable = false) public AuthLoginRespVO login(AuthLoginReqVO reqVO) { // 校验验证码 validateCaptcha(reqVO); diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java index a0b765e590..06a688e606 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java @@ -36,6 +36,13 @@ public interface DeptService { */ void deleteDept(Long id); + /** + * 批量删除部门 + * + * @param ids 部门编号数组 + */ + void deleteDeptList(List ids); + /** * 获得部门信息 * diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java index 946d92df3b..6086474c60 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java @@ -88,6 +88,21 @@ public class DeptServiceImpl implements DeptService { deptMapper.deleteById(id); } + @Override + @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, + allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 + public void deleteDeptList(List ids) { + // 校验是否有子部门 + for (Long id : ids) { + if (deptMapper.selectCountByParentId(id) > 0) { + throw exception(DEPT_EXITS_CHILDREN); + } + } + + // 批量删除部门 + deptMapper.deleteByIds(ids); + } + @VisibleForTesting void validateDeptExists(Long id) { if (id == null) { diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogService.java index 4a0b204385..1c66e55ef4 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogService.java @@ -6,6 +6,8 @@ import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO; import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailLogDO; import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailTemplateDO; +import java.util.Collection; +import java.util.List; import java.util.Map; /** @@ -35,18 +37,21 @@ public interface MailLogService { /** * 创建邮件日志 * - * @param userId 用户编码 - * @param userType 用户类型 - * @param toMail 收件人邮件 - * @param account 邮件账号信息 - * @param template 模版信息 + * @param userId 用户编码 + * @param userType 用户类型 + * @param toMails 收件人邮件 + * @param ccMails 收件人邮件 + * @param bccMails 收件人邮件 + * @param account 邮件账号信息 + * @param template 模版信息 * @param templateContent 模版内容 - * @param templateParams 模版参数 - * @param isSend 是否发送成功 + * @param templateParams 模版参数 + * @param isSend 是否发送成功 * @return 日志编号 */ - Long createMailLog(Long userId, Integer userType, String toMail, - MailAccountDO account, MailTemplateDO template , + Long createMailLog(Long userId, Integer userType, + Collection toMails, Collection ccMails, Collection bccMails, + MailAccountDO account, MailTemplateDO template, String templateContent, Map templateParams, Boolean isSend); /** diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImpl.java index 827d0c56e9..c17abaf016 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailLogServiceImpl.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.system.service.mail; +import cn.hutool.core.collection.ListUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.system.controller.admin.mail.vo.log.MailLogPageReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailAccountDO; @@ -12,8 +13,7 @@ import org.springframework.validation.annotation.Validated; import jakarta.annotation.Resource; import java.time.LocalDateTime; -import java.util.Map; -import java.util.Objects; +import java.util.*; import static cn.hutool.core.exceptions.ExceptionUtil.getRootCauseMessage; @@ -41,7 +41,8 @@ public class MailLogServiceImpl implements MailLogService { } @Override - public Long createMailLog(Long userId, Integer userType, String toMail, + public Long createMailLog(Long userId, Integer userType, + Collection toMails, Collection ccMails, Collection bccMails, MailAccountDO account, MailTemplateDO template, String templateContent, Map templateParams, Boolean isSend) { MailLogDO.MailLogDOBuilder logDOBuilder = MailLogDO.builder(); @@ -49,7 +50,8 @@ public class MailLogServiceImpl implements MailLogService { logDOBuilder.sendStatus(Objects.equals(isSend, true) ? MailSendStatusEnum.INIT.getStatus() : MailSendStatusEnum.IGNORE.getStatus()) // 用户信息 - .userId(userId).userType(userType).toMail(toMail) + .userId(userId).userType(userType) + .toMails(ListUtil.toList(toMails)).ccMails(ListUtil.toList(ccMails)).bccMails(ListUtil.toList(bccMails)) .accountId(account.getId()).fromMail(account.getMail()) // 模板相关字段 .templateId(template.getId()).templateCode(template.getCode()).templateNickname(template.getNickname()) diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java index 898816868f..1b600bc90c 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java @@ -1,7 +1,9 @@ package cn.iocoder.yudao.module.system.service.mail; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.module.system.mq.message.mail.MailSendMessage; +import java.util.Collection; import java.util.Map; /** @@ -15,38 +17,53 @@ public interface MailSendService { /** * 发送单条邮件给管理后台的用户 * - * @param mail 邮箱 * @param userId 用户编码 + * @param toMails 收件邮箱 + * @param ccMails 抄送邮箱 + * @param bccMails 密送邮箱 * @param templateCode 邮件模版编码 * @param templateParams 邮件模版参数 * @return 发送日志编号 */ - Long sendSingleMailToAdmin(String mail, Long userId, - String templateCode, Map templateParams); + default Long sendSingleMailToAdmin(Long userId, + Collection toMails, Collection ccMails, Collection bccMails, + String templateCode, Map templateParams) { + return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.ADMIN.getValue(), + templateCode, templateParams); + } /** * 发送单条邮件给用户 APP 的用户 * - * @param mail 邮箱 * @param userId 用户编码 + * @param toMails 收件邮箱 + * @param ccMails 抄送邮箱 + * @param bccMails 密送邮箱 * @param templateCode 邮件模版编码 * @param templateParams 邮件模版参数 * @return 发送日志编号 */ - Long sendSingleMailToMember(String mail, Long userId, - String templateCode, Map templateParams); + default Long sendSingleMailToMember(Long userId, + Collection toMails, Collection ccMails, Collection bccMails, + String templateCode, Map templateParams) { + return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.MEMBER.getValue(), + templateCode, templateParams); + } /** - * 发送单条邮件给用户 + * 发送单条邮件 * - * @param mail 邮箱 - * @param userId 用户编码 + * @param toMails 收件邮箱 + * @param ccMails 抄送邮箱 + * @param bccMails 密送邮箱 + * @param userId 用户编号 * @param userType 用户类型 * @param templateCode 邮件模版编码 * @param templateParams 邮件模版参数 * @return 发送日志编号 */ - Long sendSingleMail(String mail, Long userId, Integer userType, + Long sendSingleMail(Collection toMails, Collection ccMails, Collection bccMails, + Long userId, Integer userType, String templateCode, Map templateParams); /** diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java index 306b05c048..682696f932 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java @@ -1,5 +1,7 @@ package cn.iocoder.yudao.module.system.service.mail; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Validator; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; @@ -13,10 +15,13 @@ import cn.iocoder.yudao.module.system.service.user.AdminUserService; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; -import org.dromara.hutool.extra.mail.*; +import org.dromara.hutool.extra.mail.MailAccount; +import org.dromara.hutool.extra.mail.MailUtil; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.Map; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -49,56 +54,67 @@ public class MailSendServiceImpl implements MailSendService { private MailProducer mailProducer; @Override - public Long sendSingleMailToAdmin(String mail, Long userId, - String templateCode, Map templateParams) { - // 如果 mail 为空,则加载用户编号对应的邮箱 - if (StrUtil.isEmpty(mail)) { - AdminUserDO user = adminUserService.getUser(userId); - if (user != null) { - mail = user.getEmail(); - } - } - // 执行发送 - return sendSingleMail(mail, userId, UserTypeEnum.ADMIN.getValue(), templateCode, templateParams); - } - - @Override - public Long sendSingleMailToMember(String mail, Long userId, - String templateCode, Map templateParams) { - // 如果 mail 为空,则加载用户编号对应的邮箱 - if (StrUtil.isEmpty(mail)) { - mail = memberService.getMemberUserEmail(userId); - } - // 执行发送 - return sendSingleMail(mail, userId, UserTypeEnum.MEMBER.getValue(), templateCode, templateParams); - } - - @Override - public Long sendSingleMail(String mail, Long userId, Integer userType, + public Long sendSingleMail(Collection toMails, Collection ccMails, Collection bccMails, + Long userId, Integer userType, String templateCode, Map templateParams) { - // 校验邮箱模版是否合法 + // 1.1 校验邮箱模版是否合法 MailTemplateDO template = validateMailTemplate(templateCode); - // 校验邮箱账号是否合法 + // 1.2 校验邮箱账号是否合法 MailAccountDO account = validateMailAccount(template.getAccountId()); - - // 校验邮箱是否存在 - mail = validateMail(mail); + // 1.3 校验邮件参数是否缺失 validateTemplateParams(template, templateParams); + // 2. 组装邮箱 + String userMail = getUserMail(userId, userType); + Collection toMailSet = new LinkedHashSet<>(); + Collection ccMailSet = new LinkedHashSet<>(); + Collection bccMailSet = new LinkedHashSet<>(); + if (Validator.isEmail(userMail)) { + toMailSet.add(userMail); + } + if (CollUtil.isNotEmpty(toMails)) { + toMails.stream().filter(Validator::isEmail).forEach(toMailSet::add); + } + if (CollUtil.isNotEmpty(ccMails)) { + ccMails.stream().filter(Validator::isEmail).forEach(ccMailSet::add); + } + if (CollUtil.isNotEmpty(bccMails)) { + bccMails.stream().filter(Validator::isEmail).forEach(bccMailSet::add); + } + if (CollUtil.isEmpty(toMailSet)) { + throw exception(MAIL_SEND_MAIL_NOT_EXISTS); + } + // 创建发送日志。如果模板被禁用,则不发送短信,只记录日志 Boolean isSend = CommonStatusEnum.ENABLE.getStatus().equals(template.getStatus()); String title = mailTemplateService.formatMailTemplateContent(template.getTitle(), templateParams); String content = mailTemplateService.formatMailTemplateContent(template.getContent(), templateParams); - Long sendLogId = mailLogService.createMailLog(userId, userType, mail, + Long sendLogId = mailLogService.createMailLog(userId, userType, toMailSet, ccMailSet, bccMailSet, account, template, content, templateParams, isSend); // 发送 MQ 消息,异步执行发送短信 if (isSend) { - mailProducer.sendMailSendMessage(sendLogId, mail, account.getId(), - template.getNickname(), title, content); + mailProducer.sendMailSendMessage(sendLogId, toMailSet, ccMailSet, bccMailSet, + account.getId(), template.getNickname(), title, content); } return sendLogId; } + private String getUserMail(Long userId, Integer userType) { + if (userId == null || userType == null) { + return null; + } + if (UserTypeEnum.ADMIN.getValue().equals(userType)) { + AdminUserDO user = adminUserService.getUser(userId); + if (user != null) { + return user.getEmail(); + } + } + if (UserTypeEnum.MEMBER.getValue().equals(userType)) { + return memberService.getMemberUserEmail(userId); + } + return null; + } + @Override public void doSendMail(MailSendMessage message) { // 1. 创建发送账号 @@ -106,7 +122,7 @@ public class MailSendServiceImpl implements MailSendService { MailAccount mailAccount = buildMailAccount(account, message.getNickname()); // 2. 发送邮件 try { - String messageId = MailUtil.send(mailAccount, message.getMail(), + String messageId = MailUtil.send(mailAccount, message.getToMails(), message.getCcMails(), message.getBccMails(), message.getTitle(), message.getContent(), true); // 3. 更新结果(成功) mailLogService.updateMailSendResult(message.getLogId(), messageId, null); @@ -146,16 +162,8 @@ public class MailSendServiceImpl implements MailSendService { return account; } - @VisibleForTesting - String validateMail(String mail) { - if (StrUtil.isEmpty(mail)) { - throw exception(MAIL_SEND_MAIL_NOT_EXISTS); - } - return mail; - } - /** - * 校验邮件参数是否确实 + * 校验邮件参数是否缺失 * * @param template 邮箱模板 * @param templateParams 参数列表 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java index e95fecccc6..56c980ab7b 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2GrantServiceImpl.java @@ -86,8 +86,8 @@ public class OAuth2GrantServiceImpl implements OAuth2GrantService { @Override public OAuth2AccessTokenDO grantClientCredentials(String clientId, List scopes) { - // TODO 芋艿:项目中使用 OAuth2 解决的是三方应用的授权,内部的 SSO 等问题,所以暂时不考虑 client_credentials 这个场景 - throw new UnsupportedOperationException("暂时不支持 client_credentials 授权模式"); + // 特殊:https://yuanbao.tencent.com/bot/app/share/chat/wFj642xSZHHx + return oauth2TokenService.createAccessToken(0L, UserTypeEnum.ADMIN.getValue(), clientId, scopes); } @Override diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java index fb0e756a20..5c628b8e1e 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java @@ -197,6 +197,9 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService { * @return 用户信息 */ private Map buildUserInfo(Long userId, Integer userType) { + if (userId == null || userId <= 0) { + return Collections.emptyMap(); + } if (userType.equals(UserTypeEnum.ADMIN.getValue())) { AdminUserDO user = adminUserService.getUser(userId); return MapUtil.builder(LoginUser.INFO_KEY_NICKNAME, user.getNickname()) @@ -205,7 +208,7 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService { // 注意:目前 Member 暂时不读取,可以按需实现 return Collections.emptyMap(); } - return null; + throw new IllegalArgumentException("未知用户类型:" + userType); } private static String generateAccessToken() { diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/permission/MenuServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/permission/MenuServiceImpl.java index 0d7536a1a7..80832e969f 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/permission/MenuServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/permission/MenuServiceImpl.java @@ -255,9 +255,6 @@ public class MenuServiceImpl implements MenuService { return; } // 如果 id 为空,说明不用比较是否为相同 id 的菜单 - if (id == null) { - throw exception(MENU_NAME_DUPLICATE); - } if (!menu.getId().equals(id)) { throw exception(MENU_NAME_DUPLICATE); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogService.java index 0c86c0f07f..49ec93aaca 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogService.java @@ -12,7 +12,7 @@ import java.util.Map; * 短信日志 Service 接口 * * @author zzf - * @date 13:48 2021/3/2 + * @since 13:48 2021/3/2 */ public interface SmsLogService { @@ -49,12 +49,13 @@ public interface SmsLogService { * 更新日志的接收结果 * * @param id 日志编号 + * @param apiSerialNo 发送编号 * @param success 是否接收成功 * @param receiveTime 用户接收时间 * @param apiReceiveCode API 接收结果的编码 * @param apiReceiveMsg API 接收结果的说明 */ - void updateSmsReceiveResult(Long id, Boolean success, + void updateSmsReceiveResult(Long id, String apiSerialNo, Boolean success, LocalDateTime receiveTime, String apiReceiveCode, String apiReceiveMsg); /** diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogServiceImpl.java index 4f969cedf8..45660e60e0 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsLogServiceImpl.java @@ -11,6 +11,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import jakarta.annotation.Resource; + import java.time.LocalDateTime; import java.util.Map; import java.util.Objects; @@ -63,10 +64,17 @@ public class SmsLogServiceImpl implements SmsLogService { } @Override - public void updateSmsReceiveResult(Long id, Boolean success, LocalDateTime receiveTime, + public void updateSmsReceiveResult(Long id, String apiSerialNo, Boolean success, LocalDateTime receiveTime, String apiReceiveCode, String apiReceiveMsg) { SmsReceiveStatusEnum receiveStatus = Objects.equals(success, true) ? SmsReceiveStatusEnum.SUCCESS : SmsReceiveStatusEnum.FAILURE; + if (id == null || id == 0) { + SmsLogDO log = smsLogMapper.selectByApiSerialNo(apiSerialNo); + if (log == null) { + return; + } + id = log.getId(); + } smsLogMapper.updateById(SmsLogDO.builder().id(id).receiveStatus(receiveStatus.getStatus()) .receiveTime(receiveTime).apiReceiveCode(apiReceiveCode).apiReceiveMsg(apiReceiveMsg).build()); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsSendServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsSendServiceImpl.java index 41f429eca0..bd5068f7b5 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsSendServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/sms/SmsSendServiceImpl.java @@ -184,7 +184,7 @@ public class SmsSendServiceImpl implements SmsSendService { return; } // 更新短信日志的接收结果. 因为量一般不大,所以先使用 for 循环更新 - receiveResults.forEach(result -> smsLogService.updateSmsReceiveResult(result.getLogId(), + receiveResults.forEach(result -> smsLogService.updateSmsReceiveResult(result.getLogId(), result.getSerialNo(), result.getSuccess(), result.getReceiveTime(), result.getErrorCode(), result.getErrorMsg())); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/tenant/TenantServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/tenant/TenantServiceImpl.java index 30c6340071..a3139a9431 100755 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/tenant/TenantServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/tenant/TenantServiceImpl.java @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.system.service.tenant; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ObjectUtil; -import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; @@ -102,7 +101,7 @@ public class TenantServiceImpl implements TenantService { // 校验租户名称是否重复 validTenantNameDuplicate(createReqVO.getName(), null); // 校验租户域名是否重复 - validTenantWebsiteDuplicate(createReqVO.getWebsite(), null); + validTenantWebsiteDuplicate(createReqVO.getWebsites(), null); // 校验套餐被禁用 TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(createReqVO.getPackageId()); @@ -148,7 +147,7 @@ public class TenantServiceImpl implements TenantService { // 校验租户名称是否重复 validTenantNameDuplicate(updateReqVO.getName(), updateReqVO.getId()); // 校验租户域名是否重复 - validTenantWebsiteDuplicate(updateReqVO.getWebsite(), updateReqVO.getId()); + validTenantWebsiteDuplicate(updateReqVO.getWebsites(), updateReqVO.getId()); // 校验套餐被禁用 TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(updateReqVO.getPackageId()); @@ -175,21 +174,19 @@ public class TenantServiceImpl implements TenantService { } } - private void validTenantWebsiteDuplicate(String website, Long id) { - if (StrUtil.isEmpty(website)) { + private void validTenantWebsiteDuplicate(List websites, Long excludeId) { + if (CollUtil.isEmpty(websites)) { return; } - TenantDO tenant = tenantMapper.selectByWebsite(website); - if (tenant == null) { - return; - } - // 如果 id 为空,说明不用比较是否为相同名字的租户 - if (id == null) { - throw exception(TENANT_WEBSITE_DUPLICATE, website); - } - if (!tenant.getId().equals(id)) { - throw exception(TENANT_WEBSITE_DUPLICATE, website); - } + websites.forEach(website -> { + List tenants = tenantMapper.selectListByWebsite(website); + if (excludeId != null) { + tenants.removeIf(tenant -> tenant.getId().equals(excludeId)); + } + if (CollUtil.isNotEmpty(tenants)) { + throw exception(TENANT_WEBSITE_DUPLICATE, website); + } + }); } @Override @@ -263,7 +260,8 @@ public class TenantServiceImpl implements TenantService { @Override public TenantDO getTenantByWebsite(String website) { - return tenantMapper.selectByWebsite(website); + List tenants = tenantMapper.selectListByWebsite(website); + return CollUtil.getFirst(tenants); } @Override diff --git a/yudao-module-system/src/main/resources/META-INF/services/com.anji.captcha.service.CaptchaService b/yudao-module-system/src/main/resources/META-INF/services/com.anji.captcha.service.CaptchaService new file mode 100644 index 0000000000..80adf6ddb6 --- /dev/null +++ b/yudao-module-system/src/main/resources/META-INF/services/com.anji.captcha.service.CaptchaService @@ -0,0 +1 @@ +cn.iocoder.yudao.module.system.framework.captcha.core.PictureWordCaptchaServiceImpl \ No newline at end of file diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index 6d228c7474..970002f84a 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -201,28 +201,4 @@ justauth: cache: type: REDIS prefix: 'social_auth_state:' # 缓存前缀,目前只对 Redis 缓存生效,默认 JUSTAUTH::STATE:: - timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 - - ---- #################### iot相关配置 TODO 芋艿:再瞅瞅 #################### -iot: - emq: - # 账号 - username: anhaohao - # 密码 - password: ahh@123456 - # 主机地址 - hostUrl: tcp://chaojiniu.top:1883 - # 客户端Id,不能相同,采用随机数 ${random.value} - client-id: ${random.int} - # 默认主题 - default-topic: test - # 保持连接 - keepalive: 60 - # 清除会话(设置为false,断开连接,重连后使用原来的会话 保留订阅的主题,能接收离线期间的消息) - clearSession: true - - -# 插件配置 -pf4j: - pluginsDir: ${user.home}/plugins # 插件目录 \ No newline at end of file + timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 \ No newline at end of file diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index 7b79eca5b6..2185548b55 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -82,7 +82,7 @@ spring: host: 127.0.0.1 # 地址 port: 6379 # 端口 database: 0 # 数据库索引 - # password: dev # 密码,建议生产环境开启 +# password: dev # 密码,建议生产环境开启 --- #################### 定时任务相关配置 #################### @@ -185,6 +185,7 @@ logging: cn.iocoder.yudao.module.erp.dal.mysql: debug cn.iocoder.yudao.module.iot.dal.mysql: debug cn.iocoder.yudao.module.iot.dal.tdengine: DEBUG + cn.iocoder.yudao.module.iot.service.rule: debug cn.iocoder.yudao.module.ai.dal.mysql: debug org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR # TODO 芋艿:先禁用,Spring Boot 3.X 存在部分错误的 WARN 提示 @@ -264,9 +265,4 @@ justauth: cache: type: REDIS prefix: 'social_auth_state:' # 缓存前缀,目前只对 Redis 缓存生效,默认 JUSTAUTH::STATE:: - timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 - ---- #################### iot相关配置 TODO 芋艿【IOT】:再瞅瞅 #################### -pf4j: -# pluginsDir: /tmp/ - pluginsDir: ../plugins \ No newline at end of file + timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 \ No newline at end of file diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index 6519cbf0e5..5ad93a1d83 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -48,7 +48,7 @@ springdoc: default-flat-param-object: true # 参见 https://doc.xiaominfo.com/docs/faq/v4/knife4j-parameterobject-flat-param 文档 knife4j: - enable: false # TODO 芋艿:需要关闭增强,具体原因见:https://github.com/xiaoymin/knife4j/issues/874 + enable: true setting: language: zh_cn @@ -107,7 +107,7 @@ aj: cache-type: redis # 缓存 local/redis... cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存 timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行 - type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选 + type: blockPuzzle # 验证码类型 default 三种都实例化。blockPuzzle 滑块拼图、clickWord 文字点选、pictureWord 文本输入 water-mark: 芋道源码 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode interference-options: 0 # 滑动干扰项(0/1/2) req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false @@ -175,7 +175,8 @@ spring: azure: # OpenAI 微软 openai: endpoint: https://eastusprejade.openai.azure.com - api-key: xxx + anthropic: # Anthropic Claude + api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 ollama: base-url: http://127.0.0.1:11434 chat: @@ -183,7 +184,7 @@ spring: stabilityai: api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx dashscope: # 通义千问 - api-key: sk-71800982914041848008480000000000 + api-key: sk-47aa124781be4bfb95244cc62f6xxxx minimax: # Minimax:https://www.minimaxi.com/ api-key: xxxx moonshot: # 月之暗灭(KIMI) @@ -193,9 +194,30 @@ spring: chat: options: model: deepseek-chat + model: + rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 + mcp: + server: + enabled: false + name: yudao-mcp-server + version: 1.0.0 + instructions: 一个 MCP 示例服务 + sse-endpoint: /sse + client: + enabled: false + name: mcp + sse: + connections: + filesystem: + url: http://127.0.0.1:8089 + sse-endpoint: /sse yudao: ai: + gemini: # 谷歌 Gemini + enable: true + api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ + model: gemini-2.5-flash doubao: # 字节豆包 enable: true api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 @@ -212,7 +234,7 @@ yudao: enable: true appKey: 75b161ed2aef4719b275d6e7f2a4d4cd secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz - model: generalv3.5 + model: x1 baichuan: # 百川智能 enable: true api-key: sk-abc @@ -227,6 +249,9 @@ yudao: enable: true # base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app base-url: http://127.0.0.1:3001 + web-search: + enable: true + api-key: sk-40500e52840f4d24b956d0b1d80d9abe --- #################### 芋道相关配置 #################### @@ -245,6 +270,13 @@ yudao: security: permit-all_urls: - /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录 + api-encrypt: + enable: true # 是否开启 API 加密 + algorithm: AES # 加密算法,支持 AES、RSA 等 + request-key: 52549111389893486934626385991395 # 【AES 案例】请求加密的秘钥,,必须 16、24、32 位 + response-key: 96103715984234343991809655248883 # 【AES 案例】响应加密的秘钥,AES 案例,必须 16、24、32 位 +# request-key: MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKWzasimcZ1icsWDPVdTXcZs1DkOWjI+m9bTQU8aOqflnomkr6QO1WWeSHBHzuJGsTlV/ZY2pFfq/NstKC94hBjx7yioYJvzb2bKN/Uy4j5nM3iCF//u0RiFkkY8j0Bt/EWoFTOb6RHf8cHIAjbYYtP3pYzbpCIwryfe0g//KIuzAgMBAAECgYADDjZrYcpZjR2xr7RbXmGtzYbyUGXwZEAqa3XaWBD51J2iSyOkAlQEDjGmxGQ3vvb4qDHHadWI+3/TKNeDXJUO+xTVJrnismK5BsHyC6dfxlIK/5BAuknryTca/3UoA1yomS9ZlF3Q0wcecaDoEnSmZEaTrp9T3itPAz4KnGjv5QJBAN5mNcfu6iJ5ktNvEdzqcxkKwbXb9Nq1SLnmTvt+d5TPX7eQ9fCwtOfVu5iBLhhZzb5PJ7pSN3Zt6rl5/jPOGv0CQQC+vETX9oe1wbxZSv6/RBGy0Xow6GndbJwvd89PcAJ2h+OJXWtg/rRHB3t9EQm7iis0XbZTapj19E4U6l8EibhvAkEA1CvYpRwmHKu1SqdM+GBnW/2qHlBwwXJvpoK02TOm674HR/4w0+YRQJfkd7LOAgcyxJuJgDTNmtt0MmzS+iNoFQJAMVSUIZ77XoDq69U/qcw7H5qaFcgmiUQr6QL9tTftCyb+LGri+MUnby96OtCLSdvkbLjIDS8GvKYhA7vSM2RDNQJBAKGyVVnFFIrbK3yuwW71yvxQEGoGxlgvZSezZ4vGgqTxrr9HvAtvWLwR6rpe6ybR/x8uUtoW7NRBWgpiIFwjvY4= # 【RSA 案例】请求解密的私钥 +# response-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDh/CHyBcS/zEfVyINVA7+c9Xxl0CPdxPMK1OIjxaLy/7BLfbkoEpI8onQtjuzfpuxCraDem9bu3BMF0pMH95HytI3Vi0kGjaV+WLIalwgc2w37oA2sbsmKzQOP7SDLO5s2QJNAD7kVwd+Q5rqaLu2MO0xVv+0IUJhn83hClC0L5wIDAQAB # 【RSA 案例】响应加密的公钥 websocket: enable: true # websocket的开关 path: /infra/ws # 路径 @@ -312,8 +344,8 @@ yudao: kd100: key: pLXUGAwK5305 customer: E77DF18BE109F454A5CD319E44BF5177 + iot: + message-bus: + type: redis # 消息总线的类型 -debug: false -# 插件配置 TODO 芋艿:【IOT】需要处理下 -pf4j: - pluginsDir: /Users/anhaohao/code/gitee/ruoyi-vue-pro/plugins # 插件目录 \ No newline at end of file +debug: false \ No newline at end of file