Compare commits

104 Commits

Author SHA1 Message Date
tianyongbao
e20f532244 fix: 设备即将过期,告警类型改为1,新增。 2026-05-30 19:44:42 +08:00
tianyongbao
fb43cc5996 Revert "fix: 设备即将过期,告警类型改为1。"
This reverts commit 0fbb934785.
2026-05-30 19:42:26 +08:00
tianyongbao
0fbb934785 fix: 设备即将过期,告警类型改为1。 2026-05-30 19:42:02 +08:00
tianyongbao
ea4e6b5ea7 fix: 设备即将过期,功能优化完善。 2026-05-30 19:33:00 +08:00
tianyongbao
fbda67ed62 fix: 设备24小时内到期,电话通知修改。 2026-05-29 22:09:48 +08:00
tianyongbao
657df0a8be fix:设备过期,离线报警和溶解氧报警应不报警,sensorErrorCode逻辑问题修复。 2026-05-29 12:10:39 +08:00
tianyongbao
598f34eae2 fix: 设备添加,默认告警码为0,告警消息排序修改,sensorErrorCode处理修改。 2026-05-12 06:56:16 +08:00
tianyongbao
02cf52aed4 fix: 设备解绑后,新生成一个无用户无塘口的设备信息。 2026-05-12 05:49:27 +08:00
tianyongbao
b643662856 fix: 新绑定设备,偶尔出现设备名称重复情况,问题修复。 2026-05-06 23:04:03 +08:00
tianyongbao
5d5beff9a2 fix: 告警电话记录和报警记录,新增告警标题查询条件。 2026-05-06 22:50:00 +08:00
tianyongbao
b97194c5b5 fix: 设备绑定记录,设备管理,联动控制等,设备编号支持模糊查询。 2026-05-02 22:49:21 +08:00
tianyongbao
1a66982586 fix: 溶解氧探头离线告警修复。 2026-05-02 22:18:46 +08:00
tianyongbao
69bc7bf020 fix: 太阳能网控,设备休眠状态修改。 2026-04-27 16:48:21 +08:00
tianyongbao
c15cbc8c86 fix: 太阳能网控,离线报警问题修复。 2026-04-26 23:01:16 +08:00
tianyongbao
237a322b7b fix: 电话报警,回执超时问题修复。 2026-04-26 21:19:09 +08:00
tianyongbao
e98460af07 fix: 控制器定时控制删除时,实际未删除。 2026-04-26 11:18:59 +08:00
tianyongbao
d34e47dbf7 fix:太阳能网控,离线报警优化配置。 2026-04-26 07:24:50 +08:00
tianyongbao
51aae2b2b9 fix:太阳能网控,溶解氧下限未保存。 2026-04-26 06:26:44 +08:00
tianyongbao
03c8bfb276 fix:太阳能网控,后台代码修改完善。 2026-04-25 06:49:58 +08:00
tianyongbao
9217efc155 fix:子账号不充值后,回调后,更新设备有效期,问题修复。 2026-04-21 13:11:33 +08:00
tianyongbao
160b28e441 fix:子账号不存在,提示信息修改。 2026-04-20 12:56:04 +08:00
tianyongbao
7bb036c52c fix:设备电话通知和告警,以及设备历史数据,增加设备有效期判断。 2026-04-20 09:44:25 +08:00
tianyongbao
c7b74f6293 fix:给设备充值,去掉设备归属用户限制,允许给子账号充值。 2026-04-18 16:06:49 +08:00
tianyongbao
5cc04a2f0d fix:校准记录,去掉权限校验,登录用户都可以调用。 2026-04-15 16:33:21 +08:00
tianyongbao
34d9e2f8a5 fix:告警类型错误,问题修复。 2026-04-14 22:44:31 +08:00
tianyongbao
563449bd63 fix:三相输入缺相,缺相无告警,bug修复。 2026-04-14 22:31:02 +08:00
tianyongbao
33521b17c9 fix: 电话通知逻辑,功能修改,完善机制。 2026-04-14 22:02:13 +08:00
tianyongbao
6bb02480dd fix: 日志字符转义修复。 2026-04-12 20:49:44 +08:00
tianyongbao
82fe313b19 fix: 控制器不报警,问题解决。 2026-04-12 20:42:49 +08:00
tianyongbao
c604c84a08 fix: 用户普通登录,接口新增几个字段。 2026-04-12 12:07:54 +08:00
tianyongbao
ff07f77694 fix: 修改微信用户和验证码登录,改为永不因不活跃过期,token时间1年有效。 2026-04-11 21:50:33 +08:00
tianyongbao
41fc3ccc9d fix: 修改微信用户登录默认token时间,改为不活跃7天,超时30天。 2026-04-11 20:22:39 +08:00
tianyongbao
3755d01ea9 fix: 回执时间过长,问题修复。 2026-04-10 22:10:21 +08:00
tianyongbao
0011245a32 fix: 盐度取值错误,问题彻底解决。 2026-04-10 14:03:17 +08:00
tianyongbao
93fcd79f0b fix: 盐度取值错误,问题再次解决。 2026-04-10 12:24:53 +08:00
tianyongbao
16bd95ea30 fix: 盐度取值错误,问题解决。 2026-04-10 12:06:02 +08:00
tianyongbao
9a6c8b963d fix: 相位差不上数,修复。 2026-04-10 11:21:36 +08:00
tianyongbao
294b7c4e37 fix: 水温,饱和度等不上数,问题解决。 2026-04-10 10:58:00 +08:00
tianyongbao
70ac046e3e fix: 离线报警逻辑问题修复。 2026-04-09 09:17:41 +08:00
tianyongbao
3f27fc7541 fix:测试问题修复,ph值。 2026-04-09 00:03:15 +08:00
tianyongbao
2aec012fc2 fix: 塘口、设备按创建时间进行正序排列。 2026-04-05 13:28:47 +08:00
tianyongbao
98f91eb9de fix: 微信支付回调接口加入白名单。 2026-04-05 13:03:03 +08:00
tianyongbao
3d4ee0f33a fix: 用户登录逻辑判断修改。 2026-04-05 12:13:34 +08:00
tianyongbao
a425f1339d fix: 配置文件加上电话通知报警是否开启。 2026-04-05 00:09:06 +08:00
tianyongbao
6b2817393a fix: 新增电话告警统一配置,是否开启。 2026-04-05 00:05:33 +08:00
tianyongbao
8e7cccc537 fix: 微信支付,支付选项配置文件修改。 2026-04-04 08:29:36 +08:00
tianyongbao
8267b410a5 fix: 微信支付,改为http。 2026-04-04 01:01:39 +08:00
tianyongbao
bb2e0f1987 fix: 微信支付,创建失败,问题修复。 2026-04-04 00:44:12 +08:00
tianyongbao
0b006725bd fix: 子账号报警: ....您的xxx塘口, 设备设备 , xxx异常...。bug修复。 2026-04-04 00:29:03 +08:00
tianyongbao
3490aaa7a5 fix: 微信小程序登录,自测bug修复。 2026-04-04 00:07:33 +08:00
tianyongbao
b70e81266e fix: 微信小程序登录,传手机号,修改手机号接口修改完善。 2026-04-03 23:37:25 +08:00
tianyongbao
f252b94832 fix: 联动控制:溶氧低于阈值时,自动打开增氧机测试bug修复。 2026-04-03 22:51:40 +08:00
tianyongbao
7bf5813b35 fix: 同设备离线成功通知后抑制时间,改为2小时。 2026-04-03 16:00:27 +08:00
tianyongbao
75fa70184c fix: 编辑设备输入端接线方式接口bug修复。 2026-03-31 13:36:31 +08:00
tianyongbao
62b305855b fix: 电流上报值为0的时候,没有更新,bug修复。 2026-03-30 15:31:05 +08:00
tianyongbao
611f3d0542 fix: 新增太阳能网控添加接口,设置太阳能网控电量告警下限值。 2026-03-29 22:44:51 +08:00
tianyongbao
faa507f4bd fix: 新增太阳能网控添加接口。 2026-03-29 21:37:12 +08:00
tianyongbao
c6a5148ac6 fix: 控制开关,去掉三相电计算逻辑。 2026-03-29 19:26:43 +08:00
tianyongbao
cde35aee96 fix: 塘口控制器列表和溶解氧列表,自测问题修复。 2026-03-27 22:28:06 +08:00
tianyongbao
d3312db206 fix: 塘口控制器列表,只有开关关联,无溶解氧探头时,显示问题,bug修复。 2026-03-27 21:30:23 +08:00
tianyongbao
7eb07611a6 fix: 塘口设备列表,bug修复。 2026-03-27 20:50:19 +08:00
tianyongbao
035e9f78c0 fix: 定时任务逻辑修改,回查不管是否成功,都返回true。 2026-03-27 17:55:29 +08:00
tianyongbao
1aee70b492 fix: 删除定时控制不好用,逻辑修复。 2026-03-25 22:03:14 +08:00
tianyongbao
4944895622 fix: 获取开关电压和电流逻辑修改。 2026-03-23 23:49:57 +08:00
tianyongbao
0526aefb01 fix: 定时任务,新增回查逻辑。 2026-03-23 23:33:54 +08:00
tianyongbao
18a16dce35 fix: 控制器新增时候,开关原始状态修复。 2026-03-23 23:06:37 +08:00
tianyongbao
80b8e9da51 fix: 控制器新增时候,电压设置bug修复。 2026-03-23 22:52:52 +08:00
tianyongbao
ff3c3079ae fix: 修改设备故障码记录相关逻辑。 2026-03-22 22:21:38 +08:00
tianyongbao
5d9510f934 fix: 开关数据状态变化,bug修复。 2026-03-22 19:27:22 +08:00
tianyongbao
ada48d0d77 fix: 饱和度报警错别字修改。 2026-03-19 08:01:31 +08:00
tianyongbao
266087973f fix: 新增定时任务,饱和度。 2026-03-19 07:51:20 +08:00
tianyongbao
35901f667d fix: 饱和度浓度超过200,连续8小时,报警新增。 2026-03-18 23:17:37 +08:00
tianyongbao
975eb308a3 fix: 应该每次电话报警,就新增条消息,不能每次超过阈值就生成一条报警。 2026-03-18 22:36:13 +08:00
tianyongbao
cd1e8249ce fix: 告警通知,电话次数问题修复。 2026-03-10 08:34:12 +08:00
tianyongbao
fe85fb2b75 fix: 电话通知判断逻辑问题修复。 2026-03-10 00:02:37 +08:00
tianyongbao
c33ecd0ab4 fix: 电话通知报警,逻辑完善,改为报警电话列表获取电话号码,通知成功后24小时不再打电话等等。 2026-03-09 23:37:33 +08:00
tianyongbao
0f968d5e9f fix: 微信支付问题修复,代码提交。 2026-03-09 07:43:39 +08:00
tianyongbao
aa213e4d8d fix: 微信支付问题修复,bug。 2026-03-08 23:30:06 +08:00
tianyongbao
7e708c1286 fix: 设备在离线,类型修改。 2026-03-08 19:37:16 +08:00
tianyongbao
456d1af39a fix: 设备在离线,溶解氧和温度报警逻辑修改。 2026-03-08 19:28:30 +08:00
tianyongbao
85949ab4a0 fix: 测控一体机开关,VO备注新增。 2026-03-08 16:19:34 +08:00
tianyongbao
ab73d00299 fix: 逻辑修复完善。 2026-03-08 13:54:28 +08:00
tianyongbao
ec148bd203 fix: 电话报警通知逻辑修改。 2026-03-07 22:13:40 +08:00
tianyongbao
b5963e0948 fix: 数据接入,控制器需要判断开关数据和溶解氧数据。 2026-01-21 18:21:21 +08:00
tianyongbao
ceafe5646c fix: 绑定解绑数据时,没有添加操作记录,bug修复。 2026-01-21 16:10:30 +08:00
tianyongbao
adb291312d fix: 绑定解绑记录,新增接口。 2026-01-21 15:25:18 +08:00
tianyongbao
bb38f4d285 fix: 塘口绑定设备bug修复。 2026-01-21 13:12:39 +08:00
tianyongbao
a7bf9e6f6f fix: 短信验证码登录,逻辑修改。 2026-01-20 22:30:32 +08:00
tianyongbao
ae20a13add fix: 扫描设备二维码,bug修复。 2026-01-20 18:21:28 +08:00
tianyongbao
0a9e808b6f fix: 主子账号登录,数据隔离问题修复。 2026-01-20 16:10:01 +08:00
tianyongbao
22726ca92b fix: 微信登录,注册账户修改。 2026-01-20 11:12:53 +08:00
tianyongbao
7dce3a27d5 fix: 手机验证码和微信手机号登录,如果用户不存在,自动创建系统用户和鱼测云用户信息。 2026-01-20 09:31:41 +08:00
tianyongbao
770e615d80 fix: 自测问题修复。 2026-01-20 07:51:32 +08:00
tianyongbao
5a5ae53d09 fix: 微信支付,支付订单创建失败,修改。 2026-01-19 14:57:30 +08:00
tianyongbao
4e5c94b427 fix: 微信支付,接口测试问题修复。 2026-01-19 14:29:42 +08:00
tianyongbao
3fc42ac5b9 fix: 微信支付,接口bug修复。 2026-01-19 14:16:33 +08:00
tianyongbao
063579f8e3 fix: 微信登录,接口修改。 2026-01-18 20:23:15 +08:00
tianyongbao
d7702ee991 fix: 微信支付,登录接口。 2026-01-16 14:33:21 +08:00
tianyongbao
7efbad95aa fix: 微信小程序接口对接修改,联调测试问题修复。 2026-01-15 11:35:13 +08:00
tianyongbao
54bcaf3c2c fix: 微信小程序接口对接修改,联调测试问题修复。 2026-01-14 08:31:55 +08:00
tianyongbao
ecdaf5d4f8 fix: 微信小程序接口对接,测试问题修复。 2026-01-12 18:47:11 +08:00
tianyongbao
0387cc2f31 fix: 微信小程序接口对接修改。 2026-01-12 00:33:59 +08:00
tianyongbao
2f740b74cd fix: bug修复。 2026-01-10 01:43:49 +08:00
tianyongbao
5bd63f83df fix: 物联网平台,amqp数据接入并插入TD数据库相关逻辑编码。 2026-01-10 01:20:51 +08:00
33 changed files with 362 additions and 3097 deletions

View File

@@ -51,16 +51,16 @@ spring:
# username: root
# password: root
driverClassName: org.postgresql.Driver
url: jdbc:postgresql://172.16.42.181:15432/fishery_test?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
url: jdbc:postgresql://81.70.89.108:15432/fishery_dev?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
username: postgres
password: htYkSuRn7Gb*pbFn
password: intc@123987
# 从库数据源 - TDengine
taos:
lazy: false
type: ${spring.datasource.type}
driverClassName: com.taosdata.jdbc.rs.RestfulDriver
# 不指定数据库名,在 SQL 中使用完整路径 fishery.table_name
url: jdbc:TAOS-RS://172.16.42.181:6041?timezone=Shanghai&charset=UTF-8&locale=en_US.UTF-8
url: jdbc:TAOS-RS://81.70.89.108:6041?timezone=Shanghai&charset=UTF-8&locale=en_US.UTF-8
username: root
password: intc@123456
hikari:
@@ -106,11 +106,11 @@ spring:
spring.data:
redis:
# 地址
host: 172.16.42.181
host: 81.70.89.108
# 端口默认为6379
port: 26379
# 数据库索引
database: 6
database: 1
# redis 密码必须配置
password: bbd4b56e5d3f
# 连接超时时间
@@ -229,8 +229,7 @@ aliyun:
# VMS 语音通知配置
vms:
# 是否启用 MNS 回扇消费true 时自动启动 MNS 消费线程接收 VMS 回扇)
# 注意:测试服设为 false避免与正式服共抢同一个 MNS 队列导致回执随机丢失
mns-enabled: false
mns-enabled: true
# VMS 语音服务对应的 AccessKey ID如果与上面 IoT 平台的 AK 相同可不填)
access-key-id: LTAI5tKh6fEKJJESaTdnChkh
# VMS 语音服务对应的 AccessKey Secret
@@ -249,7 +248,7 @@ aliyun:
# 太阳能网控离线告警延迟触发时间分钟工作1分钟休眠同分钟为正常行为需要更长延迟
solar-offline-warn-delay-minutes: 30
# 是否启用报警电话通知(全局开关,关闭后所有类型的报警都不会触发电话通知)
call-notice-enabled: false
call-notice-enabled: true
# AMQP 服务端订阅配置(使用数据同步的 AppKey + AppSecret
amqp:
# 是否启用
@@ -423,6 +422,6 @@ wx:
# 支付回调通知配置
pay-notify:
# 支付回调通知URL需根据实际域名配置
notify-url: "https://api.yuceyun.cn/test-api/weixin/pay_notify"
notify-url: "https://api.yuceyun.cn/fishery-api/weixin/pay_notify"
# 微信商户号与上面wx.pay.mch-id保持一致
mch-id: "1671289865"

View File

@@ -138,7 +138,6 @@ tenant:
- sys_user_role
- sys_client
- sys_oss_config
- iot_device_status
- flow_spel
# MyBatisPlus配置
@@ -222,7 +221,6 @@ xss:
# 排除链接
excludeUrls:
- /system/notice
- /weixin/pay_notify
--- # 分布式锁 lock4j 全局配置
lock4j:

View File

@@ -107,14 +107,6 @@
<artifactId>intc-system</artifactId>
</dependency>
<!-- TDengine 模块(用于清理设备时序数据) -->
<dependency>
<groupId>com.intc</groupId>
<artifactId>intc-tdengine</artifactId>
<version>5.5.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@@ -295,13 +295,30 @@ public class AquUserController extends BaseController {
return R.fail("用户未登录");
}
// 查询用户信息,确认当前用户存在
// 查询用户信息,获取当前用户的手机号
AquUserVo user = aquUserService.queryById(userId);
if (user == null) {
return R.fail("用户不存在");
}
String oldMobilePhone = user.getMobilePhone();
if (StringUtils.isEmpty(oldMobilePhone)) {
return R.fail("用户手机号不存在,无权操作");
}
// 验证报警电话列表是否包含用户自己的手机号
List<String> listPhone = request.getListPhone();
boolean containUser = false;
for (String phone : listPhone) {
if (phone.equals(oldMobilePhone)) {
containUser = true;
break;
}
}
if (!containUser) {
return R.fail("报警电话必须包含自己手机号");
}
// 序列化为JSON字符串
String warnPhoneJson = JsonUtils.toJsonString(listPhone);
@@ -438,7 +455,6 @@ public class AquUserController extends BaseController {
// 同步更新 SysUser 中的 phonenumber
SysUserBo sysUserBo = new SysUserBo();
sysUserBo.setUserId(userId);
sysUserBo.setUserName(request.getNewMobilePhone());
sysUserBo.setPhonenumber(request.getNewMobilePhone());
sysUserService.updateUserProfile(sysUserBo);

View File

@@ -30,25 +30,18 @@ import com.intc.fishery.domain.vo.PublicDeviceLinkedCtrl;
import com.intc.fishery.domain.vo.PublicDeviceDeadInfo;
import com.intc.fishery.domain.vo.PublicDeviceTypeAndSerialNum;
import com.intc.fishery.domain.Device;
import com.intc.fishery.domain.DeviceBindRecord;
import com.intc.fishery.domain.DeviceSwitch;
import com.intc.fishery.domain.DeviceErrorCode;
import com.intc.fishery.domain.Pond;
import com.intc.fishery.domain.LinkedCtrl;
import com.intc.fishery.domain.DeviceCorrectRecord;
import com.intc.fishery.mapper.DeviceMapper;
import com.intc.fishery.mapper.DeviceSwitchMapper;
import com.intc.fishery.mapper.DeviceBindRecordMapper;
import com.intc.fishery.mapper.DeviceErrorCodeMapper;
import com.intc.fishery.mapper.PondMapper;
import com.intc.fishery.mapper.LinkedCtrlMapper;
import com.intc.fishery.mapper.DeviceCorrectRecordMapper;
import com.intc.fishery.constant.DefineDeviceWarnCode;
import com.intc.fishery.utils.MessageOpRecordUtil;
import com.intc.common.core.config.properties.DeviceTypeProperties;
import com.intc.common.redis.utils.RedisUtils;
import com.intc.tdengine.service.IDeviceSensorDataService;
import org.springframework.beans.factory.annotation.Autowired;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.springframework.transaction.annotation.Transactional;
@@ -76,13 +69,8 @@ public class DeviceController extends BaseController {
private final PondMapper pondMapper;
private final LinkedCtrlMapper linkedCtrlMapper;
private final DeviceCorrectRecordMapper deviceCorrectRecordMapper;
private final DeviceBindRecordMapper deviceBindRecordMapper;
private final DeviceErrorCodeMapper deviceErrorCodeMapper;
private final DeviceTypeProperties deviceTypeProperties;
@Autowired(required = false)
private IDeviceSensorDataService deviceSensorDataService;
/**
* 查询设备管理列表
*/
@@ -92,16 +80,6 @@ public class DeviceController extends BaseController {
return deviceService.queryPageList(bo, pageQuery);
}
/**
* 查询太阳能网控设备列表
*/
@SaCheckPermission("fishery:solarControl:list")
@GetMapping("/solar/list")
public TableDataInfo<DeviceVo> solarList(DeviceBo bo, PageQuery pageQuery) {
bo.setDeviceType(3);
return deviceService.queryPageList(bo, pageQuery);
}
/**
* 导出设备管理列表
*/
@@ -148,7 +126,7 @@ public class DeviceController extends BaseController {
}
/**
* 删除设备管理解绑模式删除后重建设备记录保留IoT信息
* 删除设备管理
*
* @param ids 主键串
*/
@@ -160,139 +138,6 @@ public class DeviceController extends BaseController {
return toAjax(deviceService.deleteWithValidByIds(List.of(ids), true));
}
/**
* 彻底删除设备及其所有关联数据(物理删除,不重建设备记录)
* <p>删除范围:设备记录、开关、定时控制、联动控制、故障码记录、校准记录、绑定/解绑记录、TDengine时序数据、Redis告警标记
* <p>与普通删除的区别:不重建设备记录,设备彻底从系统中移除</p>
*
* @param ids 主键串
* @return 操作结果
*/
@SaCheckPermission("fishery:device:remove")
@Log(title = "设备管理", businessType = BusinessType.DELETE)
@DeleteMapping("/hard_delete/{ids}")
public R<Void> hardDelete(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
// 1. 删除TDengine时序数据需在设备记录删除前通过serialNum清理
if (deviceSensorDataService != null) {
for (Long id : ids) {
Device device = deviceMapper.selectById(id);
if (device != null && device.getSerialNum() != null && !device.getSerialNum().isBlank()) {
try {
deviceSensorDataService.dropSubTable(device.getSerialNum());
} catch (Exception e) {
org.slf4j.LoggerFactory.getLogger(DeviceController.class)
.warn("彻底删除-清理TDengine数据失败: deviceId={}, serialNum={}, error={}",
id, device.getSerialNum(), e.getMessage());
}
}
}
}
// 2. 执行彻底删除设备记录、关联数据、Redis标记
return toAjax(deviceService.hardDeleteByIds(List.of(ids)));
}
/**
* 清零设备测试数据(出厂测试用)
* <p>清理范围:
* <ul>
* <li>有效期(deadTime)、告警状态(warnCode)、实时传感器值、最后上数时间</li>
* <li>TDengine时序数据删除设备子表清除所有历史传感器数据</li>
* <li>设备绑定/解绑记录</li>
* <li>设备校准记录</li>
* <li>设备故障码记录</li>
* <li>Redis告警标记</li>
* </ul>
* 清零后设备重新绑定时会自动重新计算365天有效期。</p>
*
* @param id 设备ID
* @return 操作结果
*/
@SaCheckPermission("fishery:device:edit")
@Log(title = "设备管理", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping("/resetTestData/{id}")
public R<Void> resetTestData(@NotNull(message = "主键不能为空")
@PathVariable Long id) {
Device device = deviceMapper.selectById(id);
if (device == null) {
return R.fail("设备不存在");
}
String serialNum = device.getSerialNum();
// 1. 清零设备表:有效期、告警状态、实时传感器值、最后上数时间
deviceMapper.update(null,
new LambdaUpdateWrapper<Device>()
.eq(Device::getId, id)
.set(Device::getDeadTime, null)
.set(Device::getWarnCode, DefineDeviceWarnCode.None)
.set(Device::getLastReportTime, null)
.set(Device::getValueDissolvedOxygen, null)
.set(Device::getValueTemperature, null)
.set(Device::getValueSaturability, null)
.set(Device::getValuePh, null)
.set(Device::getValueSalinity, null)
.set(Device::getValueBattery, null)
.set(Device::getIsOxygenWarnExist, 0)
.set(Device::getIsTempWarnExist, 0)
);
// 2. 删除TDengine时序数据设备子表含所有历史传感器数据
if (deviceSensorDataService != null && serialNum != null && !serialNum.isBlank()) {
try {
deviceSensorDataService.dropSubTable(serialNum);
} catch (Exception e) {
// TDengine清理失败不阻断主流程
org.slf4j.LoggerFactory.getLogger(DeviceController.class)
.warn("清理TDengine数据失败: deviceId={}, serialNum={}, error={}", id, serialNum, e.getMessage());
}
}
// 3. 删除设备绑定/解绑记录
deviceBindRecordMapper.delete(
new LambdaQueryWrapper<DeviceBindRecord>()
.eq(DeviceBindRecord::getSerialNum, serialNum)
);
// 4. 删除设备校准记录
deviceCorrectRecordMapper.delete(
new LambdaQueryWrapper<DeviceCorrectRecord>()
.eq(DeviceCorrectRecord::getDeviceId, id)
);
// 5. 删除设备故障码记录
deviceErrorCodeMapper.delete(
new LambdaQueryWrapper<DeviceErrorCode>()
.eq(DeviceErrorCode::getDeviceId, id)
);
// 6. 清除Redis告警标记
if (serialNum != null && !serialNum.isBlank()) {
try {
RedisUtils.deleteObject("device:offline:wait:" + serialNum);
RedisUtils.deleteObject("device:detector:oxy:offline:wait:" + serialNum);
} catch (Exception e) {
// Redis清理失败不影响主流程
}
}
// 7. 记录操作日志
if (device.getUserId() != null) {
MessageOpRecordUtil.addMessageOpRecordUser(
device.getUserId(),
"设备管理",
String.format("%s(%s),清零设备测试数据。", device.getDeviceName(), serialNum)
);
} else {
org.slf4j.LoggerFactory.getLogger(DeviceController.class)
.info("清零设备测试数据(设备未绑定用户): deviceId={}, serialNum={}", id, serialNum);
}
return R.ok();
}
/**
* 查询用户所有设备简化列表
* type=1: 返回完整信息(包含塘口信息和开关列表)
@@ -473,7 +318,6 @@ public class DeviceController extends BaseController {
data.setSalinityCompensation(device.getSalinityCompensation());
data.setInputVoltage(device.getInputVoltage());
data.setVoltageWarnOpen(device.getVoltageWarnOpen());
data.setControllerFaultCallOpen(device.getControllerFaultCallOpen() == null ? 1 : device.getControllerFaultCallOpen());
data.setBatteryWarnCallOpen(device.getBatteryWarnCallOpen());
data.setBatteryWarnCallNoDis(device.getBatteryWarnCallNoDis());
data.setBatteryWarnLower(device.getBatteryWarnLower());
@@ -1196,62 +1040,6 @@ public class DeviceController extends BaseController {
return R.ok();
}
/**
* 设置控制器故障电话报警开关。
* 仅控制缺相、过压、欠压、开关空载/欠流/过流等故障的电话通知;断电电话通知不受此开关限制。
*
* @param rootUserId 用户ID
* @param request 请求对象包含设备ID和开关状态
* @return 操作结果
*/
@PutMapping("/controller_fault_call_open")
public R<Void> updateControllerFaultCallOpen(
@RequestParam("rootUserId") Long rootUserId,
@Validated @RequestBody ReqTurnOpen request) {
Device device = deviceMapper.selectOne(
new LambdaQueryWrapper<Device>()
.eq(Device::getId, request.getId())
.select(Device::getId, Device::getUserId, Device::getSerialNum,
Device::getDeviceName, Device::getDeviceType,
Device::getControllerFaultCallOpen, Device::getDeadTime)
);
if (device == null || device.getUserId() == null || !device.getUserId().equals(rootUserId)
|| device.getDeviceType() == null || device.getDeviceType() != 2) {
return R.fail("设备不存在或无权限操作");
}
if (device.getDeadTime() != null && device.getDeadTime().before(new Date())) {
return R.fail("设备服务已过期");
}
Integer currentOpen = device.getControllerFaultCallOpen() == null ? 1 : device.getControllerFaultCallOpen();
if (currentOpen.equals(request.getIsOpen())) {
return R.ok();
}
boolean updated = deviceMapper.update(null,
new LambdaUpdateWrapper<Device>()
.eq(Device::getId, request.getId())
.set(Device::getControllerFaultCallOpen, request.getIsOpen())
) > 0;
if (!updated) {
return R.fail("更新失败");
}
String op = request.getIsOpen() == 1 ? "开启" : "关闭";
MessageOpRecordUtil.addMessageOpRecordUser(
rootUserId,
"设备告警设置",
String.format("%s(%s) 设置控制器故障电话报警开关:%s",
device.getDeviceName(), device.getSerialNum(), op)
);
return R.ok();
}
/**
* 设置测控一体机溶解氧启用/禁用
* 启用时:同时启用溶解氧和温度告警及免打扰

View File

@@ -234,14 +234,14 @@ public class TimingCtrlController extends BaseController {
return R.fail("开关未绑定塘口");
}
// 检查定时控制数量是否超限5
// 检查定时控制数量是否超限4
long count = timingCtrlMapper.selectCount(
new LambdaQueryWrapper<TimingCtrl>()
.eq(TimingCtrl::getSwitchId, request.getId())
);
if (count >= 5) {
return R.fail("定时控制数量不得超过5");
if (count >= 4) {
return R.fail("定时控制数量不得超过4");
}
// 创建定时控制记录

View File

@@ -208,11 +208,6 @@ public class Device extends TenantEntity {
*/
private Integer voltageWarnOpen;
/**
* 控制器故障电话报警开关(断电不受此开关限制)
*/
private Integer controllerFaultCallOpen;
/**
* 备注
*/
@@ -248,10 +243,5 @@ public class Device extends TenantEntity {
*/
private Integer valueBattery;
/**
* 最后上数时间
*/
private Date lastReportTime;
}

View File

@@ -240,11 +240,6 @@ public class DeviceBo extends BaseEntity {
// @NotNull(message = "电压告警开关不能为空", groups = { AddGroup.class, EditGroup.class })
private Integer voltageWarnOpen;
/**
* 控制器故障电话报警开关(断电不受此开关限制)
*/
private Integer controllerFaultCallOpen;
/**
* 备注
*/
@@ -275,10 +270,5 @@ public class DeviceBo extends BaseEntity {
*/
private Long batteryWarnLower;
/**
* 最后上数时间
*/
private Date lastReportTime;
}

View File

@@ -270,12 +270,6 @@ public class DeviceVo implements Serializable {
@ExcelProperty(value = "电压告警开关")
private Integer voltageWarnOpen;
/**
* 控制器故障电话报警开关(断电不受此开关限制)
*/
@ExcelProperty(value = "控制器故障电话报警开关")
private Integer controllerFaultCallOpen;
/**
* 备注
*/
@@ -318,12 +312,6 @@ public class DeviceVo implements Serializable {
@ExcelProperty(value = "电量值")
private Integer valueBattery;
/**
* 最后上数时间
*/
@ExcelProperty(value = "最后上数时间")
private Date lastReportTime;
/**
* 手机号
*/
@@ -336,17 +324,12 @@ public class DeviceVo implements Serializable {
@ExcelProperty(value = "用户名")
private String userName;
/**
/**
* 塘口名称
*/
@ExcelProperty(value = "塘口名称")
private String pondName;
/**
* 设备在线状态online/offline
*/
private String onlineStatus;
/**
* 是否过期0-未过期1-已过期)
*/

View File

@@ -109,11 +109,6 @@ public class PublicDeviceBaseData implements Serializable {
*/
private Integer voltageWarnOpen;
/**
* 控制器故障电话报警开关(断电不受此开关限制)
*/
private Integer controllerFaultCallOpen;
/**
* 低电量电话告警开关(太阳能网控专用)
*/

View File

@@ -65,13 +65,4 @@ public interface IDeviceService {
* @return 是否删除成功
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
/**
* 彻底删除设备及其所有关联数据(不重建设备记录)
* <p>删除范围设备记录、开关、定时控制、联动控制、故障码记录、校准记录、绑定记录、Redis告警标记</p>
*
* @param ids 待删除的主键集合
* @return 是否删除成功
*/
Boolean hardDeleteByIds(Collection<Long> ids);
}

View File

@@ -5,7 +5,6 @@ import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.intc.common.core.config.properties.DeviceTypeProperties;
import com.intc.common.core.utils.MapstructUtils;
import com.intc.common.core.utils.StringUtils;
import com.intc.common.mybatis.core.page.PageQuery;
@@ -61,22 +60,11 @@ public class DeviceServiceImpl implements IDeviceService {
private final DeviceSwitchMapper deviceSwitchMapper;
private final LinkedCtrlMapper linkedCtrlMapper;
private final TimingCtrlMapper timingCtrlMapper;
private final DeviceTypeProperties deviceTypeProperties;
/** Redis 离线等待标记前缀(与 DeviceDataHandler 保持一致) */
private static final String DEVICE_OFFLINE_WAIT_KEY_PREFIX = "device:offline:wait:";
/** Redis 溶解氧探头离线等待标记前缀 */
private static final String DETECTOR_OXY_OFFLINE_WAIT_KEY_PREFIX = "device:detector:oxy:offline:wait:";
/** 太阳能网控设备类型 */
private static final Integer DEVICE_TYPE_SOLAR_CONTROLLER = 3;
/** 设备在线状态表别名 */
private static final String DEVICE_STATUS_ALIAS = "ids";
/** 设备在线状态表状态字段 */
private static final String DEVICE_STATUS_COLUMN = DEVICE_STATUS_ALIAS + ".status";
/** 设备在线状态展示字段 */
private static final String DEVICE_STATUS_DISPLAY_COLUMN = "LOWER(COALESCE(" + DEVICE_STATUS_COLUMN + ", 'offline'))";
/** 设备在线状态表状态字段(小写) */
private static final String DEVICE_STATUS_LOWER_COLUMN = "LOWER(" + DEVICE_STATUS_COLUMN + ")";
/**
* 查询设备管理
@@ -127,7 +115,6 @@ public class DeviceServiceImpl implements IDeviceService {
private MPJLambdaWrapper<Device> buildQueryWrapper(DeviceBo bo) {
Map<String, Object> params = bo.getParams();
MPJLambdaWrapper<Device> wrapper = new MPJLambdaWrapper<>();
boolean solarQuery = DEVICE_TYPE_SOLAR_CONTROLLER.equals(bo.getDeviceType());
// Select main table fields
wrapper.selectAll(Device.class);
@@ -140,12 +127,6 @@ public class DeviceServiceImpl implements IDeviceService {
wrapper.leftJoin(AquUser.class, AquUser::getId, Device::getUserId);
// Left join with pond table
wrapper.leftJoin(Pond.class, Pond::getId, Device::getPondId);
if (solarQuery) {
wrapper.selectAs(DEVICE_STATUS_DISPLAY_COLUMN, DeviceVo::getOnlineStatus)
.leftJoin(buildSolarStatusJoinSql());
applySolarOnlineStatusFilter(wrapper, params);
wrapper.orderByAsc("CASE WHEN " + DEVICE_STATUS_LOWER_COLUMN + " = 'online' THEN 0 ELSE 1 END");
}
// 处理排序逼辑如果expiredFlag为1按过期时间倒序否则按创建时间倒序
String expiredFlag = params != null ? (String) params.get("expiredFlag") : null;
@@ -173,33 +154,6 @@ public class DeviceServiceImpl implements IDeviceService {
return wrapper;
}
private String buildSolarStatusJoinSql() {
String sql = "iot_device_status " + DEVICE_STATUS_ALIAS + " ON "
+ DEVICE_STATUS_ALIAS + ".device_name = t.serial_num";
String productKey = deviceTypeProperties.getSolarController();
if (StringUtils.isNotBlank(productKey)) {
sql += " AND " + DEVICE_STATUS_ALIAS + ".product_key = '" + productKey.replace("'", "''") + "'";
}
return sql;
}
/**
* 太阳能网控在线状态查询。
*/
private void applySolarOnlineStatusFilter(MPJLambdaWrapper<Device> wrapper, Map<String, Object> params) {
if (params == null || params.isEmpty()) {
return;
}
String onlineStatus = params.get("onlineStatus") != null ? params.get("onlineStatus").toString() : null;
if ("online".equalsIgnoreCase(onlineStatus)) {
wrapper.apply(DEVICE_STATUS_LOWER_COLUMN + " = {0}", "online");
} else if ("offline".equalsIgnoreCase(onlineStatus)) {
wrapper.and(w -> w.isNull(DEVICE_STATUS_COLUMN)
.or()
.apply(DEVICE_STATUS_LOWER_COLUMN + " <> {0}", "online"));
}
}
/**
* 处理额外的查询参数
*
@@ -402,9 +356,6 @@ public class DeviceServiceImpl implements IDeviceService {
try {
AquUser user = aquUserMapper.selectById(device.getUserId());
if (user != null) {
// 管理员(出厂测试人员)解绑时清空有效期,普通用户解绑时保留有效期(防止解绑续命)
boolean isManager = user.getIsManager() != null && user.getIsManager() == 1L;
Device newDevice = new Device();
newDevice.setUserId(null);
newDevice.setIsOxygenUsed(device.getIsOxygenUsed());
@@ -414,8 +365,7 @@ public class DeviceServiceImpl implements IDeviceService {
newDevice.setDeviceType(device.getDeviceType());
newDevice.setIccId(device.getIccId());
newDevice.setBindTime(device.getBindTime());
// 管理员解绑时清空有效期(出厂测试),普通用户解绑时保留有效期
newDevice.setDeadTime(isManager ? null : device.getDeadTime());
newDevice.setDeadTime(device.getDeadTime());
newDevice.setPondId(null);
newDevice.setSalinityCompensation(0.0);
newDevice.setOxyWarnLower(device.getOxyWarnLower());
@@ -423,12 +373,11 @@ public class DeviceServiceImpl implements IDeviceService {
newDevice.setOxyWarnCallNoDis(0);
newDevice.setTempWarnCallOpen(0);
newDevice.setTempWarnCallNoDis(0);
newDevice.setControllerFaultCallOpen(1);
newDevice.setWarnCode(DefineDeviceWarnCode.None);
newDevice.setCategory(device.getCategory());
baseMapper.insert(newDevice);
log.info("已为用户重建无绑定设备: oldDeviceId={}, newDeviceId={}, serialNum={}, isManager={}, deadTime={}",
deviceId, newDevice.getId(), device.getSerialNum(), isManager, newDevice.getDeadTime());
log.info("已为用户重建无绑定设备: oldDeviceId={}, newDeviceId={}, serialNum={}",
deviceId, newDevice.getId(), device.getSerialNum());
}
} catch (Exception e) {
log.error("重建无绑定设备失败: deviceId={}, error={}", deviceId, e.getMessage(), e);
@@ -462,132 +411,7 @@ public class DeviceServiceImpl implements IDeviceService {
return true;
}
/**
* 彻底删除设备及其所有关联数据(不重建设备记录)
* 与 deleteWithValidByIds 的区别:
* - 不插入解绑记录
* - 不重建无绑定设备
* - 额外删除该设备的所有绑定/解绑记录
* 设备关联数据全部清除包括联动控制、故障码、校准记录、开关、定时控制、绑定记录、Redis告警标记
*
* @param ids 待删除的主键集合
* @return 是否删除成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean hardDeleteByIds(Collection<Long> ids) {
// 1. 先查询完整设备信息(删除前需要用到各字段)
List<Device> devicesToDelete = baseMapper.selectList(
new LambdaQueryWrapper<Device>()
.in(Device::getId, ids)
);
if (devicesToDelete.isEmpty()) {
return false;
}
for (Device device : devicesToDelete) {
Long deviceId = device.getId();
// 2. 删除设备关联的联动控制
linkedCtrlMapper.delete(
new LambdaQueryWrapper<LinkedCtrl>()
.eq(LinkedCtrl::getDeviceId, deviceId)
);
// 3. 删除设备关联的故障码记录
deviceErrorCodeMapper.delete(
new LambdaQueryWrapper<DeviceErrorCode>()
.eq(DeviceErrorCode::getDeviceId, deviceId)
);
// 4. 删除设备关联的校准记录
deviceCorrectRecordMapper.delete(
new LambdaQueryWrapper<DeviceCorrectRecord>()
.eq(DeviceCorrectRecord::getDeviceId, deviceId)
);
// 5. 删除设备关联的开关、定时控制、联动绑定
List<DeviceSwitch> switches = deviceSwitchMapper.selectList(
new LambdaQueryWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getDeviceId, deviceId)
);
if (!switches.isEmpty()) {
List<Long> switchIds = switches.stream()
.map(DeviceSwitch::getId)
.collect(Collectors.toList());
// 删除开关关联的定时控制
timingCtrlMapper.delete(
new LambdaQueryWrapper<TimingCtrl>()
.in(TimingCtrl::getSwitchId, switchIds)
);
// 收集开关关联的联动控制ID
List<Long> linkedCtrlIds = switches.stream()
.map(DeviceSwitch::getLinkedCtrlId)
.filter(id -> id != null && id > 0)
.distinct()
.collect(Collectors.toList());
// 解除开关与联动控制的绑定
deviceSwitchMapper.update(null,
new LambdaUpdateWrapper<DeviceSwitch>()
.set(DeviceSwitch::getLinkedCtrlId, null)
.eq(DeviceSwitch::getDeviceId, deviceId)
);
// 删除联动控制(如果有)
if (!linkedCtrlIds.isEmpty()) {
linkedCtrlMapper.delete(
new LambdaQueryWrapper<LinkedCtrl>()
.in(LinkedCtrl::getId, linkedCtrlIds)
);
}
// 删除开关
deviceSwitchMapper.delete(
new LambdaQueryWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getDeviceId, deviceId)
);
}
// 6. 删除该设备的所有绑定/解绑记录
if (StringUtils.isNotBlank(device.getSerialNum())) {
deviceBindRecordMapper.delete(
new LambdaQueryWrapper<DeviceBindRecord>()
.eq(DeviceBindRecord::getSerialNum, device.getSerialNum())
);
}
// 7. 物理删除设备记录
baseMapper.deleteById(deviceId);
// 8. 清除Redis告警标记
try {
String serialNum = device.getSerialNum();
if (StringUtils.isNotBlank(serialNum)) {
RedisUtils.deleteObject(DEVICE_OFFLINE_WAIT_KEY_PREFIX + serialNum);
RedisUtils.deleteObject(DETECTOR_OXY_OFFLINE_WAIT_KEY_PREFIX + serialNum);
log.info("[彻底删除] 已清除设备Redis告警标记: serialNum={}", serialNum);
}
} catch (Exception e) {
log.error("[彻底删除] 清除Redis告警标记失败: deviceId={}, error={}", deviceId, e.getMessage(), e);
}
// 9. 记录操作日志
try {
MessageOpRecordUtil.addMessageOpRecordUser(
device.getUserId(), device.getUserId(),
"设备操作",
String.format("%s(%s),彻底删除。", device.getDeviceName(), device.getSerialNum())
);
} catch (Exception e) {
log.error("[彻底删除] 记录操作日志失败: deviceId={}, error={}", deviceId, e.getMessage(), e);
}
}
return true;
}
/**
* 计算设备列表的过期信息

View File

@@ -1,11 +1,9 @@
package com.intc.iot.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.intc.common.core.domain.R;
import com.intc.common.mybatis.core.page.PageQuery;
import com.intc.common.mybatis.core.page.TableDataInfo;
import com.intc.common.satoken.utils.LoginHelper;
import com.intc.common.tenant.helper.TenantHelper;
import com.intc.common.web.core.BaseController;
import com.intc.common.core.config.properties.DeviceTypeProperties;
import com.intc.fishery.domain.Device;
@@ -32,7 +30,6 @@ import com.intc.iot.domain.bo.AddDeviceDetectorBo;
import com.intc.iot.domain.bo.DeviceCalibrateBo;
import com.intc.iot.domain.bo.DeviceRealtimeDataBo;
import com.intc.iot.domain.bo.DeviceSalinityCompensationBo;
import com.intc.iot.domain.bo.DeviceSolarPropertyCommandBo;
import com.intc.iot.domain.bo.DeviceVoltageTypeBo;
import com.intc.iot.domain.vo.DeviceRealtimeDataVo;
import com.intc.iot.service.DeviceDataService;
@@ -42,8 +39,6 @@ import com.intc.iot.service.IotDeviceService;
import com.intc.iot.service.MqttService;
import com.intc.iot.service.VmsMnsConsumerService;
import com.intc.iot.service.VmsNoticeService;
import com.intc.iot.service.SwitchTurnResult;
import com.intc.iot.service.TimingCtrlExecuteService;
import com.intc.iot.handler.DeviceDataHandler;
import com.intc.iot.service.WarnCallNoticeService;
import com.intc.iot.utils.AliyunAmqpSignUtil;
@@ -51,7 +46,6 @@ import com.intc.iot.utils.ControllerHelper;
import com.intc.iot.service.IotCloudService;
import com.intc.iot.constant.IOTPropertyName;
import com.intc.fishery.utils.MessageOpRecordUtil;
import com.intc.tdengine.service.IDeviceSensorDataService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -63,15 +57,8 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.Duration;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 阿里云生活物联网平台(飞燕平台)控制器
@@ -85,33 +72,6 @@ import java.util.Set;
@Tag(name = "生活物联网平台管理", description = "阿里云飞燕平台对接接口")
public class IotController extends BaseController {
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int DEVICE_TYPE_SOLAR_CONTROLLER = 3;
private static final int DEVICE_STATUS_ONLINE = 1;
private static final int DEVICE_STATUS_OFFLINE = 3;
private static final Set<String> SOLAR_WRITABLE_PROPERTY_KEYS = Set.of(
"correct",
"salinitySet",
"ammonia_nitrogen",
"phasedif_compensation",
"phase_compensation",
"temperature_compensation",
"stdsatphase",
"stdphaseinterval",
"SaturationPoint",
"AnaerobicPoint",
"LowtempCOEF",
"HightempCOEF",
"updatatime",
"PowerMode",
"WakeUpTime",
"BootToSleepTimeout",
"CorrectCharge",
"TempPobeOffSet1",
"TempPobeOffSet2",
"FirstBootToSleepTimeout"
);
private final DeviceTypeProperties deviceTypeProperties;
@Autowired(required = false)
@@ -141,9 +101,6 @@ public class IotController extends BaseController {
@Autowired(required = false)
private DeviceDataHandler deviceDataHandler;
@Autowired(required = false)
private IDeviceSensorDataService deviceSensorDataService;
@Autowired(required = false)
private javax.jms.Connection amqpConnection;
@@ -171,9 +128,6 @@ public class IotController extends BaseController {
@Autowired(required = false)
private IotCloudService iotCloudService;
@Autowired(required = false)
private TimingCtrlExecuteService timingCtrlExecuteService;
@Operation(summary = "测试接口")
@GetMapping("/test")
public R<String> test() {
@@ -237,106 +191,6 @@ public class IotController extends BaseController {
}
}
@Operation(summary = "回填设备最后上数时间")
@SaCheckPermission("fishery:device:edit")
@PostMapping("/device/backfill-last-report-time")
public R<Map<String, Object>> backfillLastReportTime(
@RequestParam(defaultValue = "false") boolean ignoreTenant) {
if (deviceMapper == null || deviceSensorDataService == null) {
return R.fail("设备或TDengine服务未启用");
}
Map<String, Object> result = ignoreTenant
? TenantHelper.ignore((java.util.function.Supplier<Map<String, Object>>) this::doBackfillLastReportTime)
: doBackfillLastReportTime();
result.put("ignoreTenant", ignoreTenant);
return R.ok(result);
}
private Map<String, Object> doBackfillLastReportTime() {
int total = 0;
int updated = 0;
int skipped = 0;
int failed = 0;
int noReportTime = 0;
int updateNoRows = 0;
List<Map<String, Object>> skippedSamples = new java.util.ArrayList<>();
List<Map<String, Object>> failedSamples = new java.util.ArrayList<>();
List<Device> devices = deviceMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
.isNotNull(Device::getSerialNum)
.ne(Device::getSerialNum, "")
.select(Device::getId, Device::getSerialNum)
);
total = devices.size();
for (Device device : devices) {
try {
String latestTime = deviceSensorDataService.getLatestReportTime(device.getSerialNum());
if (latestTime == null || latestTime.isBlank()) {
noReportTime++;
skipped++;
addBackfillSample(skippedSamples, device, "TDengine未查到最新上数时间");
continue;
}
Device updateDevice = new Device();
updateDevice.setId(device.getId());
updateDevice.setLastReportTime(parseReportTime(latestTime));
if (deviceMapper.updateById(updateDevice) > 0) {
updated++;
} else {
updateNoRows++;
skipped++;
addBackfillSample(skippedSamples, device, "业务库更新影响行数为0");
}
} catch (Exception e) {
failed++;
addBackfillSample(failedSamples, device, e.getMessage());
log.warn("回填设备最后上数时间失败: deviceId={}, serialNum={}, error={}",
device.getId(), device.getSerialNum(), e.getMessage());
}
}
Map<String, Object> result = new java.util.HashMap<>();
result.put("total", total);
result.put("updated", updated);
result.put("skipped", skipped);
result.put("failed", failed);
result.put("noReportTime", noReportTime);
result.put("updateNoRows", updateNoRows);
result.put("skippedSamples", skippedSamples);
result.put("failedSamples", failedSamples);
return result;
}
private void addBackfillSample(List<Map<String, Object>> samples, Device device, String reason) {
if (samples.size() >= 10) {
return;
}
Map<String, Object> sample = new java.util.HashMap<>();
sample.put("deviceId", device.getId());
sample.put("serialNum", device.getSerialNum());
sample.put("reason", reason);
samples.add(sample);
}
private Date parseReportTime(String time) {
String normalized = normalizeReportTime(time);
return Date.from(LocalDateTime.parse(normalized, DATETIME_FORMATTER)
.atZone(ZoneId.systemDefault())
.toInstant());
}
private String normalizeReportTime(String time) {
String normalized = time.trim();
if (normalized.length() > 19) {
normalized = normalized.substring(0, 19);
}
return normalized.replace('T', ' ');
}
@Operation(summary = "根据 ProductKey 和 DeviceName 查询设备信息")
@GetMapping("/device/find")
public R<Map<String, Object>> findDeviceByProductKeyAndName(
@@ -1078,7 +932,7 @@ public class IotController extends BaseController {
// 如果是三相380V四线设置输出电压
if (bo.getInputVoltage() == 4) {
for (int i = 1; i <= 4; i++) {
properties.put("Switch" + i + "_volt", 220);
properties.put("Switch" + i + "_volt", 380);
}
}
@@ -1151,12 +1005,11 @@ public class IotController extends BaseController {
device.setIotId(iotId);
device.setSerialNum(bo.getSerialNum());
device.setDeviceType(2); // 2-测控一体机
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000));
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000)); // 一年后到期
} else {
// 管理员解绑会清空有效期(null)普通用户解绑会保留有效期。仅null时才设置365天
if (device.getDeadTime() == null) {
log.info("测控一体机重新绑定,有效期已被管理员解绑清空,重新设置: serialNum={}", bo.getSerialNum());
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000));
// 检查是否已过期
if (device.getDeadTime() != null && now.after(device.getDeadTime())) {
warnCode |= DefineDeviceWarnCode.DeviceTimeDead;
}
}
@@ -1576,13 +1429,12 @@ public class IotController extends BaseController {
device.setIotId(iotId);
device.setSerialNum(bo.getSerialNum());
device.setDeviceType(1); // 1-水质检测仪
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000)); // 一年后到期
device.setIsOxygenUsed(1);
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000));
} else {
// 管理员解绑会清空有效期(null)普通用户解绑会保留有效期。仅null时才设置365天
if (device.getDeadTime() == null) {
log.info("水质检测仪重新绑定,有效期已被管理员解绑清空,重新设置: serialNum={}", bo.getSerialNum());
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000));
// 检查是否已过期
if (device.getDeadTime() != null && now.after(device.getDeadTime())) {
warnCode |= 0x0040; // 设备时间到期 (0x0040)
}
}
@@ -1680,7 +1532,7 @@ public class IotController extends BaseController {
// 如果设备离线,添加离线警告
if (status != null && "OFFLINE".equalsIgnoreCase(status)) {
warnCode |= DefineDeviceWarnCode.DeviceOffline;
warnCode |= 0x0080; // 设备离线 (0x0080)
}
device.setWarnCode(warnCode);
@@ -1834,13 +1686,7 @@ public class IotController extends BaseController {
device.setIotId(iotId);
device.setSerialNum(bo.getSerialNum());
device.setDeviceType(3); // 3-太阳能网控
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000));
} else {
// 管理员解绑会清空有效期(null)普通用户解绑会保留有效期。仅null时才设置365天
if (device.getDeadTime() == null) {
log.info("太阳能网控重新绑定,有效期已被管理员解绑清空,重新设置: serialNum={}", bo.getSerialNum());
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000));
}
device.setDeadTime(new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000)); // 一年后到期
}
// 设置基本信息
@@ -1900,7 +1746,7 @@ public class IotController extends BaseController {
// 如果设备离线,添加离线警告
if (isOffline) {
warnCode |= DefineDeviceWarnCode.DeviceSleeping;
warnCode |= 0x0080; // 设备离线
}
device.setWarnCode(warnCode);
@@ -1968,58 +1814,6 @@ public class IotController extends BaseController {
return R.fail("设备已到期");
}
// 太阳能网控设备:缓存校准指令,等设备上线后自动补发
boolean isSolarDevice = Integer.valueOf(3).equals(device.getDeviceType());
if (isSolarDevice) {
if (device.getIotId() == null || device.getIotId().isBlank()) {
return R.fail("设备 iotId 为空,无法缓存校准指令");
}
if (device.getSerialNum() == null || device.getSerialNum().isBlank()) {
return R.fail("设备编号为空,无法缓存校准指令");
}
Map<String, Object> calibrateProperties = new LinkedHashMap<>();
calibrateProperties.put("correct", 1);
if (!cachePendingSolarProperties(device, calibrateProperties)) {
return R.fail("校准指令缓存失败,请稍后重试");
}
// 更新设备警告码:清除未校准标记
int warnCode = device.getWarnCode() != null ? device.getWarnCode() : 0;
if ((warnCode & DefineDeviceWarnCode.OxyDetectorNoCorrect) != 0) {
warnCode &= ~DefineDeviceWarnCode.OxyDetectorNoCorrect;
}
deviceMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<Device>()
.eq(Device::getId, request.getId())
.set(Device::getWarnCode, warnCode)
);
// 创建校准记录
if (deviceCorrectRecordMapper != null) {
DeviceCorrectRecord correctRecord = new DeviceCorrectRecord();
correctRecord.setDeviceId(device.getId());
correctRecord.setUserId(userId);
correctRecord.setSerialNum(device.getSerialNum());
correctRecord.setDeviceType(device.getDeviceType());
correctRecord.setValueDissolvedOxygen(device.getValueDissolvedOxygen());
correctRecord.setValueTemperature(device.getValueTemperature());
correctRecord.setValueSaturability(device.getValueSaturability());
deviceCorrectRecordMapper.insert(correctRecord);
}
MessageOpRecordUtil.addMessageOpRecordUser(
device.getUserId(),
userId,
"设备操作",
String.format("%s(%s),设备校准。", device.getDeviceName(), device.getSerialNum())
);
log.info("太阳能网控校准指令已缓存: userId={}, deviceId={}, deviceName={}, serialNum={}",
userId, device.getId(), device.getDeviceName(), device.getSerialNum());
return R.ok("设置成功");
}
// 查询设备状态
if (iotDeviceService == null) {
return R.fail("飞燕平台配置未启用");
@@ -2129,460 +1923,55 @@ public class IotController extends BaseController {
return R.fail("设备已到期");
}
boolean isSolarDevice = Integer.valueOf(3).equals(device.getDeviceType());
if (isSolarDevice) {
if (device.getIotId() == null || device.getIotId().isBlank()) {
return R.fail("设备 iotId 为空,无法缓存盐度指令");
}
if (device.getSerialNum() == null || device.getSerialNum().isBlank()) {
return R.fail("设备编号为空,无法缓存盐度指令");
}
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("salinitySet", request.getSalinityCompensation());
if (!cachePendingSolarProperties(device, properties)) {
return R.fail("盐度缓存失败,请稍后重试");
}
updateDeviceSalinityCompensation(device.getId(), request.getSalinityCompensation());
setSalinityFreeze(device.getSerialNum(), request.getSalinityCompensation());
log.info("太阳能网控盐度已缓存: userId={}, deviceId={}, deviceName={}, serialNum={}, salinityCompensation={}‰",
userId, device.getId(), device.getDeviceName(), device.getSerialNum(), request.getSalinityCompensation());
return R.ok("设置成功");
}
// 查询设备状态
if (iotDeviceService == null) {
return R.fail("飞燕平台配置未启用");
}
try {
Map<String, Object> deviceDetail = iotDeviceService.queryDeviceInfo(device.getIotId());
if (deviceDetail != null && deviceDetail.get("data") != null) {
try {
Object detailData = deviceDetail.get("data");
java.lang.reflect.Method getStatusMethod = detailData.getClass().getMethod("getStatus");
Object statusObj = getStatusMethod.invoke(detailData);
if (statusObj != null) {
String statusStr = statusObj.toString();
if (!"ONLINE".equalsIgnoreCase(statusStr) && !"online".equalsIgnoreCase(statusStr)) {
return R.fail("设备离线或断电");
}
Map<String, Object> deviceDetail = iotDeviceService.queryDeviceInfo(device.getIotId());
if (deviceDetail != null && deviceDetail.get("data") != null) {
try {
Object detailData = deviceDetail.get("data");
java.lang.reflect.Method getStatusMethod = detailData.getClass().getMethod("getStatus");
Object statusObj = getStatusMethod.invoke(detailData);
if (statusObj != null) {
String statusStr = statusObj.toString();
if ("OFFLINE".equalsIgnoreCase(statusStr) || "offline".equals(statusStr)) {
return R.fail("设备离线或断电");
}
} catch (Exception ex) {
log.warn("获取设备状态失败: {}", ex.getMessage());
}
} catch (Exception ex) {
log.warn("获取设备状态失败: {}", ex.getMessage());
}
} catch (Exception ex) {
log.warn("查询设备状态失败: {}", ex.getMessage());
return R.fail("设备状态查询失败,请稍后重试");
}
if (!pushSalinitySetting(device.getIotId(), request.getSalinityCompensation())) {
// 设置盐度补偿属性
Map<String, Object> properties = new java.util.HashMap<>();
properties.put("salinitySet", request.getSalinityCompensation());
String propertiesJson = cn.hutool.json.JSONUtil.toJsonStr(properties);
Map<String, Object> setResult = iotDeviceService.setDeviceProperty(device.getIotId(), propertiesJson);
if (setResult == null || !Boolean.TRUE.equals(setResult.get("success"))) {
return R.fail("设置盐度补偿失败,请重试");
}
updateDeviceSalinityCompensation(device.getId(), request.getSalinityCompensation());
// 更新设备盐度补偿值
deviceMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<Device>()
.eq(Device::getId, request.getId())
.set(Device::getSalinityCompensation, request.getSalinityCompensation())
);
log.info("设置盐度补偿成功: userId={}, deviceId={}, deviceName={}, serialNum={}, salinityCompensation={}‰",
userId, device.getId(), device.getDeviceName(), device.getSerialNum(), request.getSalinityCompensation());
return R.ok("设置成功");
return R.ok();
} catch (Exception e) {
log.error("设置盐度补償失败: {}", e.getMessage(), e);
return R.fail("设置盐度补償失败: " + e.getMessage());
}
}
private boolean pushSalinitySetting(String iotId, Double salinityCompensation) throws Exception {
Map<String, Object> properties = new java.util.HashMap<>();
properties.put("salinitySet", salinityCompensation);
String propertiesJson = cn.hutool.json.JSONUtil.toJsonStr(properties);
Map<String, Object> setResult = iotDeviceService.setDeviceProperty(iotId, propertiesJson);
return setResult != null && Boolean.TRUE.equals(setResult.get("success"));
}
private void updateDeviceSalinityCompensation(Long deviceId, Double salinityCompensation) {
deviceMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<Device>()
.eq(Device::getId, deviceId)
.set(Device::getSalinityCompensation, salinityCompensation)
);
}
/**
* 设置盐度冻结标记30分钟内阻止设备上报的旧值回写 DB
*
* @param serialNum 设备序列号
* @param salinityCompensation 用户设定的盐度值
*/
private void setSalinityFreeze(String serialNum, Double salinityCompensation) {
if (serialNum != null && !serialNum.isBlank() && salinityCompensation != null) {
com.intc.common.redis.utils.RedisUtils.setCacheObject(
DeviceDataHandler.SALINITY_SET_FREEZE_KEY_PREFIX + serialNum,
salinityCompensation,
Duration.ofMinutes(DeviceDataHandler.SALINITY_FREEZE_MINUTES)
);
log.info("[盐度冻结] 已设置冻结标记: serialNum={}, value={}, freezeMinutes={}",
serialNum, salinityCompensation, DeviceDataHandler.SALINITY_FREEZE_MINUTES);
}
}
@Operation(summary = "太阳能网控属性下发")
@SaCheckPermission("fishery:solarControl:command")
@PostMapping("/device/solar/property-command")
public R<Map<String, Object>> setSolarPropertyCommand(@Validated @RequestBody DeviceSolarPropertyCommandBo request) {
try {
Long userId = LoginHelper.getUserId();
if (userId == null) {
return R.fail("未登录或登录已过期");
}
if (deviceMapper == null) {
return R.fail("设备服务未启用");
}
Device device = deviceMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
.eq(Device::getId, request.getId())
.select(Device::getId, Device::getUserId, Device::getIotId, Device::getSerialNum,
Device::getDeviceName, Device::getDeviceType, Device::getDeadTime)
);
if (device == null) {
return R.fail("设备不存在");
}
if (!Integer.valueOf(DEVICE_TYPE_SOLAR_CONTROLLER).equals(device.getDeviceType())) {
return R.fail("仅支持太阳能网控设备");
}
if (device.getDeadTime() != null && new Date().after(device.getDeadTime())) {
return R.fail("设备已到期");
}
if (device.getIotId() == null || device.getIotId().isBlank()) {
return R.fail("设备 iotId 为空,无法下发指令");
}
if (device.getSerialNum() == null || device.getSerialNum().isBlank()) {
return R.fail("设备编号为空,无法缓存指令");
}
Map<String, Object> properties = normalizeSolarProperties(request.getProperties());
// 太阳能网控统一走缓存下发,设备上线后自动补发,不再区分在线/离线
if (!cachePendingSolarProperties(device, properties)) {
return R.fail("指令缓存失败,请稍后重试");
}
syncDeviceFieldsAfterSolarCommand(device.getId(), properties);
if (properties.containsKey("salinitySet")) {
setSalinityFreeze(device.getSerialNum(), toDouble(properties.get("salinitySet")));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("deviceId", device.getId());
result.put("serialNum", device.getSerialNum());
result.put("properties", properties);
result.put("mode", "cached");
MessageOpRecordUtil.addMessageOpRecordUser(
device.getUserId(),
userId,
"设备操作",
String.format("%s(%s),太阳能网控属性缓存下发:%s。",
device.getDeviceName(), device.getSerialNum(), properties)
);
log.info("太阳能网控属性已缓存: userId={}, deviceId={}, serialNum={}, properties={}",
userId, device.getId(), device.getSerialNum(), properties);
return R.ok("缓存成功,设备上线后自动下发", result);
} catch (IllegalArgumentException e) {
return R.fail(e.getMessage());
} catch (Exception e) {
log.error("太阳能网控属性下发失败: {}", e.getMessage(), e);
return R.fail("太阳能网控属性下发失败: " + e.getMessage());
}
}
private Map<String, Object> normalizeSolarProperties(Map<String, Object> source) {
if (source == null || source.isEmpty()) {
throw new IllegalArgumentException("下发属性不能为空");
}
Map<String, Object> properties = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
String identifier = entry.getKey();
if (identifier == null || identifier.isBlank()) {
throw new IllegalArgumentException("属性标识符不能为空");
}
if (!SOLAR_WRITABLE_PROPERTY_KEYS.contains(identifier)) {
throw new IllegalArgumentException("属性不支持下发: " + identifier);
}
properties.put(identifier, normalizeSolarPropertyValue(identifier, entry.getValue()));
}
return properties;
}
private Object normalizeSolarPropertyValue(String identifier, Object value) {
if (value == null || (value instanceof String str && str.isBlank())) {
throw new IllegalArgumentException(identifier + " 的值不能为空");
}
return switch (identifier) {
case "correct" -> {
Integer intValue = parseInteger(value, identifier);
if (!Set.of(1, 2, 11, 12, 13, 14, 31, 32).contains(intValue)) {
throw new IllegalArgumentException("校准指令值不合法");
}
yield intValue;
}
case "PowerMode" -> requireIntegerInRange(value, identifier, 0, 2);
case "updatatime" -> requireIntegerInRange(value, identifier, 0, 65535);
case "WakeUpTime" -> requireIntegerInRange(value, identifier, 1, Integer.MAX_VALUE);
case "BootToSleepTimeout" -> requireIntegerInRange(value, identifier, 2, Integer.MAX_VALUE);
case "FirstBootToSleepTimeout" -> requireIntegerInRange(value, identifier, 3, Integer.MAX_VALUE);
case "CorrectCharge" -> normalizeCorrectCharge(value);
case "salinitySet" -> requireDecimalInRange(value, identifier, "0", "100");
case "ammonia_nitrogen" -> requireDecimalInRange(value, identifier, "0", "1000");
case "phasedif_compensation" -> requireDecimalInRange(value, identifier, "-10", "100");
case "phase_compensation" -> requireDecimalInRange(value, identifier, "0", "10");
case "temperature_compensation" -> requireDecimalInRange(value, identifier, "-10", "10");
case "stdsatphase", "stdphaseinterval" -> requireDecimalInRange(value, identifier, "0", "360");
case "SaturationPoint", "AnaerobicPoint", "LowtempCOEF", "HightempCOEF" ->
requireDecimalInRange(value, identifier, "-1000", "1000");
case "TempPobeOffSet1", "TempPobeOffSet2" -> requireDecimalInRange(value, identifier, "-12.7", "12.7");
default -> throw new IllegalArgumentException("属性不支持下发: " + identifier);
};
}
@SuppressWarnings("unchecked")
private Map<String, Object> normalizeCorrectCharge(Object value) {
if (!(value instanceof Map<?, ?> rawMap)) {
throw new IllegalArgumentException("电量校准参数格式不正确");
}
Map<String, Object> map = (Map<String, Object>) rawMap;
Map<String, Object> result = new LinkedHashMap<>();
result.put("lowvoltage", requireDecimalInRange(map.get("lowvoltage"), "lowvoltage", "0", "10"));
result.put("highvoltage", requireDecimalInRange(map.get("highvoltage"), "highvoltage", "0", "10"));
return result;
}
private Integer requireIntegerInRange(Object value, String identifier, int min, int max) {
Integer intValue = parseInteger(value, identifier);
if (intValue < min || intValue > max) {
throw new IllegalArgumentException(identifier + " 超出范围: " + min + " - " + max);
}
return intValue;
}
private Integer parseInteger(Object value, String identifier) {
try {
if (value instanceof Number number) {
double doubleValue = number.doubleValue();
if (doubleValue % 1 != 0) {
throw new IllegalArgumentException(identifier + " 必须为整数");
}
return number.intValue();
}
return Integer.valueOf(value.toString());
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException(identifier + " 必须为整数");
}
}
private Double requireDecimalInRange(Object value, String identifier, String min, String max) {
BigDecimal decimal = parseDecimal(value, identifier);
BigDecimal minDecimal = new BigDecimal(min);
BigDecimal maxDecimal = new BigDecimal(max);
if (decimal.compareTo(minDecimal) < 0 || decimal.compareTo(maxDecimal) > 0) {
throw new IllegalArgumentException(identifier + " 超出范围: " + min + " - " + max);
}
return decimal.doubleValue();
}
private BigDecimal parseDecimal(Object value, String identifier) {
if (value == null || (value instanceof String str && str.isBlank())) {
throw new IllegalArgumentException(identifier + " 的值不能为空");
}
try {
return new BigDecimal(value.toString());
} catch (Exception e) {
throw new IllegalArgumentException(identifier + " 必须为数字");
}
}
private Integer queryDeviceStatusCode(String iotId) {
if (iotDeviceService == null || iotId == null || iotId.isBlank()) {
return null;
}
try {
Map<String, Object> deviceDetail = iotDeviceService.queryDeviceInfo(iotId);
return extractDeviceStatusCode(deviceDetail);
} catch (Exception e) {
log.warn("查询设备状态失败: iotId={}, err={}", iotId, e.getMessage());
return null;
}
}
private Integer extractDeviceStatusCode(Map<String, Object> deviceDetail) {
if (deviceDetail == null || deviceDetail.get("data") == null) {
return 0;
}
Object detailData = deviceDetail.get("data");
try {
java.lang.reflect.Method getStatusMethod = detailData.getClass().getMethod("getStatus");
Object statusObj = getStatusMethod.invoke(detailData);
if (statusObj == null) {
return 0;
}
String statusStr = statusObj.toString();
if ("ONLINE".equalsIgnoreCase(statusStr) || "online".equalsIgnoreCase(statusStr)) {
return DEVICE_STATUS_ONLINE;
}
if ("OFFLINE".equalsIgnoreCase(statusStr) || "offline".equalsIgnoreCase(statusStr)) {
return DEVICE_STATUS_OFFLINE;
}
if ("DISABLE".equalsIgnoreCase(statusStr) || "disable".equalsIgnoreCase(statusStr)) {
return 8;
}
return 0;
} catch (Exception ex) {
log.warn("无法从 SDK 对象中获取 status: {}", ex.getMessage());
return 0;
}
}
private boolean cachePendingSolarProperties(Device device, Map<String, Object> properties) {
try {
Map<String, Object> mergedProperties = new LinkedHashMap<>();
String cacheKey = DeviceDataHandler.PENDING_SOLAR_PROPERTY_KEY_PREFIX + device.getSerialNum();
TenantHelper.ignore(() -> {
Object rawPending = com.intc.common.redis.utils.RedisUtils.getCacheObject(cacheKey);
if (rawPending != null) {
try {
cn.hutool.json.JSONObject pending = cn.hutool.json.JSONUtil.parseObj(rawPending);
Object propertiesObj = pending.get("properties");
if (propertiesObj != null) {
cn.hutool.json.JSONObject propertyJson = cn.hutool.json.JSONUtil.parseObj(propertiesObj);
for (String key : propertyJson.keySet()) {
mergedProperties.put(key, propertyJson.get(key));
}
}
} catch (Exception e) {
log.warn("解析旧太阳能属性缓存失败,将用新值覆盖: serialNum={}, err={}",
device.getSerialNum(), e.getMessage());
}
}
});
mergedProperties.putAll(properties);
Map<String, Object> cacheValue = new LinkedHashMap<>();
cacheValue.put("deviceId", device.getId());
cacheValue.put("iotId", device.getIotId());
cacheValue.put("serialNum", device.getSerialNum());
cacheValue.put("properties", mergedProperties);
cacheValue.put("updatedAt", System.currentTimeMillis());
String cacheJson = cn.hutool.json.JSONUtil.toJsonStr(cacheValue);
TenantHelper.ignore(() ->
com.intc.common.redis.utils.RedisUtils.setCacheObject(cacheKey, cacheJson, Duration.ofDays(7))
);
return true;
} catch (Exception e) {
log.error("缓存太阳能网控属性失败: deviceId={}, serialNum={}, err={}",
device.getId(), device.getSerialNum(), e.getMessage(), e);
return false;
}
}
private void removePendingSolarProperties(Device device, Set<String> identifiers) {
if (device == null || device.getSerialNum() == null || device.getSerialNum().isBlank()
|| identifiers == null || identifiers.isEmpty()) {
return;
}
try {
String cacheKey = DeviceDataHandler.PENDING_SOLAR_PROPERTY_KEY_PREFIX + device.getSerialNum();
TenantHelper.ignore(() -> {
Object rawPending = com.intc.common.redis.utils.RedisUtils.getCacheObject(cacheKey);
if (rawPending == null) {
return;
}
cn.hutool.json.JSONObject pending = cn.hutool.json.JSONUtil.parseObj(rawPending);
Object propertiesObj = pending.get("properties");
if (propertiesObj == null) {
com.intc.common.redis.utils.RedisUtils.deleteObject(cacheKey);
return;
}
cn.hutool.json.JSONObject propertyJson = cn.hutool.json.JSONUtil.parseObj(propertiesObj);
Map<String, Object> remainingProperties = new LinkedHashMap<>();
for (String key : propertyJson.keySet()) {
if (!identifiers.contains(key)) {
remainingProperties.put(key, propertyJson.get(key));
}
}
if (remainingProperties.isEmpty()) {
com.intc.common.redis.utils.RedisUtils.deleteObject(cacheKey);
return;
}
Map<String, Object> cacheValue = new LinkedHashMap<>();
cacheValue.put("deviceId", device.getId());
cacheValue.put("iotId", device.getIotId());
cacheValue.put("serialNum", device.getSerialNum());
cacheValue.put("properties", remainingProperties);
cacheValue.put("updatedAt", System.currentTimeMillis());
com.intc.common.redis.utils.RedisUtils.setCacheObject(
cacheKey,
cn.hutool.json.JSONUtil.toJsonStr(cacheValue),
Duration.ofDays(7)
);
});
} catch (Exception e) {
log.warn("清理太阳能网控已下发属性缓存失败: serialNum={}, identifiers={}, err={}",
device.getSerialNum(), identifiers, e.getMessage());
}
}
private void syncDeviceFieldsAfterSolarCommand(Long deviceId, Map<String, Object> properties) {
if (deviceId == null || properties == null || properties.isEmpty()) {
return;
}
Device updateDevice = new Device();
updateDevice.setId(deviceId);
boolean needUpdate = false;
if (properties.containsKey("salinitySet")) {
updateDevice.setSalinityCompensation(toDouble(properties.get("salinitySet")));
needUpdate = true;
}
if (properties.containsKey("phase_compensation")) {
updateDevice.setPhaseCompensation(toDouble(properties.get("phase_compensation")));
needUpdate = true;
}
if (properties.containsKey("phasedif_compensation")) {
updateDevice.setPhasedifCompensation(toDouble(properties.get("phasedif_compensation")));
needUpdate = true;
}
if (properties.containsKey("temperature_compensation")) {
updateDevice.setTemperatureCompensation(toDouble(properties.get("temperature_compensation")));
needUpdate = true;
}
if (needUpdate) {
deviceMapper.updateById(updateDevice);
}
}
private Double toDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
try {
return Double.valueOf(value.toString());
} catch (Exception e) {
return null;
}
}
/**
* 修改设备输入电压类型
*
@@ -2697,12 +2086,84 @@ public class IotController extends BaseController {
@PutMapping("/switch/turn_switch")
public R<Void> turnSwitch(@RequestBody ReqTurnOpen request) {
try {
if (timingCtrlExecuteService == null) {
if (deviceSwitchMapper == null || deviceMapper == null) {
return R.fail("系统配置未完成");
}
SwitchTurnResult result = timingCtrlExecuteService.executeSwitch(
request.getId(), request.getIsOpen(), "开关操作", null, false);
return result.isSuccess() ? R.ok() : R.fail(result.getMessage());
Long userId = LoginHelper.getUserId();
Long switchId = request.getId();
Integer targetStatus = request.getIsOpen();
// 查询开关信息
DeviceSwitch deviceSwitch = deviceSwitchMapper.selectById(switchId);
if (deviceSwitch == null) {
return R.fail("开关不存在");
}
// 查询关联的设备信息
Device device = deviceMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
.eq(Device::getId, deviceSwitch.getDeviceId())
.select(Device::getId, Device::getUserId, Device::getIotId, Device::getSerialNum,
Device::getDeviceName, Device::getWarnCode, Device::getDeadTime)
);
if (device == null) {
return R.fail("开关不存在或无权限访问");
}
// 检查设备是否到期
if (device.getDeadTime() != null && new Date().after(device.getDeadTime())) {
return R.fail("设备服务已过期");
}
// 防抖处理检查距离上次操作是否小于3秒
if (deviceSwitch.getLastTurnTime() != null) {
long timeDiff = System.currentTimeMillis() - deviceSwitch.getLastTurnTime().getTime();
if (timeDiff < 3000) {
return R.fail("操作过于频繁,请稍后再试");
}
}
// 如果开关状态已经是目标状态,无需操作
if (deviceSwitch.getIsOpen() != null && deviceSwitch.getIsOpen().equals(targetStatus)) {
return R.ok();
}
// 检查设备是否离线warnCode为99表示离线
if (device.getWarnCode() == null || device.getWarnCode() == 99) {
return R.fail("设备离线或已关机");
}
// 检查IoT云服务是否可用
if (iotCloudService == null) {
return R.fail("物联网服务未启用");
}
// 构造IoT属性
Map<String, Object> properties = new java.util.HashMap<>();
properties.put(IOTPropertyName.SWITCH_INDEX + deviceSwitch.getIndex(), targetStatus);
// 调用物联网服务设置属性
boolean success = iotCloudService.setProperty(device.getIotId(), properties, true, 2);
if (!success) {
return R.fail("设置开关失败");
}
// 更新数据库中的开关状态和最后操作时间
deviceSwitchMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getId, switchId)
.set(DeviceSwitch::getIsOpen, targetStatus)
.set(DeviceSwitch::getLastTurnTime, new Date())
);
// 记录操作日志
String operation = targetStatus == 1 ? "开启" : "关闭";
log.info("开关操作:{}({})的开关{}{}",
device.getDeviceName(), device.getSerialNum(), deviceSwitch.getSwitchName(), operation);
return R.ok();
} catch (Exception e) {
log.error("开关控制失败: {}", e.getMessage(), e);
return R.fail("开关控制失败: " + e.getMessage());
@@ -3107,8 +2568,58 @@ public class IotController extends BaseController {
return R.ok();
}
// 注意定时任务绑定在开关维度TimingCtrl 仅含 switchId不随塘口变化。
// 切换塘口只更新归属,不清空设备端 localTimer、不关开关、不删除定时任务记录。
// 如果开关原本有塘口,需要清理定时控制
if (oldPondId != null) {
// 查询设备的IotId
Device device = deviceMapper.selectById(vo.getDeviceId());
if (device != null && com.intc.common.core.utils.StringUtils.isNotBlank(device.getIotId())) {
// 检查设备是否在线通过判断warnCode判断设备状态
boolean isOnline = device.getWarnCode() != null && device.getWarnCode() != 99;
if (isOnline && iotCloudService != null) {
try {
// 构造清空定时控制的属性
Map<String, Object> properties = new java.util.HashMap<>();
// 清空定时器localTimer_switch{Index}
properties.put("localTimer_switch" + vo.getIndex(), java.util.Collections.emptyList());
// 如果开关是打开状态,需要关闭它
if (vo.getIsOpen() != null && vo.getIsOpen() == 1) {
// 关闭开关Switch{Index} = 0
properties.put(IOTPropertyName.SWITCH_INDEX + vo.getIndex(), 0);
// 更新数据库中的开关状态
deviceSwitchMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getId, switchId)
.set(DeviceSwitch::getIsOpen, 0)
);
}
// 调用物联网服务设置属性
boolean success = iotCloudService.setProperty(device.getIotId(), properties, false, 0);
if (success) {
log.info("成功通过物联网服务清空开关定时控制和关闭开关, switchId={}", switchId);
} else {
log.warn("通过物联网服务清空开关定时控制失败, switchId={}", switchId);
}
} catch (Exception e) {
log.error("调用物联网服务失败, switchId={}", switchId, e);
}
}
}
// 删除该开关的所有定时控制记录
if (timingCtrlMapper != null) {
timingCtrlMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TimingCtrl>()
.eq(TimingCtrl::getSwitchId, switchId)
);
}
log.info("已清除开关ID={}的定时控制数据", switchId);
}
// 处理新塘口分配
if (pondId != null && pondId > 0) {
@@ -3317,8 +2828,8 @@ public class IotController extends BaseController {
return R.fail("设备服务已过期");
}
// // 检查设备是否离线
// if (device.getWarnCode() != null && (device.getWarnCode() & DefineDeviceWarnCode.DeviceOffline) != 0) {
// // 检查设备是否离线warnCode & 0x0080
// if (device.getWarnCode() != null && (device.getWarnCode() & 0x0080) != 0) {
// return R.fail("设备离线或关机");
// }
@@ -3624,8 +3135,8 @@ public class IotController extends BaseController {
return R.fail("设备服务已过期");
}
// // 检查设备是否离线
// if (device.getWarnCode() != null && (device.getWarnCode() & DefineDeviceWarnCode.DeviceOffline) != 0) {
// // 检查设备是否离线warnCode & 0x0080
// if (device.getWarnCode() != null && (device.getWarnCode() & 0x0080) != 0) {
// return R.fail("设备离线或关机");
// }

View File

@@ -1,43 +0,0 @@
package com.intc.iot.domain.bo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 太阳能网控属性下发请求对象
*
* @author intc
*/
@Data
@Schema(description = "太阳能网控属性下发请求对象")
public class DeviceSolarPropertyCommandBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备ID
*/
@Schema(description = "设备ID")
@NotNull(message = "设备ID不能为空")
private Long id;
/**
* 待下发属性key 为物模型属性标识符
*/
@Schema(description = "待下发属性")
@NotEmpty(message = "下发属性不能为空")
private Map<String, Object> properties;
/**
* 是否缓存下发。true=写入缓存设备上线后自动补发false=立即下发
*/
@Schema(description = "是否缓存下发")
private Boolean cache;
}

View File

@@ -32,7 +32,6 @@ import com.intc.tdengine.domain.DeviceSensorData;
import com.intc.tdengine.service.IDeviceSensorDataService;
import com.intc.iot.config.AliyunIotProperties;
import com.intc.common.redis.utils.RedisUtils;
import com.intc.common.tenant.helper.TenantHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -41,15 +40,10 @@ import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 设备数据处理器
@@ -62,28 +56,6 @@ import java.util.concurrent.TimeUnit;
@ConditionalOnBean(IDeviceSensorDataService.class)
public class DeviceDataHandler {
public static final String PENDING_SOLAR_PROPERTY_KEY_PREFIX = "device:pending:solar:property:";
private static final String PENDING_SOLAR_PROPERTY_FLUSH_LOCK_PREFIX = "device:pending:solar:property:flush:";
private static final int DEVICE_TYPE_SOLAR_CONTROLLER = 3;
/**
* 太阳能网控上线后缓存属性下发的延迟时间(毫秒)
* 设备上线后 MQTT 订阅需要一定时间完成,延迟后再下发设置避免失败
*/
private static final long SOLAR_PROPERTY_DELAY_MS = 5000L;
/**
* 盐度设定防回写冻结标记 Redis key 前缀
* value = 用户设定的盐度值TTL = {@link #SALINITY_FREEZE_MINUTES} 分钟
* 用户修改盐度后写入此标记,冻结期内设备上报的旧值不会回写 DB避免客户误判修改未生效
*/
public static final String SALINITY_SET_FREEZE_KEY_PREFIX = "device:salinity:set:freeze:";
/**
* 盐度设定冻结时长(分钟),与设备上报周期匹配,超时后恢复正常回写逻辑
*/
public static final int SALINITY_FREEZE_MINUTES = 30;
/**
* TDengine 服务,用于存储设备时序数据
*/
@@ -149,11 +121,6 @@ public class DeviceDataHandler {
*/
private final AliyunIotProperties aliyunIotProperties;
/**
* 调度线程池,用于延迟执行任务
*/
private final ScheduledExecutorService scheduledExecutorService;
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
@@ -269,12 +236,6 @@ public class DeviceDataHandler {
handleSensorErrorCode(deviceName, params);
}
// 太阳能网控在线窗口很短,先处理盐度设定值,再尽快补发缓存盐度。
if (params != null) {
handleSalinitySet(deviceName, params);
}
// flushPendingSolarProperties(deviceName, "属性上报");
if (params != null && params.size() > 0) {
try {
// 如果是控制器,需要区分处理
@@ -332,7 +293,7 @@ public class DeviceDataHandler {
log.error("保存数据失败[{}]: {}", deviceName, e.getMessage());
}
}
//
// 检查是否触发报警
if (params != null && sensorData != null) {
checkAndTriggerAlarm(productKey, deviceName, sensorData);
@@ -987,272 +948,11 @@ public class DeviceDataHandler {
log.info("[离线告警] 设备已上线,取消离线告警: {}", deviceName);
}
// 太阳能网控上线后延迟下发缓存属性设备MQTT订阅需要一定时间完成立即下发可能失败
scheduledExecutorService.schedule(
() -> flushPendingSolarProperties(deviceName, "生命周期上线"),
SOLAR_PROPERTY_DELAY_MS, TimeUnit.MILLISECONDS);
// 同步更新 Device.warnCode清除离线和休眠标志位
setDeviceWarnCodeBit(deviceName, DefineDeviceWarnCode.DeviceOffline, false);
setDeviceWarnCodeBit(deviceName, DefineDeviceWarnCode.DeviceSleeping, false);
}
private Device findDeviceByOnlineName(String deviceName) {
if (StrUtil.isBlank(deviceName)) {
return null;
}
return deviceMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
.and(wrapper -> wrapper
.eq(Device::getSerialNum, deviceName)
.or()
.eq(Device::getIotId, deviceName)
)
.select(Device::getId, Device::getIotId, Device::getSerialNum, Device::getDeviceType)
.last("LIMIT 1")
);
}
private void flushPendingSolarProperties(String deviceName, String trigger) {
Device device = findDeviceByOnlineName(deviceName);
if (device == null) {
log.debug("[太阳能属性] 上线设备未绑定,跳过补发: deviceName={}", deviceName);
return;
}
if (!Integer.valueOf(DEVICE_TYPE_SOLAR_CONTROLLER).equals(device.getDeviceType())) {
log.debug("[太阳能属性] 非太阳能网控设备,跳过补发: deviceName={}, deviceId={}, deviceType={}",
deviceName, device.getId(), device.getDeviceType());
return;
}
if (StrUtil.isBlank(device.getSerialNum())) {
log.debug("[太阳能属性] 上线设备编号为空,跳过补发: deviceName={}, deviceId={}, iotId={}",
deviceName, device.getId(), device.getIotId());
return;
}
String flushLockKey = PENDING_SOLAR_PROPERTY_FLUSH_LOCK_PREFIX + device.getSerialNum();
boolean locked = false;
try {
locked = RedisUtils.setObjectIfAbsent(flushLockKey, "1", Duration.ofSeconds(30));
if (!locked) {
log.debug("[太阳能属性] 已有补发任务执行中,跳过重复补发: deviceName={}, deviceId={}",
deviceName, device.getId());
return;
}
PendingSolarProperty pendingProperty = findPendingSolarProperty(device);
if (pendingProperty == null) {
logNoPendingSolarProperty(trigger, deviceName, device);
return;
}
if (pendingProperty.iotId == null || pendingProperty.deviceId == null || pendingProperty.properties == null) {
log.warn("[太阳能属性] 缓存数据不完整,跳过补发: deviceName={}, cacheKey={}",
deviceName, pendingProperty.redisKey);
return;
}
if (pendingProperty.properties.isEmpty()) {
log.warn("[太阳能属性] 缓存属性为空,跳过补发: deviceName={}, cacheKey={}",
deviceName, pendingProperty.redisKey);
return;
}
log.info("[太阳能属性] 太阳能网控{},准备补发缓存属性: deviceName={}, cacheKey={}, deviceId={}, properties={}",
trigger, deviceName, pendingProperty.redisKey, pendingProperty.deviceId, pendingProperty.properties);
boolean success = iotCloudService.setProperty(pendingProperty.iotId, pendingProperty.properties, true, 2);
if (!success) {
log.warn("[太阳能属性] {}补发失败,保留缓存: deviceName={}, deviceId={}, properties={}",
trigger, deviceName, pendingProperty.deviceId, pendingProperty.properties);
return;
}
syncDeviceFieldsAfterPropertySet(pendingProperty.deviceId, pendingProperty.properties);
// 补发成功后,如果包含盐度设定,写入冻结标记
setSalinityFreezeIfNeeded(deviceName, pendingProperty.properties);
clearPendingSolarProperty(pendingProperty);
log.info("[太阳能属性] {}补发成功并清除缓存: deviceName={}, cacheKey={}, deviceId={}, properties={}",
trigger, deviceName, pendingProperty.redisKey, pendingProperty.deviceId, pendingProperty.properties);
} catch (Exception e) {
log.error("[太阳能属性] {}补发异常: deviceName={}, err={}", trigger, deviceName, e.getMessage(), e);
} finally {
if (locked) {
try {
RedisUtils.deleteObject(flushLockKey);
} catch (Exception e) {
log.warn("[太阳能属性] 释放补发锁失败: deviceName={}, lockKey={}, err={}",
deviceName, flushLockKey, e.getMessage());
}
}
}
}
private PendingSolarProperty findPendingSolarProperty(Device device) {
String serialNum = device.getSerialNum();
if (StrUtil.isBlank(serialNum)) {
return null;
}
return TenantHelper.ignore(() -> {
String redisKey = PENDING_SOLAR_PROPERTY_KEY_PREFIX + serialNum;
Object rawPending = RedisUtils.getCacheObject(redisKey);
if (rawPending == null) {
return null;
}
Long deviceId = device.getId();
String iotId = device.getIotId();
Map<String, Object> properties = new LinkedHashMap<>();
try {
JSONObject pending = JSONUtil.parseObj(rawPending);
Long cachedDeviceId = pending.getLong("deviceId");
String cachedIotId = pending.getStr("iotId");
if (cachedDeviceId != null) {
deviceId = cachedDeviceId;
}
if (StrUtil.isNotBlank(cachedIotId)) {
iotId = cachedIotId;
}
Object propertiesObj = pending.get("properties");
if (propertiesObj != null) {
JSONObject propertiesJson = JSONUtil.parseObj(propertiesObj);
for (String key : propertiesJson.keySet()) {
properties.put(key, propertiesJson.get(key));
}
}
} catch (Exception e) {
log.warn("[太阳能属性] 解析缓存属性失败: serialNum={}, err={}", serialNum, e.getMessage());
}
return new PendingSolarProperty(redisKey, deviceId, iotId, properties);
});
}
private boolean hasPendingSolarProperty(Device device, String identifier) {
if (device == null || StrUtil.isBlank(identifier)) {
return false;
}
PendingSolarProperty pendingProperty = findPendingSolarProperty(device);
return pendingProperty != null
&& pendingProperty.properties != null
&& pendingProperty.properties.containsKey(identifier);
}
private void logNoPendingSolarProperty(String trigger, String deviceName, Device device) {
if ("生命周期上线".equals(trigger)) {
log.info("[太阳能属性] 太阳能网控{},未找到待补发缓存: deviceName={}, deviceId={}, serialNum={}, iotId={}",
trigger, deviceName, device.getId(), device.getSerialNum(), device.getIotId());
return;
}
log.debug("[太阳能属性] 太阳能网控{},未找到待补发缓存: deviceName={}, deviceId={}, serialNum={}, iotId={}",
trigger, deviceName, device.getId(), device.getSerialNum(), device.getIotId());
}
private void clearPendingSolarProperty(PendingSolarProperty pendingProperty) {
if (pendingProperty == null) {
return;
}
TenantHelper.ignore(() -> {
if (StrUtil.isNotBlank(pendingProperty.redisKey)) {
RedisUtils.deleteObject(pendingProperty.redisKey);
}
});
}
private void syncDeviceFieldsAfterPropertySet(Long deviceId, Map<String, Object> properties) {
if (deviceId == null || properties == null || properties.isEmpty()) {
return;
}
Device updateDevice = new Device();
updateDevice.setId(deviceId);
boolean needUpdate = false;
if (properties.containsKey("salinitySet")) {
Double value = toDouble(properties.get("salinitySet"));
if (value != null) {
updateDevice.setSalinityCompensation(value);
needUpdate = true;
}
}
if (properties.containsKey("phase_compensation")) {
Double value = toDouble(properties.get("phase_compensation"));
if (value != null) {
updateDevice.setPhaseCompensation(value);
needUpdate = true;
}
}
if (properties.containsKey("phasedif_compensation")) {
Double value = toDouble(properties.get("phasedif_compensation"));
if (value != null) {
updateDevice.setPhasedifCompensation(value);
needUpdate = true;
}
}
if (properties.containsKey("temperature_compensation")) {
Double value = toDouble(properties.get("temperature_compensation"));
if (value != null) {
updateDevice.setTemperatureCompensation(value);
needUpdate = true;
}
}
if (needUpdate) {
deviceMapper.updateById(updateDevice);
}
}
private Double toDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
try {
return Double.valueOf(value.toString());
} catch (Exception e) {
return null;
}
}
/**
* 如果属性中包含 salinitySet写入盐度冻结标记到 Redis
* 冻结期内设备上报的旧值不会被回写 DB
*
* @param deviceName 设备名称serialNum
* @param properties 下发的属性
*/
private void setSalinityFreezeIfNeeded(String deviceName, Map<String, Object> properties) {
if (StrUtil.isBlank(deviceName) || properties == null || !properties.containsKey("salinitySet")) {
return;
}
Double value = toDouble(properties.get("salinitySet"));
if (value == null) {
return;
}
String freezeKey = SALINITY_SET_FREEZE_KEY_PREFIX + deviceName;
RedisUtils.setCacheObject(freezeKey, value, Duration.ofMinutes(SALINITY_FREEZE_MINUTES));
log.info("[盐度冻结] 已设置冻结标记: deviceName={}, value={}, freezeMinutes={}",
deviceName, value, SALINITY_FREEZE_MINUTES);
}
private static class PendingSolarProperty {
private final String redisKey;
private final Long deviceId;
private final String iotId;
private final Map<String, Object> properties;
private PendingSolarProperty(String redisKey, Long deviceId, String iotId, Map<String, Object> properties) {
this.redisKey = redisKey;
this.deviceId = deviceId;
this.iotId = iotId;
this.properties = properties;
}
}
/**
* 太阳能网控设备离线超时:从休眠状态转为真正离线
* 置 DeviceOffline 位,清 DeviceSleeping 位
@@ -1802,12 +1502,8 @@ public class DeviceDataHandler {
* @param params 事件参数
*/
private void triggerDeviceErrorAlarm(String deviceName, JSONObject params) {
int errorCode = params != null && params.containsKey("errorCode")
? params.getInt("errorCode", 0) : 0;
boolean onlyRecordAlarm = isNoCallControllerFault(errorCode);
// 全局电话通知开关检查
if (!aliyunIotProperties.isCallNoticeEnabled() && !onlyRecordAlarm) {
if (!aliyunIotProperties.isCallNoticeEnabled()) {
log.debug("[设备故障] 电话通知已全局关闭,跳过: {}", deviceName);
return;
}
@@ -1837,28 +1533,32 @@ public class DeviceDataHandler {
return;
}
String pondName = getPondName(device.getPondId());
if (device.getPondId() == null) {
log.debug("[设备故障] 设备未绑定塘口,跳过: {}", deviceName);
return;
}
Pond pond = pondMapper.selectById(device.getPondId());
if (pond == null) {
log.debug("[设备故障] 塘口不存在,跳过: {}", deviceName);
return;
}
String pondName = pond.getPondName();
String displayName = device.getDeviceName() != null ? device.getDeviceName() : deviceName;
// 解析事件参数获取故障码
int errorCode = params != null && params.containsKey("errorCode")
? params.getInt("errorCode", 0) : 0;
String errorDesc = DefineDeviceErrorCode.getErrorMessage(errorCode);
if (StrUtil.isBlank(errorDesc)) {
errorDesc = "设备故障(code=" + errorCode + ")";
}
String warnMsg = formatPondWarnMessage(pondName,
String.format("%s(%s)发生故障:%s", displayName, deviceName, errorDesc));
String warnMsg = String.format("%s塘口中%s(%s)发生故障:%s",
pondName, displayName, deviceName, errorDesc);
log.error("[设备故障] 设备: {}, 故障信息: {}", deviceName, warnMsg);
if (onlyRecordAlarm) {
String errorTitle = "控制器故障";
MessageWarn warn = createMessageWarn(deviceId, userId, pondName, errorTitle, WARN_TYPE_DISSOLVED_OXYGEN, warnMsg);
messageWarnMapper.insert(warn);
log.info("[设备故障] 欠压/欠流故障仅记录报警,不创建电话通知 - 设备:{}, 故障码:{}",
deviceName, errorCode);
return;
}
// 获取告警手机号
AquUser aquUser = aquUserMapper.selectById(userId);
if (aquUser == null) {
@@ -1963,8 +1663,7 @@ public class DeviceDataHandler {
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
.eq(Device::getSerialNum, deviceName)
.select(Device::getId, Device::getSerialNum, Device::getDeviceName, Device::getWarnCode,
Device::getPondId, Device::getUserId, Device::getDeadTime,
Device::getControllerFaultCallOpen)
Device::getPondId, Device::getUserId, Device::getDeadTime)
.last("LIMIT 1")
);
if (device == null) {
@@ -2027,22 +1726,26 @@ public class DeviceDataHandler {
}
// 查询塘口(如果设备在塘口中)
String pondName = getPondName(device.getPondId());
String pondName = null;
if (device.getPondId() != null) {
Pond pond = pondMapper.selectById(device.getPondId());
if (pond != null) {
pondName = pond.getPondName();
}
}
String displayName = device.getDeviceName() != null ? device.getDeviceName() : device.getSerialNum();
// 非离线状态才触发告警;未绑定塘口时仍告警,告警内容不带塘口前缀
// 设备在塘口中且非山机才触发告警
boolean isOffline = device.getWarnCode() != null
&& (device.getWarnCode() & DefineDeviceWarnCode.DeviceOffline) != 0;
boolean warnTrigger = !isOffline;
boolean warnTrigger = !isOffline && pondName != null;
// 根据故障码范围分类生成告警内容
String title = null;
String message = null;
String messagePondName = pondName;
int switchIndex = 0;
// 告警类型:默认为控制器告警(3),开关相关故障使用开关告警(4)
int warnType = WARN_TYPE_CONTROLLER;
boolean powerOffError = false;
if (errorCode >= DefineDeviceErrorCode.Three_InputLosePhaseA
&& errorCode <= DefineDeviceErrorCode.Three_InputLosePhaseC) {
@@ -2053,8 +1756,8 @@ public class DeviceDataHandler {
}
if (warnTrigger) {
title = "控制器输入缺相";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)输入缺%s相", displayName, device.getSerialNum(), phaseName));
message = String.format("%s塘口中%s(%s)输入缺%s相",
pondName, displayName, device.getSerialNum(), phaseName);
}
} else if (errorCode >= DefineDeviceErrorCode.Three_PhaseA_OverVoltage
&& errorCode <= DefineDeviceErrorCode.Three_PhaseC_UnderVoltage) {
@@ -2064,9 +1767,8 @@ public class DeviceDataHandler {
String phaseName = DefineDeviceErrorCode.getPhaseName(phaseIndex);
if (warnTrigger) {
title = isOver ? "控制器输入过压" : "控制器输入欠压";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)%s相%s",
displayName, device.getSerialNum(), phaseName, isOver ? "过压" : "欠压"));
message = String.format("%s塘口中%s(%s)%s相%s",
pondName, displayName, device.getSerialNum(), phaseName, isOver ? "过压" : "欠压");
}
} else if (errorCode >= DefineDeviceErrorCode.Three_Switch1LosePhaseA
&& errorCode <= DefineDeviceErrorCode.Three_Switch4LosePhaseC) {
@@ -2081,12 +1783,10 @@ public class DeviceDataHandler {
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null)
? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出缺相";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出缺%s相",
displayName, device.getSerialNum(), switchIndex, swName, phaseName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出缺%s相",
swPondName, displayName, device.getSerialNum(), switchIndex, swName, phaseName);
}
} else if (errorCode >= DefineDeviceErrorCode.Three_Switch1OverElectricA
&& errorCode <= DefineDeviceErrorCode.Three_Switch4OverElectricC) {
@@ -2096,14 +1796,14 @@ public class DeviceDataHandler {
String phaseName = DefineDeviceErrorCode.getPhaseName(tempIndex % 3);
switchIndex = tempIndex / 3 + 1;
DeviceSwitch sw = getDeviceSwitchByIndex(deviceId, switchIndex);
boolean electricWarnOpen = sw != null && sw.getElectricWarnOpen() != null && sw.getElectricWarnOpen() == 1;
warnTrigger = warnTrigger && electricWarnOpen;
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
String swPondName = (sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
String swName = sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出过流";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出%s相过流",
displayName, device.getSerialNum(), switchIndex, swName, phaseName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出%s相过流",
swPondName, displayName, device.getSerialNum(), switchIndex, swName, phaseName);
}
} else if (errorCode >= DefineDeviceErrorCode.Three_Switch1UnderElectricA
&& errorCode <= DefineDeviceErrorCode.Three_Switch4UnderElectricC) {
@@ -2113,14 +1813,14 @@ public class DeviceDataHandler {
String phaseName = DefineDeviceErrorCode.getPhaseName(tempIndex % 3);
switchIndex = tempIndex / 3 + 1;
DeviceSwitch sw = getDeviceSwitchByIndex(deviceId, switchIndex);
boolean electricWarnOpen = sw != null && sw.getElectricWarnOpen() != null && sw.getElectricWarnOpen() == 1;
warnTrigger = warnTrigger && electricWarnOpen;
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
String swPondName = (sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
String swName = sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出欠流";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出%s相欠流",
displayName, device.getSerialNum(), switchIndex, swName, phaseName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出%s相欠流",
swPondName, displayName, device.getSerialNum(), switchIndex, swName, phaseName);
}
} else if (errorCode >= DefineDeviceErrorCode.Three_Switch1ElectricEmpty
&& errorCode <= DefineDeviceErrorCode.Three_Switch4ElectricEmpty) {
@@ -2128,14 +1828,14 @@ public class DeviceDataHandler {
warnType = WARN_TYPE_SWITCH;
switchIndex = errorCode - DefineDeviceErrorCode.Three_Switch1ElectricEmpty + 1;
DeviceSwitch sw = getDeviceSwitchByIndex(deviceId, switchIndex);
boolean electricWarnOpen = sw != null && sw.getElectricWarnOpen() != null && sw.getElectricWarnOpen() == 1;
warnTrigger = warnTrigger && electricWarnOpen;
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
String swPondName = (sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
String swName = sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出空载";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出空载",
displayName, device.getSerialNum(), switchIndex, swName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出空载",
swPondName, displayName, device.getSerialNum(), switchIndex, swName);
}
} else if (errorCode >= DefineDeviceErrorCode.One_Switch1OverElectric
&& errorCode <= DefineDeviceErrorCode.One_Switch4OverElectric) {
@@ -2143,14 +1843,14 @@ public class DeviceDataHandler {
warnType = WARN_TYPE_SWITCH;
switchIndex = errorCode - DefineDeviceErrorCode.One_Switch1OverElectric + 1;
DeviceSwitch sw = getDeviceSwitchByIndex(deviceId, switchIndex);
boolean electricWarnOpen = sw != null && sw.getElectricWarnOpen() != null && sw.getElectricWarnOpen() == 1;
warnTrigger = warnTrigger && electricWarnOpen;
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
String swPondName = (sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
String swName = sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出过流";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出过流",
displayName, device.getSerialNum(), switchIndex, swName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出过流",
swPondName, displayName, device.getSerialNum(), switchIndex, swName);
}
} else if (errorCode >= DefineDeviceErrorCode.One_Switch1UnderElectric
&& errorCode <= DefineDeviceErrorCode.One_Switch4UnderElectric) {
@@ -2158,14 +1858,14 @@ public class DeviceDataHandler {
warnType = WARN_TYPE_SWITCH;
switchIndex = errorCode - DefineDeviceErrorCode.One_Switch1UnderElectric + 1;
DeviceSwitch sw = getDeviceSwitchByIndex(deviceId, switchIndex);
boolean electricWarnOpen = sw != null && sw.getElectricWarnOpen() != null && sw.getElectricWarnOpen() == 1;
warnTrigger = warnTrigger && electricWarnOpen;
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
String swPondName = (sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
String swName = sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出欠流";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出欠流",
displayName, device.getSerialNum(), switchIndex, swName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出欠流",
swPondName, displayName, device.getSerialNum(), switchIndex, swName);
}
} else if (errorCode >= DefineDeviceErrorCode.One_Switch1ElectricEmpty
&& errorCode <= DefineDeviceErrorCode.One_Switch4ElectricEmpty) {
@@ -2173,17 +1873,16 @@ public class DeviceDataHandler {
warnType = WARN_TYPE_SWITCH;
switchIndex = errorCode - DefineDeviceErrorCode.One_Switch1ElectricEmpty + 1;
DeviceSwitch sw = getDeviceSwitchByIndex(deviceId, switchIndex);
boolean electricWarnOpen = sw != null && sw.getElectricWarnOpen() != null && sw.getElectricWarnOpen() == 1;
warnTrigger = warnTrigger && electricWarnOpen;
if (warnTrigger) {
String swPondName = (sw != null && sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
messagePondName = swPondName;
String swName = sw != null && sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
String swPondName = (sw.getPondId() != null) ? getPondName(sw.getPondId()) : pondName;
String swName = sw.getSwitchName() != null ? sw.getSwitchName() : ("开关" + switchIndex);
title = "控制器开关输出空载";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)的开关%d(%s)输出空载",
displayName, device.getSerialNum(), switchIndex, swName));
message = String.format("%s塘口中%s(%s)的开关%d(%s)输出空载",
swPondName, displayName, device.getSerialNum(), switchIndex, swName);
}
} else if (errorCode == DefineDeviceErrorCode.PowerOff) {
powerOffError = true;
// 断电:已存在离线状态则不触发断电告警
boolean alreadyOffline = device.getWarnCode() != null
&& (device.getWarnCode() & DefineDeviceWarnCode.DeviceOffline) != 0;
@@ -2192,8 +1891,8 @@ public class DeviceDataHandler {
setDeviceWarnCodeBit(device.getSerialNum(), DefineDeviceWarnCode.ErrorPowerOff, true);
if (warnTrigger) {
title = "控制器断电";
message = formatPondWarnMessage(messagePondName,
String.format("%s(%s)断电", displayName, device.getSerialNum()));
message = String.format("%s塘口中%s(%s)断电",
pondName, displayName, device.getSerialNum());
}
} else {
warnTrigger = false;
@@ -2203,21 +1902,11 @@ public class DeviceDataHandler {
if (warnTrigger && title != null && message != null) {
Long userId = device.getUserId();
if (userId != null) {
MessageWarn warn = createMessageWarn(deviceId, userId, messagePondName, title, warnType, message);
MessageWarn warn = createMessageWarn(deviceId, userId, pondName, title, warnType, message);
messageWarnMapper.insert(warn);
boolean controllerFaultCallOpen = device.getControllerFaultCallOpen() == null
|| device.getControllerFaultCallOpen() == 1;
boolean noCallControllerFault = isNoCallControllerFault(errorCode);
boolean shouldCreateCallNotice = !noCallControllerFault && (powerOffError || controllerFaultCallOpen);
// 创建电话通知记录:断电不受控制器故障电话开关限制,其他故障受该开关限制。
if (aliyunIotProperties.isCallNoticeEnabled() && shouldCreateCallNotice) {
createCallNotices(device, messagePondName, warn);
} else if (noCallControllerFault) {
log.info("[故障码] 设备={} 欠压/欠流故障仅记录报警,不创建电话通知: title={}, errorCode={}",
device.getSerialNum(), title, errorCode);
} else if (!shouldCreateCallNotice) {
log.info("[故障码] 设备={} 控制器故障电话报警开关关闭,仅记录报警消息: title={}",
device.getSerialNum(), title);
// 创建电话通知记录(全局开关控制)
if (aliyunIotProperties.isCallNoticeEnabled()) {
createCallNotices(device, pondName, warn);
}
}
}
@@ -2234,26 +1923,6 @@ public class DeviceDataHandler {
}
}
/**
* 欠压和欠流故障只保留报警记录,不创建报警电话。
*/
private boolean isNoCallControllerFault(int errorCode) {
return isControllerInputUnderVoltage(errorCode) || isSwitchUnderElectric(errorCode);
}
private boolean isControllerInputUnderVoltage(int errorCode) {
return errorCode == DefineDeviceErrorCode.Three_PhaseA_UnderVoltage
|| errorCode == DefineDeviceErrorCode.Three_PhaseB_UnderVoltage
|| errorCode == DefineDeviceErrorCode.Three_PhaseC_UnderVoltage;
}
private boolean isSwitchUnderElectric(int errorCode) {
return (errorCode >= DefineDeviceErrorCode.Three_Switch1UnderElectricA
&& errorCode <= DefineDeviceErrorCode.Three_Switch4UnderElectricC)
|| (errorCode >= DefineDeviceErrorCode.One_Switch1UnderElectric
&& errorCode <= DefineDeviceErrorCode.One_Switch4UnderElectric);
}
/**
* 删除设备指定故障码记录(对应 C# RemoveErrorCode
*
@@ -2319,16 +1988,6 @@ public class DeviceDataHandler {
}
}
/**
* 格式化带塘口前缀的告警内容。未绑定塘口时只保留设备告警内容。
*/
private String formatPondWarnMessage(String pondName, String content) {
if (StrUtil.isBlank(pondName)) {
return content;
}
return String.format("%s塘口中%s", pondName, content);
}
/**
* 根据塘口ID获取塘口名称
*/
@@ -2899,68 +2558,6 @@ public class DeviceDataHandler {
}
}
/**
* 处理 salinitySet盐度补偿设定值
* 设备上报的盐度补偿设定值同步更新到设备表Device.salinityCompensation不入 TDengine。
* 通过遍历 keySet 做精确字符串比较,避免 Hutool ignoreCase 下 salinitySet 误匹配为 salinity。
*
* @param deviceName 设备名称serialNum
* @param params 上报参数
*/
private void handleSalinitySet(String deviceName, JSONObject params) {
for (String k : params.keySet()) {
if ("salinitySet".equals(k)) {
Double salinitySet = params.getDouble(k);
if (salinitySet != null) {
// === 盐度冻结检查用户修改盐度后30分钟内忽略设备上报的旧值回写 ===
String freezeKey = SALINITY_SET_FREEZE_KEY_PREFIX + deviceName;
Object freezeObj = RedisUtils.getCacheObject(freezeKey);
if (freezeObj != null) {
Double frozenValue = toDouble(freezeObj);
if (frozenValue != null && Math.abs(salinitySet - frozenValue) < 0.01) {
// 上报值与冻结值一致,设备已跟上新设置,提前解冻
RedisUtils.deleteObject(freezeKey);
log.info("[盐度冻结] 设备上报值={} 与冻结值={} 一致,提前解冻: deviceName={}",
salinitySet, frozenValue, deviceName);
} else {
// 冻结期内上报值与设定值不一致,跳过回写避免误导
log.debug("[盐度冻结] 冻结期内跳过设备旧值回写: deviceName={}, reportValue={}, frozenValue={}",
deviceName, salinitySet, frozenValue);
return;
}
}
// === 冻结检查结束 ===
try {
Device device = deviceMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
.eq(Device::getSerialNum, deviceName)
.select(Device::getId, Device::getIotId, Device::getSerialNum)
.last("LIMIT 1")
);
if (device == null) {
log.debug("设备不存在,无法更新盐度补偿设定值: {}", deviceName);
return;
}
if (hasPendingSolarProperty(device, "salinitySet")) {
log.debug("[太阳能属性] 存在待补发salinitySet跳过设备旧salinitySet同步: deviceName={}, reportSalinitySet={}",
deviceName, salinitySet);
return;
}
Device updateDevice = new Device();
updateDevice.setId(device.getId());
updateDevice.setSalinityCompensation(salinitySet);
deviceMapper.updateById(updateDevice);
log.debug("盐度补偿设定值已同步到设备表: {} = {}", deviceName, salinitySet);
} catch (Exception e) {
log.error("更新盐度补偿设定值失败[{}]: {}", deviceName, e.getMessage());
}
}
return;
}
}
}
/**
* 更新设备表的实时数据
*
@@ -2998,7 +2595,6 @@ public class DeviceDataHandler {
if (sensorData.getBattery() != null) {
updateDevice.setValueBattery(sensorData.getBattery().intValue());
}
updateDevice.setLastReportTime(parseSensorDataTime(sensorData));
int updated = deviceMapper.updateById(updateDevice);
if (updated > 0) {
log.debug("设备实时数据已更新: {}", deviceName);
@@ -3008,29 +2604,6 @@ public class DeviceDataHandler {
}
}
private Date parseSensorDataTime(DeviceSensorData sensorData) {
String time = StrUtil.blankToDefault(sensorData.getCreateTime(), sensorData.getTime());
if (StrUtil.isBlank(time)) {
return new Date();
}
try {
return Date.from(LocalDateTime.parse(normalizeSensorDataTime(time), DATETIME_FORMATTER)
.atZone(ZoneId.systemDefault())
.toInstant());
} catch (Exception e) {
log.warn("解析传感器上数时间失败,使用当前服务器时间: {}", time);
return new Date();
}
}
private String normalizeSensorDataTime(String time) {
String normalized = time.trim();
if (normalized.length() > 19) {
normalized = normalized.substring(0, 19);
}
return normalized.replace('T', ' ');
}
/**
* 溶解氧联动控制(参考 C# SetDeviceSwitchLinkedOpen 逻辑)
* <ul>

View File

@@ -1,34 +0,0 @@
package com.intc.iot.service;
import lombok.Data;
/**
* 开关执行结果
*/
@Data
public class SwitchTurnResult {
/**
* 是否成功
*/
private boolean success;
/**
* 结果描述
*/
private String message;
public static SwitchTurnResult ok(String message) {
SwitchTurnResult result = new SwitchTurnResult();
result.setSuccess(true);
result.setMessage(message);
return result;
}
public static SwitchTurnResult fail(String message) {
SwitchTurnResult result = new SwitchTurnResult();
result.setSuccess(false);
result.setMessage(message);
return result;
}
}

View File

@@ -1,38 +0,0 @@
package com.intc.iot.service;
import com.intc.fishery.domain.TimingCtrl;
import java.util.List;
/**
* 定时控制执行服务
*/
public interface TimingCtrlExecuteService {
/**
* 按开关ID执行开/关控制
*
* @param switchId 开关ID
* @param targetStatus 目标状态1=开启0=关闭
* @param opTypeLabel 操作类型描述
* @param opMessagePrefix 操作记录前缀
* @param timingOp 是否按定时任务记录
* @return 执行结果
*/
SwitchTurnResult executeSwitch(Long switchId, Integer targetStatus, String opTypeLabel,
String opMessagePrefix, boolean timingOp);
/**
* 扫描并执行当天到点的循环定时任务
*
* @return 执行数量
*/
int scanAndExecuteDailyTimingCtrl();
/**
* 查询启用中的每天循环定时控制
*
* @return 定时控制列表
*/
List<TimingCtrl> listEnabledDailyTimingCtrls();
}

View File

@@ -5,15 +5,11 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.intc.iot.handler.DeviceDataHandler;
import com.intc.iot.service.AmqpMessageHandler;
import com.intc.iot.service.DeviceStatusService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* AMQP 消息处理器实现类
*
@@ -33,9 +29,6 @@ public class AmqpMessageHandlerImpl implements AmqpMessageHandler {
@Autowired(required = false)
private DeviceDataHandler deviceDataHandler;
@Autowired(required = false)
private DeviceStatusService deviceStatusService;
@Override
public void handleMessage(String message) {
try {
@@ -57,9 +50,6 @@ public class AmqpMessageHandlerImpl implements AmqpMessageHandler {
String topic = json.getStr("topic");
String method = json.getStr("method");
String status = json.getStr("status");
if (status != null) {
status = status.trim().toLowerCase();
}
// 检查是否是设备上线/下线消息
if (status != null && ("online".equals(status) || "offline".equals(status))) {
@@ -225,11 +215,14 @@ public class AmqpMessageHandlerImpl implements AmqpMessageHandler {
private void handleStatusMessage(JSONObject json) {
String deviceName = json.getStr("deviceName");
String status = json.getStr("status");
status = normalizeStatus(status);
log.debug("设备状态: {} - {}", deviceName, status);
handleDeviceStatusChange(json, deviceName, status);
if ("offline".equals(status) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOffline(deviceName);
} else if ("online".equals(status) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOnline(deviceName);
}
}
/**
@@ -242,52 +235,14 @@ public class AmqpMessageHandlerImpl implements AmqpMessageHandler {
if (action == null) {
action = json.getStr("status");
}
action = normalizeStatus(action);
log.debug("设备{}: {}", "online".equals(action) ? "上线" : "下线", deviceName);
handleDeviceStatusChange(json, deviceName, action);
}
private void handleDeviceStatusChange(JSONObject json, String deviceName, String status) {
if (deviceName == null || status == null) {
log.warn("设备状态消息缺少设备名称或状态: {}", json);
return;
}
String productKey = json.getStr("productKey");
if (deviceStatusService != null && productKey != null && !productKey.trim().isEmpty()) {
try {
Map<String, Object> statusData = new HashMap<>();
statusData.put("productKey", productKey);
statusData.put("deviceName", deviceName);
statusData.put("status", status);
statusData.put("statusTime", json.get("statusTime"));
statusData.put("lastTime", json.get("lastTime"));
statusData.put("clientIp", json.getStr("clientIp"));
statusData.put("iotId", json.getStr("iotId"));
statusData.put("userId", json.get("userId"));
deviceStatusService.handleStatusChange(statusData);
return;
} catch (Exception e) {
log.error("写入设备状态失败,降级执行上下线处理: deviceName={}, status={}, err={}",
deviceName, status, e.getMessage(), e);
}
}
if ("offline".equals(status) && deviceDataHandler != null) {
if ("offline".equals(action) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOffline(deviceName);
} else if ("online".equals(status) && deviceDataHandler != null) {
} else if ("online".equals(action) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOnline(deviceName);
}
}
private String normalizeStatus(String status) {
if (status == null) {
return null;
}
status = status.trim().toLowerCase();
return ("online".equals(status) || "offline".equals(status)) ? status : null;
}
}

View File

@@ -2,20 +2,16 @@ package com.intc.iot.service.impl;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.intc.common.tenant.helper.TenantHelper;
import com.intc.iot.handler.DeviceDataHandler;
import com.intc.iot.domain.IotDeviceStatus;
import com.intc.iot.mapper.IotDeviceStatusMapper;
import com.intc.iot.service.DeviceStatusService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
@@ -26,45 +22,37 @@ import java.util.Map;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBean(IotDeviceStatusMapper.class)
@Slf4j
public class DeviceStatusServiceImpl implements DeviceStatusService {
private final IotDeviceStatusMapper deviceStatusMapper;
@Autowired(required = false)
private DeviceDataHandler deviceDataHandler;
@Override
@Transactional(rollbackFor = Exception.class)
public void handleStatusChange(Map<String, Object> statusData) {
try {
if (statusData == null || statusData.isEmpty()) {
log.warn("[设备状态] 状态数据为空,跳过处理");
return;
}
String productKey = trimToNull(statusData.get("productKey"));
String deviceName = trimToNull(statusData.get("deviceName"));
String status = normalizeStatus(statusData.get("status"));
String productKey = (String) statusData.get("productKey");
String deviceName = (String) statusData.get("deviceName");
String status = (String) statusData.get("status");
Long statusTime = getLongValue(statusData, "statusTime");
Long lastTime = getLongValue(statusData, "lastTime");
String clientIp = trimToNull(statusData.get("clientIp"));
String iotId = trimToNull(statusData.get("iotId"));
Long userId = getLongValue(statusData, "userId");
String clientIp = (String) statusData.get("clientIp");
if (productKey == null || deviceName == null || status == null) {
log.warn("[设备状态] 状态数据不完整,跳过处理: {}", JSONUtil.toJsonStr(statusData));
return;
}
if (statusTime == null) {
statusTime = System.currentTimeMillis() / 1000;
}
if (lastTime == null) {
lastTime = statusTime;
}
log.info("[设备状态] 设备状态变更 - ProductKey: {}, DeviceName: {}, Status: {}",
productKey, deviceName, status);
IotDeviceStatus existingStatus = findExistingStatus(productKey, deviceName);
// 查询是否已存在记录
IotDeviceStatus existingStatus = deviceStatusMapper.selectOne(
new LambdaQueryWrapper<IotDeviceStatus>()
.eq(IotDeviceStatus::getProductKey, productKey)
.eq(IotDeviceStatus::getDeviceName, deviceName)
);
LocalDateTime now = LocalDateTime.now();
@@ -74,12 +62,8 @@ public class DeviceStatusServiceImpl implements DeviceStatusService {
existingStatus.setStatusTime(statusTime);
existingStatus.setLastTime(lastTime);
existingStatus.setClientIp(clientIp);
existingStatus.setIotId(iotId);
existingStatus.setUserId(userId);
existingStatus.setUpdatedTime(now);
TenantHelper.ignore(() -> {
deviceStatusMapper.updateById(existingStatus);
});
deviceStatusMapper.updateById(existingStatus);
log.debug("[设备状态] 更新设备状态记录 - ID: {}, Status: {}", existingStatus.getId(), status);
} else {
@@ -87,34 +71,18 @@ public class DeviceStatusServiceImpl implements DeviceStatusService {
IotDeviceStatus newStatus = new IotDeviceStatus();
newStatus.setProductKey(productKey);
newStatus.setDeviceName(deviceName);
newStatus.setIotId(iotId);
newStatus.setIotId((String) statusData.get("iotId"));
newStatus.setStatus(status);
newStatus.setStatusTime(statusTime);
newStatus.setLastTime(lastTime);
newStatus.setClientIp(clientIp);
newStatus.setUserId(userId);
newStatus.setCreatedTime(now);
newStatus.setUpdatedTime(now);
try {
TenantHelper.ignore(() -> {
deviceStatusMapper.insert(newStatus);
});
} catch (DuplicateKeyException e) {
log.warn("[设备状态] 状态记录并发插入冲突,改为更新: productKey={}, deviceName={}", productKey, deviceName);
updateStatusByDevice(productKey, deviceName, newStatus);
}
deviceStatusMapper.insert(newStatus);
log.info("[设备状态] 新增设备状态记录 - ProductKey: {}, DeviceName: {}, Status: {}",
productKey, deviceName, status);
}
if (deviceDataHandler != null) {
if ("online".equals(status)) {
deviceDataHandler.handleDeviceOnline(deviceName);
} else if ("offline".equals(status)) {
deviceDataHandler.handleDeviceOffline(deviceName);
}
}
} catch (Exception e) {
log.error("[设备状态] 处理设备状态变更异常: {}", e.getMessage(), e);
throw e;
@@ -123,11 +91,11 @@ public class DeviceStatusServiceImpl implements DeviceStatusService {
@Override
public IotDeviceStatus queryDeviceStatus(String productKey, String deviceName) {
return TenantHelper.ignore(() -> deviceStatusMapper.selectOne(
return deviceStatusMapper.selectOne(
new LambdaQueryWrapper<IotDeviceStatus>()
.eq(IotDeviceStatus::getProductKey, productKey)
.eq(IotDeviceStatus::getDeviceName, deviceName)
));
);
}
@Override
@@ -153,53 +121,4 @@ public class DeviceStatusServiceImpl implements DeviceStatusService {
return null;
}
}
private IotDeviceStatus findExistingStatus(String productKey, String deviceName) {
return TenantHelper.ignore(() -> {
List<IotDeviceStatus> list = deviceStatusMapper.selectList(
new LambdaQueryWrapper<IotDeviceStatus>()
.eq(IotDeviceStatus::getProductKey, productKey)
.eq(IotDeviceStatus::getDeviceName, deviceName)
);
if (list == null || list.isEmpty()) {
return null;
}
if (list.size() > 1) {
log.warn("[设备状态] 存在重复状态记录: productKey={}, deviceName={}, count={}",
productKey, deviceName, list.size());
}
return list.get(0);
});
}
private void updateStatusByDevice(String productKey, String deviceName, IotDeviceStatus status) {
TenantHelper.ignore(() -> {
deviceStatusMapper.update(status,
new LambdaUpdateWrapper<IotDeviceStatus>()
.eq(IotDeviceStatus::getProductKey, productKey)
.eq(IotDeviceStatus::getDeviceName, deviceName)
);
});
}
private String normalizeStatus(Object status) {
String text = trimToNull(status);
if (text == null) {
return null;
}
text = text.toLowerCase();
if ("online".equals(text) || "offline".equals(text)) {
return text;
}
log.warn("[设备状态] 未知状态值,跳过处理: {}", text);
return null;
}
private String trimToNull(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
}

View File

@@ -1,363 +0,0 @@
package com.intc.iot.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.intc.common.redis.utils.RedisUtils;
import com.intc.common.tenant.helper.TenantHelper;
import com.intc.fishery.constant.DefineDeviceWarnCode;
import com.intc.fishery.domain.Device;
import com.intc.fishery.domain.DeviceSwitch;
import com.intc.fishery.domain.TimingCtrl;
import com.intc.fishery.mapper.DeviceMapper;
import com.intc.fishery.mapper.DeviceSwitchMapper;
import com.intc.fishery.mapper.TimingCtrlMapper;
import com.intc.fishery.utils.MessageOpRecordUtil;
import com.intc.iot.constant.IOTPropertyName;
import com.intc.iot.service.IotCloudService;
import com.intc.iot.service.SwitchTurnResult;
import com.intc.iot.service.TimingCtrlExecuteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 定时控制执行服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBean(IotCloudService.class)
public class TimingCtrlExecuteServiceImpl implements TimingCtrlExecuteService {
private static final String DAILY_EXEC_LOCK_PREFIX = "fishery:timingCtrl:daily:exec:";
private static final DateTimeFormatter DATE_KEY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final long SCAN_WINDOW_MINUTES = 4L;
/**
* 同一控制器多个开关同时到期时,每条指令之间的下发间隔(秒)
*/
private static final long SAME_DEVICE_DELAY_SECONDS = 20L;
/**
* 启动时打印日志,用于确认 Bean 是否成功创建
*/
@jakarta.annotation.PostConstruct
public void init() {
log.info("[TimingCtrl] TimingCtrlExecuteService 已初始化,扫描窗口={}分钟Redis锁前缀={}",
SCAN_WINDOW_MINUTES, DAILY_EXEC_LOCK_PREFIX);
}
@jakarta.annotation.PreDestroy
public void destroy() {
log.info("[TimingCtrl] 关闭定时控制指令调度线程池...");
commandScheduler.shutdown();
try {
if (!commandScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
commandScheduler.shutdownNow();
}
} catch (InterruptedException e) {
commandScheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
private final DeviceMapper deviceMapper;
private final DeviceSwitchMapper deviceSwitchMapper;
private final TimingCtrlMapper timingCtrlMapper;
private final IotCloudService iotCloudService;
/**
* 延迟下发指令的调度线程池(守护线程,不阻塞应用关闭)
*/
private final ScheduledExecutorService commandScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "timing-ctrl-cmd-scheduler");
t.setDaemon(true);
return t;
});
@Override
public SwitchTurnResult executeSwitch(Long switchId, Integer targetStatus, String opTypeLabel,
String opMessagePrefix, boolean timingOp) {
try {
if (switchId == null || targetStatus == null) {
return SwitchTurnResult.fail("参数不能为空");
}
DeviceSwitch deviceSwitch = deviceSwitchMapper.selectOne(
new LambdaQueryWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getId, switchId)
.select(DeviceSwitch::getId, DeviceSwitch::getDeviceId, DeviceSwitch::getIndex,
DeviceSwitch::getSwitchName, DeviceSwitch::getIsOpen, DeviceSwitch::getLastTurnTime)
);
if (deviceSwitch == null) {
return SwitchTurnResult.fail("开关不存在");
}
if (deviceSwitch.getLastTurnTime() != null) {
long timeDiff = System.currentTimeMillis() - deviceSwitch.getLastTurnTime().getTime();
if (timeDiff < 3000) {
return SwitchTurnResult.fail("操作过于频繁,请稍后再试");
}
}
Device device = deviceMapper.selectOne(
new LambdaQueryWrapper<Device>()
.eq(Device::getId, deviceSwitch.getDeviceId())
.select(Device::getId, Device::getUserId, Device::getIotId, Device::getSerialNum,
Device::getDeviceName, Device::getWarnCode, Device::getDeadTime)
);
if (device == null) {
return SwitchTurnResult.fail("开关不存在或无权限访问");
}
if (device.getDeadTime() != null && new Date().after(device.getDeadTime())) {
return SwitchTurnResult.fail("设备服务已过期");
}
if (device.getWarnCode() == null || device.getWarnCode() == 99
|| (device.getWarnCode() & DefineDeviceWarnCode.DeviceOffline) != 0) {
return SwitchTurnResult.fail("设备离线或已关机");
}
if (iotCloudService == null) {
return SwitchTurnResult.fail("物联网服务未启用");
}
Map<String, Object> properties = new HashMap<>();
properties.put(IOTPropertyName.SWITCH_INDEX + deviceSwitch.getIndex(), targetStatus);
boolean success = iotCloudService.setProperty(device.getIotId(), properties, true, 2);
if (!success) {
return SwitchTurnResult.fail("设置开关失败");
}
Date now = new Date();
deviceSwitchMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getId, switchId)
.set(DeviceSwitch::getIsOpen, targetStatus)
.set(DeviceSwitch::getLastTurnTime, now)
);
if (device.getUserId() != null && opTypeLabel != null && timingOp) {
String deviceLabel = device.getDeviceName() != null ? device.getDeviceName() : device.getSerialNum();
String switchLabel = deviceSwitch.getSwitchName() != null
? deviceSwitch.getSwitchName()
: ("Switch" + deviceSwitch.getIndex());
String operation = targetStatus == 1 ? "开启" : "关闭";
String message = String.format("%s(%s)的开关%s%s%s。",
deviceLabel, device.getSerialNum(), switchLabel, operation,
opMessagePrefix != null ? "" + opMessagePrefix + "" : "");
MessageOpRecordUtil.addMessageOpRecordTimingLoop(device.getUserId(), opTypeLabel, message);
}
log.info("[SwitchTurn] 设备={} 开关{} {}成功",
device.getSerialNum() != null ? device.getSerialNum() : device.getIotId(),
deviceSwitch.getSwitchName(),
targetStatus == 1 ? "开启" : "关闭");
return SwitchTurnResult.ok("操作成功");
} catch (Exception e) {
log.error("[SwitchTurn] 开关执行失败: {}", e.getMessage(), e);
return SwitchTurnResult.fail("开关控制失败: " + e.getMessage());
}
}
@Override
public int scanAndExecuteDailyTimingCtrl() {
// 后台定时任务没有租户上下文必须忽略租户过滤否则SQL会拼接 WHERE tenant_id = NULL 导致查不到数据
return TenantHelper.ignore(() -> doScanAndExecuteDailyTimingCtrl());
}
private int doScanAndExecuteDailyTimingCtrl() {
List<TimingCtrl> list = listEnabledDailyTimingCtrls();
if (list.isEmpty()) {
log.info("[TimingCtrl] 数据库中没有启用的每天循环定时控制记录(isOpen=1, loopType=1)");
return 0;
}
LocalDateTime now = LocalDateTime.now();
log.info("[TimingCtrl] 扫描开始,当前时间={},查到 {} 条启用的定时控制", now, list.size());
// 第一轮收集所有到期任务按设备ID分组
Map<Long, List<DueTask>> tasksByDevice = new LinkedHashMap<>();
for (TimingCtrl timingCtrl : list) {
try {
if (timingCtrl.getSwitchId() == null || timingCtrl.getOpenTime() == null || timingCtrl.getCloseTime() == null) {
log.debug("[TimingCtrl] 跳过: 字段为空id={}, switchId={}, openTime={}, closeTime={}",
timingCtrl.getId(), timingCtrl.getSwitchId(), timingCtrl.getOpenTime(), timingCtrl.getCloseTime());
continue;
}
LocalTime openTime = toLocalTime(timingCtrl.getOpenTime());
LocalTime closeTime = toLocalTime(timingCtrl.getCloseTime());
boolean openDue = isDueInLookbackWindow(now, openTime);
boolean closeDue = isDueInLookbackWindow(now, closeTime);
if (!openDue && !closeDue) {
log.debug("[TimingCtrl] 跳过: 未到执行时间id={}, openTime={}, closeTime={}, now={}",
timingCtrl.getId(), openTime, closeTime, now.toLocalTime());
continue;
}
// 查询开关所属设备ID用于按设备分组错峰下发
Long deviceId = getDeviceIdBySwitchId(timingCtrl.getSwitchId());
if (deviceId == null) {
log.warn("[TimingCtrl] 开关不存在跳过switchId={}", timingCtrl.getSwitchId());
continue;
}
if (openDue) {
if (tryLock(timingCtrl.getId(), now.toLocalDate(), "open")) {
tasksByDevice.computeIfAbsent(deviceId, k -> new ArrayList<>())
.add(new DueTask(timingCtrl.getId(), timingCtrl.getSwitchId(), 1));
} else {
log.debug("[TimingCtrl] 开启: 今天已执行过跳过id={}", timingCtrl.getId());
}
}
if (closeDue) {
if (tryLock(timingCtrl.getId(), now.toLocalDate(), "close")) {
tasksByDevice.computeIfAbsent(deviceId, k -> new ArrayList<>())
.add(new DueTask(timingCtrl.getId(), timingCtrl.getSwitchId(), 0));
} else {
log.debug("[TimingCtrl] 关闭: 今天已执行过跳过id={}", timingCtrl.getId());
}
}
} catch (Exception e) {
log.error("[TimingCtrl] 处理定时控制失败id={}: {}", timingCtrl.getId(), e.getMessage(), e);
}
}
if (tasksByDevice.isEmpty()) {
log.info("[TimingCtrl] 本次扫描没有需要执行的定时控制");
return 0;
}
// 第二轮:按设备分组下发指令,同一设备的多个开关每隔 SAME_DEVICE_DELAY_SECONDS 秒下发一条
int executedCount = 0;
for (Map.Entry<Long, List<DueTask>> entry : tasksByDevice.entrySet()) {
Long deviceId = entry.getKey();
List<DueTask> tasks = entry.getValue();
log.info("[TimingCtrl] 设备{} 共有 {} 条定时控制待执行", deviceId, tasks.size());
for (int i = 0; i < tasks.size(); i++) {
DueTask task = tasks.get(i);
long delaySeconds = i * SAME_DEVICE_DELAY_SECONDS;
if (delaySeconds == 0) {
// 第一条立即下发
SwitchTurnResult result = executeSwitch(task.switchId, task.targetStatus,
"定时控制管理", "定时任务到达" + (task.targetStatus == 1 ? "开启" : "关闭") + "时间", true);
if (result.isSuccess()) {
executedCount++;
} else {
log.warn("[TimingCtrl] 执行失败timingCtrlId={}, msg={}", task.timingCtrlId, result.getMessage());
}
} else {
// 同一设备后续开关延迟下发,避免短时间内集中下发多条指令导致硬件无法及时处理
final Long tcId = task.timingCtrlId;
final Long switchId = task.switchId;
final int targetStatus = task.targetStatus;
final long delay = delaySeconds;
log.info("[TimingCtrl] 定时控制{} 将延迟{}秒后执行(设备{}排队)", tcId, delay, deviceId);
commandScheduler.schedule(() -> {
try {
TenantHelper.ignore(() -> {
log.info("[TimingCtrl] 开始延迟执行定时控制timingCtrlId={}", tcId);
SwitchTurnResult result = executeSwitch(switchId, targetStatus,
"定时控制管理", "定时任务到达" + (targetStatus == 1 ? "开启" : "关闭") + "时间", true);
if (result.isSuccess()) {
log.info("[TimingCtrl] 延迟执行成功timingCtrlId={}", tcId);
} else {
log.warn("[TimingCtrl] 延迟执行失败timingCtrlId={}, msg={}", tcId, result.getMessage());
}
});
} catch (Exception e) {
log.error("[TimingCtrl] 延迟执行异常timingCtrlId={}: {}", tcId, e.getMessage(), e);
}
}, delay, TimeUnit.SECONDS);
executedCount++;
}
}
}
return executedCount;
}
/**
* 根据开关ID查询所属设备ID
*/
private Long getDeviceIdBySwitchId(Long switchId) {
DeviceSwitch ds = deviceSwitchMapper.selectOne(
new LambdaQueryWrapper<DeviceSwitch>()
.eq(DeviceSwitch::getId, switchId)
.select(DeviceSwitch::getDeviceId)
);
return ds != null ? ds.getDeviceId() : null;
}
/**
* 到期的定时控制任务
*/
private static class DueTask {
final Long timingCtrlId;
final Long switchId;
final int targetStatus;
DueTask(Long timingCtrlId, Long switchId, int targetStatus) {
this.timingCtrlId = timingCtrlId;
this.switchId = switchId;
this.targetStatus = targetStatus;
}
}
@Override
public List<TimingCtrl> listEnabledDailyTimingCtrls() {
return timingCtrlMapper.selectList(
new LambdaQueryWrapper<TimingCtrl>()
.eq(TimingCtrl::getIsOpen, 1L)
.eq(TimingCtrl::getLoopType, 2L)
.select(TimingCtrl::getId, TimingCtrl::getSwitchId, TimingCtrl::getOpenTime,
TimingCtrl::getCloseTime, TimingCtrl::getLoopType, TimingCtrl::getIsOpen)
);
}
private static LocalTime toLocalTime(Date date) {
return LocalDateTime.ofInstant(date.toInstant(), java.time.ZoneId.systemDefault()).toLocalTime();
}
private static boolean isDueInLookbackWindow(LocalDateTime now, LocalTime scheduleTime) {
LocalDateTime scheduleDateTime = LocalDateTime.of(now.toLocalDate(), scheduleTime);
if (scheduleDateTime.isAfter(now)) {
scheduleDateTime = scheduleDateTime.minusDays(1);
}
LocalDateTime lowerBound = now.minusMinutes(SCAN_WINDOW_MINUTES);
return !scheduleDateTime.isBefore(lowerBound) && !scheduleDateTime.isAfter(now);
}
private boolean tryLock(Long timingCtrlId, LocalDate date, String action) {
String redisKey = DAILY_EXEC_LOCK_PREFIX + timingCtrlId + ":" + date.format(DATE_KEY_FORMATTER) + ":" + action;
boolean locked = RedisUtils.setObjectIfAbsent(redisKey, "1", Duration.ofHours(25));
if (locked) {
log.info("[TimingCtrl] 获取执行锁成功timingCtrlId={}, date={}, action={}", timingCtrlId, date, action);
}
return locked;
}
}

View File

@@ -10,7 +10,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalTime;
import java.util.Date;
import java.util.List;
@@ -36,13 +35,6 @@ public class DeviceExpirationWarnTask {
@Scheduled(fixedDelay = 7200000)
public void checkDeviceExpirationAndNotify() {
try {
// 只在 8:00 ~ 21:00 之间拨打电话通知,避免夜间打扰用户
LocalTime currentTime = LocalTime.now();
if (currentTime.isBefore(LocalTime.of(8, 0)) || !currentTime.isBefore(LocalTime.of(21, 0))) {
log.debug("[到期告警] 当前时间 {} 不在电话通知时段(08:00~21:00),跳过本次扫描", currentTime);
return;
}
Date now = new Date();
// 24小时后的时间点
Date deadline = new Date(now.getTime() + 24L * 60 * 60 * 1000);

View File

@@ -1,38 +0,0 @@
package com.intc.iot.task;
import com.intc.iot.service.TimingCtrlExecuteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 开关定时任务执行扫描
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBean(TimingCtrlExecuteService.class)
public class TimingCtrlExecuteTask {
private static final long SCAN_DELAY_MS = 120000L;
private static final long INITIAL_DELAY_MS = 10000L;
private final TimingCtrlExecuteService timingCtrlExecuteService;
/**
* 每5分钟扫描一次启用中的每天循环定时控制
* 启动后10秒执行第一次扫描
*/
@Scheduled(fixedDelay = SCAN_DELAY_MS, initialDelay = INITIAL_DELAY_MS)
public void scanDailyTimingCtrl() {
try {
log.info("[TimingCtrlTask] 开始扫描定时控制...");
int count = timingCtrlExecuteService.scanAndExecuteDailyTimingCtrl();
log.info("[TimingCtrlTask] 本次扫描完成,执行了 {} 条定时控制", count);
} catch (Exception e) {
log.error("[TimingCtrlTask] 扫描定时控制失败: {}", e.getMessage(), e);
}
}
}

View File

@@ -77,23 +77,4 @@ public interface DeviceSensorDataMapper {
@Param("endTime") String endTime,
@Param("intervalType") Integer intervalType);
/**
* 查询设备最新上数时间
*
* @param serialNum 设备序列号
* @return 最新传感器数据
*/
@DS("taos")
@InterceptorIgnore(tenantLine = "true")
DeviceSensorData getLatestReportTime(@Param("serialNum") String serialNum);
/**
* 删除设备子表(清除该设备的所有时序数据)
*
* @param serialNum 设备序列号
*/
@DS("taos")
@InterceptorIgnore(tenantLine = "true")
void dropSubTable(@Param("serialNum") String serialNum);
}

View File

@@ -38,19 +38,4 @@ public interface IDeviceSensorDataService {
* @return 设备传感器数据列表
*/
public List<DeviceSensorData> getHistoryDataList(String serialNum, String startTime, String endTime, Integer intervalType);
/**
* 查询设备最新上数时间
*
* @param serialNum 设备序列号
* @return 最新上数时间字符串,格式 yyyy-MM-dd HH:mm:ss无数据时返回 null
*/
String getLatestReportTime(String serialNum);
/**
* 删除设备子表(清除该设备的所有时序数据)
*
* @param serialNum 设备序列号
*/
void dropSubTable(String serialNum);
}

View File

@@ -256,31 +256,6 @@ public class DeviceSensorDataService implements IDeviceSensorDataService {
}
return list;
}
/**
* 查询设备最新上数时间
*/
@Override
public String getLatestReportTime(String serialNum) {
if (serialNum == null || serialNum.isBlank()) {
return null;
}
try {
DeviceSensorData latest = deviceSensorDataMapper.getLatestReportTime(serialNum);
if (latest == null) {
return null;
}
return latest.getCreateTime() != null ? latest.getCreateTime() : latest.getTime();
} catch (Exception e) {
String errorMsg = e.getMessage();
if (errorMsg != null && errorMsg.toLowerCase().contains("not exist")) {
log.debug("设备TDengine子表不存在或无数据: serialNum={}", serialNum);
} else {
log.warn("查询设备最新上数时间失败: serialNum={}, error={}", serialNum, errorMsg);
}
return null;
}
}
/**
* 获取间隔类型描述
@@ -296,27 +271,4 @@ public class DeviceSensorDataService implements IDeviceSensorDataService {
default: return "未知";
}
}
/**
* 删除设备子表(清除该设备的所有时序数据)
*/
@Override
public void dropSubTable(String serialNum) {
if (serialNum == null || serialNum.isBlank()) {
return;
}
try {
deviceSensorDataMapper.dropSubTable(serialNum);
// 清除建表缓存,下次插入数据时会重新建表
CREATED_TABLES.remove(serialNum);
log.info("已删除设备TDengine子表: serialNum={}", serialNum);
} catch (Exception e) {
String errorMsg = e.getMessage();
if (errorMsg != null && errorMsg.toLowerCase().contains("not exist")) {
log.debug("设备TDengine子表不存在无需删除: serialNum={}", serialNum);
} else {
log.warn("删除设备TDengine子表失败: serialNum={}, error={}", serialNum, errorMsg);
}
}
}
}

View File

@@ -111,17 +111,4 @@
</choose>
</if>
</select>
<select id="getLatestReportTime" resultType="com.intc.tdengine.domain.DeviceSensorData">
SELECT
`time`,
createTime
FROM `fishery`.`t_${serialNum}`
ORDER BY `time` DESC
LIMIT 1
</select>
<update id="dropSubTable">
DROP TABLE IF EXISTS `fishery`.`t_${serialNum}`
</update>
</mapper>

View File

@@ -262,7 +262,6 @@ public class WeixinController extends BaseController {
// 9. 构建返回结果
WxPayOrderVo result = new WxPayOrderVo();
result.setPrepayId(prepayId);
result.setOutTradeNumber(order.getOutTradeNumber());
result.setTotalAmount(order.getTotalAmount());
result.setTimestamp(payParams.get("timeStamp"));
result.setNonceStr(payParams.get("nonceStr"));
@@ -279,36 +278,6 @@ public class WeixinController extends BaseController {
}
}
@Operation(summary = "主动确认微信支付订单")
@PostMapping("/pay/confirm_order")
public R<Boolean> confirmOrder(@RequestBody Map<String, Object> request) {
try {
if (payOrderBusinessService == null) {
return R.fail("支付服务未启用");
}
String outTradeNumber = request != null && request.get("outTradeNumber") != null
? String.valueOf(request.get("outTradeNumber"))
: null;
if ((outTradeNumber == null || outTradeNumber.isEmpty()) && request != null && request.get("orderId") != null) {
Long orderId = Long.valueOf(String.valueOf(request.get("orderId")));
com.intc.fishery.domain.PayOrder order = payOrderBusinessService.queryById(orderId);
if (order != null) {
outTradeNumber = order.getOutTradeNumber();
}
}
if (outTradeNumber == null || outTradeNumber.isEmpty()) {
return R.fail("商户订单号不能为空");
}
boolean success = payOrderBusinessService.confirmPaidOrder(outTradeNumber);
return success ? R.ok(true) : R.fail("订单未支付成功或确认失败");
} catch (Exception e) {
log.error("确认支付订单失败", e);
return R.fail("确认支付订单失败: " + e.getMessage());
}
}
@Operation(summary = "微信支付回调通知")
@PostMapping("/pay_notify")
public Map<String, String> payNotify(

View File

@@ -24,12 +24,6 @@ public class WxPayOrderVo implements Serializable {
@Schema(description = "预支付ID")
private String prepayId;
/**
* 商户系统内部订单号
*/
@Schema(description = "商户订单号")
private String outTradeNumber;
/**
* 签名类型
*/

View File

@@ -82,28 +82,4 @@ public interface PayOrderBusinessService {
* @return 是否更新成功
*/
boolean updatePrepayId(Long orderId, String prepayId);
/**
* 补偿未处理的支付订单
* 主动向微信支付查询状态为“未支付/支付中/支付失败/已关闭”且有商户订单号的订单,
* 如果微信侧已支付成功则执行回调补偿
*
* @return 补偿成功的订单数量
*/
int compensateUnpaidOrders();
/**
* 主动确认单笔订单支付状态。
*
* @param outTradeNumber 商户订单号
* @return 是否已确认并处理支付成功
*/
boolean confirmPaidOrder(String outTradeNumber);
/**
* 修复已支付订单中设备有效期未落库的异常数据。
*
* @return 修复的设备数量
*/
int repairPaidOrderDeviceDeadTime();
}

View File

@@ -61,13 +61,4 @@ public interface WxPayService {
* @return 解密后的明文
*/
String decryptCallbackData(String associatedData, String nonce, String ciphertext);
/**
* 主动查询微信支付订单状态
* 通过商户订单号向微信支付平台查询订单的实际支付状态
*
* @param outTradeNumber 商户订单号
* @return 微信返回的订单JSON字符串查询失败返回null
*/
String queryOrderByOutTradeNo(String outTradeNumber);
}

View File

@@ -1,8 +1,8 @@
package com.intc.weixin.service.impl;
import cn.hutool.core.date.DateUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.intc.common.core.exception.ServiceException;
import com.intc.common.tenant.helper.TenantHelper;
import com.intc.fishery.constant.DefineDeviceWarnCode;
@@ -15,21 +15,16 @@ import com.intc.fishery.mapper.PayOrderMapper;
import com.intc.weixin.config.WxPayItemProperties;
import com.intc.weixin.constant.PayOrderStatus;
import com.intc.weixin.service.PayOrderBusinessService;
import com.intc.weixin.service.WxPayService;
import com.intc.weixin.utils.OrderNumberGenerator;
import com.intc.weixin.utils.PayOrderStatusUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
/**
@@ -46,20 +41,11 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
private final PayDeviceMapper payDeviceMapper;
private final DeviceMapper deviceMapper;
private final WxPayItemProperties wxPayItemProperties;
private final WxPayService wxPayService;
private final TransactionTemplate transactionTemplate;
/**
* 订单处理锁,防止并发处理同一订单
*/
private static final ConcurrentHashMap<Long, Long> ORDER_PROCESSING_LOCK = new ConcurrentHashMap<>();
private static final AtomicLong COMPENSATION_HISTORY_CURSOR_ID = new AtomicLong(0L);
private static final AtomicLong REPAIR_HISTORY_CURSOR_ID = new AtomicLong(0L);
private static final int RECENT_SCAN_SIZE = 200;
private static final int HISTORY_SCAN_SIZE = 200;
private static final int ORDER_UPDATE_FAILED = -1;
private static final int ORDER_ALREADY_SUCCESS = 0;
private static final int ORDER_UPDATED = 1;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -186,6 +172,7 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public void closeUnpaidOrders(Long userId, String openId) {
// 查询用户所有未完成的订单
List<PayOrder> unpaidOrders = payOrderMapper.selectList(
@@ -203,50 +190,8 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
return;
}
// 批量处理订单:先查微信确认状态,避免错误关闭已支付订单
// 批量关闭订单
for (PayOrder order : unpaidOrders) {
try {
String queryResult = wxPayService.queryOrderByOutTradeNo(order.getOutTradeNumber());
if (queryResult != null) {
JSONObject resultJson = JSONUtil.parseObj(queryResult);
String tradeState = resultJson.getStr("trade_state");
if ("SUCCESS".equalsIgnoreCase(tradeState)) {
// 微信侧已支付成功,执行补偿而不是关闭
log.info("关闭订单前查询发现已支付: orderId={}, outTradeNumber={}", order.getId(), order.getOutTradeNumber());
compensateSingleOrder(order, resultJson);
continue;
}
if ("CLOSED".equalsIgnoreCase(tradeState)) {
payOrderMapper.update(null,
new LambdaUpdateWrapper<PayOrder>()
.eq(PayOrder::getId, order.getId())
.set(PayOrder::getOrderStatus, PayOrderStatus.CLOSED)
.set(PayOrder::getTradeState, "CLOSED")
.set(PayOrder::getTradeStateDescription, "微信侧已关闭")
);
log.info("旧订单微信侧已关闭,同步本地状态: orderId={}, outTradeNumber={}",
order.getId(), order.getOutTradeNumber());
continue;
}
if ("USERPAYING".equalsIgnoreCase(tradeState)) {
log.info("旧订单仍在支付中,跳过关闭: orderId={}, outTradeNumber={}",
order.getId(), order.getOutTradeNumber());
continue;
}
}
} catch (Exception e) {
log.warn("关闭订单前查询微信失败,跳过关闭避免误关已支付订单: orderId={}, error={}", order.getId(), e.getMessage());
continue;
}
boolean closed = wxPayService.closeOrder(order.getOutTradeNumber());
if (!closed) {
log.warn("微信关单失败,跳过本地关闭: orderId={}, outTradeNumber={}",
order.getId(), order.getOutTradeNumber());
continue;
}
// 微信侧确认未支付,安全关闭
payOrderMapper.update(null,
new LambdaUpdateWrapper<PayOrder>()
.eq(PayOrder::getId, order.getId())
@@ -260,6 +205,7 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handlePaymentSuccess(String outTradeNumber, String transactionId, Integer payerTotal,
String payerCurrency, String successTime, String tradeState,
String tradeStateDescription, String bankType) {
@@ -272,20 +218,20 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
// 2. 检查订单状态,如果已经是成功状态,直接返回
if (PayOrderStatus.SUCCESS.equals(order.getOrderStatus())) {
int repairCount = repairOrderDeviceDeadTime(order);
log.info("订单已处理过: orderId={}, outTradeNumber={}, repairDeviceCount={}",
order.getId(), outTradeNumber, repairCount);
log.info("订单已处理过: orderId={}, outTradeNumber={}", order.getId(), outTradeNumber);
return true;
}
// 3. 并发控制:检查订单是否正在处理
Long previousLockTime = ORDER_PROCESSING_LOCK.putIfAbsent(order.getId(), System.currentTimeMillis());
if (previousLockTime != null) {
log.warn("订单正在处理中,等待微信下次通知或补偿任务处理: orderId={}, outTradeNumber={}", order.getId(), outTradeNumber);
return true;
if (ORDER_PROCESSING_LOCK.containsKey(order.getId())) {
log.warn("订单正在处理中,跳过: orderId={}, outTradeNumber={}", order.getId(), outTradeNumber);
return false;
}
try {
// 4. 加锁
ORDER_PROCESSING_LOCK.put(order.getId(), System.currentTimeMillis());
// 5. 检查金额是否一致
if (!order.getTotalAmount().equals(payerTotal)) {
log.error("订单金额不匹配: orderId={}, orderAmount={}, payAmount={}",
@@ -298,55 +244,22 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
// 7. 更新订单状态
Integer orderStatus = PayOrderStatusUtil.convertTradeStateToOrderStatus(tradeState);
Integer updateResult = transactionTemplate.execute(status -> {
int updateRows = payOrderMapper.update(null,
new LambdaUpdateWrapper<PayOrder>()
.eq(PayOrder::getId, order.getId())
.ne(PayOrder::getOrderStatus, PayOrderStatus.SUCCESS)
.set(PayOrder::getTransactionId, transactionId)
.set(PayOrder::getPayerTotal, payerTotal)
.set(PayOrder::getPayerCurrency, payerCurrency)
.set(PayOrder::getSuccessTime, successDate)
.set(PayOrder::getTradeState, tradeState)
.set(PayOrder::getTradeStateDescription, tradeStateDescription)
.set(PayOrder::getBankType, bankType)
.set(PayOrder::getOrderStatus, orderStatus)
);
if (updateRows <= 0) {
PayOrder latestOrder = payOrderMapper.selectById(order.getId());
if (latestOrder != null && PayOrderStatus.SUCCESS.equals(latestOrder.getOrderStatus())) {
log.info("订单已由其他回调处理成功: orderId={}, outTradeNumber={}", order.getId(), outTradeNumber);
return ORDER_ALREADY_SUCCESS;
}
log.warn("订单状态更新失败: orderId={}, outTradeNumber={}", order.getId(), outTradeNumber);
return ORDER_UPDATE_FAILED;
}
return ORDER_UPDATED;
});
if (updateResult == null || ORDER_UPDATE_FAILED == updateResult) {
return false;
}
payOrderMapper.update(null,
new LambdaUpdateWrapper<PayOrder>()
.eq(PayOrder::getId, order.getId())
.set(PayOrder::getTransactionId, transactionId)
.set(PayOrder::getPayerTotal, payerTotal)
.set(PayOrder::getPayerCurrency, payerCurrency)
.set(PayOrder::getSuccessTime, successDate)
.set(PayOrder::getTradeState, tradeState)
.set(PayOrder::getTradeStateDescription, tradeStateDescription)
.set(PayOrder::getBankType, bankType)
.set(PayOrder::getOrderStatus, orderStatus)
);
// 8. 如果支付成功,更新设备到期时间
if (PayOrderStatus.SUCCESS.equals(orderStatus)) {
if (ORDER_UPDATED == updateResult) {
try {
int updatedDevices = updateDeviceDeadTime(order.getId());
if (updatedDevices <= 0) {
log.warn("订单已支付成功,但本次未更新任何设备有效期,等待修复任务兜底: orderId={}, outTradeNumber={}",
order.getId(), outTradeNumber);
}
} catch (Exception e) {
log.error("订单已支付成功,但更新设备有效期异常,等待修复任务兜底: orderId={}, outTradeNumber={}, error={}",
order.getId(), outTradeNumber, e.getMessage(), e);
}
} else {
int repairCount = repairOrderDeviceDeadTime(order);
log.info("订单已由其他实例处理成功,本次只做设备有效期修复: orderId={}, outTradeNumber={}, repairDeviceCount={}",
order.getId(), outTradeNumber, repairCount);
}
updateDeviceDeadTime(order.getId());
}
log.info("订单支付处理完成: orderId={}, outTradeNumber={}, status={}",
@@ -415,101 +328,49 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
*
* @param orderId 订单ID
*/
private int updateDeviceDeadTime(Long orderId) {
private void updateDeviceDeadTime(Long orderId) {
// 查询订单关联的设备充值记录
List<PayDevice> payDevices = queryPayDevicesByOrderId(orderId);
if (payDevices == null || payDevices.isEmpty()) {
log.warn("订单没有关联的设备充值记录: orderId={}", orderId);
return 0;
return;
}
int updatedCount = 0;
// 批量更新设备到期时间忽略租户过滤因为设备可能由IOT模块写入租户ID可能不一致
for (PayDevice payDevice : payDevices) {
try {
if (payDevice.getAddMonth() == null) {
log.error("设备充值记录缺少续费月数,跳过设备有效期更新: payDeviceId={}, orderId={}, serialNum={}",
payDevice.getId(), orderId, payDevice.getSerialNum());
continue;
}
Device device = queryDeviceByPayDevice(payDevice);
if (device == null) {
log.error("设备不存在,订单成功状态已保留,等待人工核对设备编号: orderId={}, payDeviceId={}, serialNum={}",
orderId, payDevice.getId(), payDevice.getSerialNum());
continue;
}
// 计算新的到期时间
Date newDeadTime = calculateNewDeadTime(device.getDeadTime(), payDevice.getAddMonth());
payDeviceMapper.update(null,
new LambdaUpdateWrapper<PayDevice>()
.eq(PayDevice::getId, payDevice.getId())
.set(PayDevice::getDeadTime, newDeadTime)
);
// 清除 warnCode 中的「设备到期」标志位(续费后恢复正常状态)
Integer oldWarnCode = device.getWarnCode();
Integer newWarnCode = oldWarnCode != null
? (oldWarnCode & ~DefineDeviceWarnCode.DeviceTimeDead)
: 0;
// 更新设备到期时间,并同步清除到期告警位
int updateRows = TenantHelper.ignore(() -> deviceMapper.update(null,
new LambdaUpdateWrapper<Device>()
.eq(Device::getId, device.getId())
.set(Device::getDeadTime, newDeadTime)
.set(Device::getWarnCode, newWarnCode)
));
if (updateRows <= 0) {
log.error("更新设备有效期失败,订单成功状态已保留,等待修复任务兜底: orderId={}, payDeviceId={}, serialNum={}, deviceId={}",
orderId, payDevice.getId(), payDevice.getSerialNum(), device.getId());
continue;
}
updatedCount++;
log.info("续费成功,更新设备到期时间并清除到期告警: deviceId={}, serialNum={}, newDeadTime={}, oldWarnCode={}, newWarnCode={}",
device.getId(), device.getSerialNum(), newDeadTime, oldWarnCode, newWarnCode);
} catch (Exception e) {
log.error("更新单台设备有效期异常,订单成功状态已保留: orderId={}, payDeviceId={}, serialNum={}, error={}",
orderId, payDevice.getId(), payDevice.getSerialNum(), e.getMessage(), e);
}
}
return updatedCount;
}
private Device queryDeviceByPayDevice(PayDevice payDevice) {
return TenantHelper.ignore(() -> {
if (payDevice.getTenantId() != null && !payDevice.getTenantId().isEmpty()) {
List<Device> sameTenantDevices = deviceMapper.selectList(
// 仅通过 serialNum 查找设备,不限制 userId因为允许代充支付者可能与设备所有者不同
Device device = TenantHelper.ignore(() ->
deviceMapper.selectOne(
new LambdaQueryWrapper<Device>()
.eq(Device::getSerialNum, payDevice.getSerialNum())
.eq(Device::getTenantId, payDevice.getTenantId())
.orderByDesc(Device::getUpdateTime)
.orderByDesc(Device::getCreateTime)
);
if (sameTenantDevices != null && !sameTenantDevices.isEmpty()) {
return sameTenantDevices.get(0);
}
)
);
if (device == null) {
log.warn("设备不存在: serialNum={}", payDevice.getSerialNum());
continue;
}
List<Device> devices = deviceMapper.selectList(
new LambdaQueryWrapper<Device>()
.eq(Device::getSerialNum, payDevice.getSerialNum())
.orderByDesc(Device::getUpdateTime)
.orderByDesc(Device::getCreateTime)
// 计算新的到期时间
Date newDeadTime = calculateNewDeadTime(device.getDeadTime(), payDevice.getAddMonth());
// 清除 warnCode 中的「设备到期」标志位(续费后恢复正常状态)
Integer oldWarnCode = device.getWarnCode();
Integer newWarnCode = oldWarnCode != null
? (oldWarnCode & ~DefineDeviceWarnCode.DeviceTimeDead)
: 0;
// 更新设备到期时间,并同步清除到期告警位
deviceMapper.update(null,
new LambdaUpdateWrapper<Device>()
.eq(Device::getId, device.getId())
.set(Device::getDeadTime, newDeadTime)
.set(Device::getWarnCode, newWarnCode)
);
if (devices == null || devices.isEmpty()) {
return null;
}
if (devices.size() > 1) {
log.warn("根据设备编号查到多台设备,已取最近更新的一台: serialNum={}, selectedDeviceId={}, matchedCount={}",
payDevice.getSerialNum(), devices.get(0).getId(), devices.size());
}
return devices.get(0);
});
log.info("续费成功,更新设备到期时间并清除到期告警: deviceId={}, serialNum={}, newDeadTime={}, oldWarnCode={}, newWarnCode={}",
device.getId(), device.getSerialNum(), newDeadTime, oldWarnCode, newWarnCode);
}
}
/**
@@ -533,338 +394,4 @@ public class PayOrderBusinessServiceImpl implements PayOrderBusinessService {
return new Date();
}
}
@Override
public int compensateUnpaidOrders() {
// 最新订单优先处理,历史订单用游标慢慢补扫,避免老异常数据被固定时间窗口或分页上限漏掉。
Date now = new Date();
Date endTime = new Date(now.getTime() - 1L * 60 * 1000); // 1分钟前
Map<Long, PayOrder> candidateMap = new LinkedHashMap<>();
scanRecentCompensationOrders(endTime).forEach(order -> candidateMap.put(order.getId(), order));
scanHistoryCompensationOrders(endTime).forEach(order -> candidateMap.putIfAbsent(order.getId(), order));
if (candidateMap.isEmpty()) {
return 0;
}
int compensatedCount = 0;
for (PayOrder order : candidateMap.values()) {
if (compensateOrderFromWechat(order)) {
compensatedCount++;
}
}
log.info("[支付补偿] 本次扫描 {} 笔待补偿订单", candidateMap.size());
if (compensatedCount > 0) {
log.info("[支付补偿] 本次补偿完成 {} 笔订单", compensatedCount);
}
return compensatedCount;
}
private List<PayOrder> scanRecentCompensationOrders(Date endTime) {
Page<PayOrder> page = payOrderMapper.selectPage(
new Page<>(1, RECENT_SCAN_SIZE, false),
buildCompensationOrderQuery(endTime)
.orderByDesc(PayOrder::getCreateTime)
.orderByDesc(PayOrder::getId)
);
return page.getRecords() != null ? page.getRecords() : Collections.emptyList();
}
private List<PayOrder> scanHistoryCompensationOrders(Date endTime) {
long cursorId = COMPENSATION_HISTORY_CURSOR_ID.get();
LambdaQueryWrapper<PayOrder> queryWrapper = buildCompensationOrderQuery(endTime);
if (cursorId > 0) {
queryWrapper.gt(PayOrder::getId, cursorId);
}
Page<PayOrder> page = payOrderMapper.selectPage(
new Page<>(1, HISTORY_SCAN_SIZE, false),
queryWrapper.orderByAsc(PayOrder::getId)
);
List<PayOrder> records = page.getRecords();
if (records == null || records.isEmpty()) {
if (cursorId > 0) {
COMPENSATION_HISTORY_CURSOR_ID.set(0L);
log.info("[支付补偿] 历史扫描游标已到末尾,下次从最早订单重新扫描");
}
return Collections.emptyList();
}
COMPENSATION_HISTORY_CURSOR_ID.set(records.get(records.size() - 1).getId());
return records;
}
private LambdaQueryWrapper<PayOrder> buildCompensationOrderQuery(Date endTime) {
return new LambdaQueryWrapper<PayOrder>()
.and(wrapper -> wrapper
.in(PayOrder::getOrderStatus, Arrays.asList(
PayOrderStatus.NOTPAY,
PayOrderStatus.USERPAYING,
PayOrderStatus.PAYERROR
))
.or()
.and(closed -> closed
.eq(PayOrder::getOrderStatus, PayOrderStatus.CLOSED)
.and(unconfirmedClosed -> unconfirmedClosed
.isNull(PayOrder::getTradeStateDescription)
.or()
.ne(PayOrder::getTradeStateDescription, "微信侧已关闭")
)
)
.or()
.isNull(PayOrder::getOrderStatus)
)
.isNotNull(PayOrder::getOutTradeNumber)
.ne(PayOrder::getOutTradeNumber, "")
.and(time -> time
.isNull(PayOrder::getCreateTime)
.or()
.le(PayOrder::getCreateTime, endTime)
);
}
private boolean compensateOrderFromWechat(PayOrder order) {
try {
String queryResult = wxPayService.queryOrderByOutTradeNo(order.getOutTradeNumber());
if (queryResult == null) {
log.warn("[支付补偿] 查询微信订单失败: orderId={}, outTradeNumber={}",
order.getId(), order.getOutTradeNumber());
return false;
}
JSONObject resultJson = JSONUtil.parseObj(queryResult);
String tradeState = resultJson.getStr("trade_state");
log.info("[支付补偿] 微信查单结果: orderId={}, outTradeNumber={}, localStatus={}, tradeState={}, prepayId={}, createTime={}",
order.getId(), order.getOutTradeNumber(), order.getOrderStatus(), tradeState, order.getPrepayId(), order.getCreateTime());
if ("SUCCESS".equalsIgnoreCase(tradeState)) {
boolean success = compensateSingleOrder(order, resultJson);
if (success) {
log.info("[支付补偿] 补偿成功: orderId={}, outTradeNumber={}", order.getId(), order.getOutTradeNumber());
} else {
log.warn("[支付补偿] 微信已支付但本地补偿失败: orderId={}, outTradeNumber={}",
order.getId(), order.getOutTradeNumber());
}
return success;
}
if ("CLOSED".equalsIgnoreCase(tradeState)) {
syncLocalClosedOrder(order.getId(), "微信侧已关闭");
}
return false;
} catch (Exception e) {
log.error("[支付补偿] 处理订单异常: orderId={}, outTradeNumber={}, error={}",
order.getId(), order.getOutTradeNumber(), e.getMessage(), e);
return false;
}
}
private void syncLocalClosedOrder(Long orderId, String description) {
payOrderMapper.update(null,
new LambdaUpdateWrapper<PayOrder>()
.eq(PayOrder::getId, orderId)
.ne(PayOrder::getOrderStatus, PayOrderStatus.SUCCESS)
.set(PayOrder::getOrderStatus, PayOrderStatus.CLOSED)
.set(PayOrder::getTradeState, "CLOSED")
.set(PayOrder::getTradeStateDescription, description)
);
log.info("[支付补偿] 订单微信侧已关闭,同步关闭: orderId={}", orderId);
}
/**
* 对单笔订单执行支付补偿
*
* @param order 本地订单
* @param wxResult 微信查询结果
* @return 是否补偿成功
*/
private boolean compensateSingleOrder(PayOrder order, JSONObject wxResult) {
String transactionId = wxResult.getStr("transaction_id");
String tradeState = wxResult.getStr("trade_state");
String tradeStateDesc = wxResult.getStr("trade_state_desc");
String successTime = wxResult.getStr("success_time");
String bankType = wxResult.getStr("bank_type");
JSONObject amountObj = wxResult.getJSONObject("amount");
Integer payerTotal = amountObj != null ? amountObj.getInt("payer_total") : null;
String payerCurrency = amountObj != null ? amountObj.getStr("payer_currency") : "CNY";
if (payerTotal == null) {
log.error("[支付补偿] 微信返回金额为空: orderId={}", order.getId());
return false;
}
return handlePaymentSuccess(
order.getOutTradeNumber(),
transactionId,
payerTotal,
payerCurrency,
successTime,
tradeState,
tradeStateDesc,
bankType
);
}
@Override
public boolean confirmPaidOrder(String outTradeNumber) {
if (outTradeNumber == null || outTradeNumber.isEmpty()) {
return false;
}
PayOrder order = queryByOutTradeNumber(outTradeNumber);
if (order == null) {
log.warn("[支付确认] 本地订单不存在: outTradeNumber={}", outTradeNumber);
return false;
}
if (PayOrderStatus.SUCCESS.equals(order.getOrderStatus())) {
int repairCount = repairOrderDeviceDeadTime(order);
log.info("[支付确认] 订单已成功: orderId={}, outTradeNumber={}, repairDeviceCount={}",
order.getId(), outTradeNumber, repairCount);
return true;
}
String queryResult = wxPayService.queryOrderByOutTradeNo(outTradeNumber);
if (queryResult == null) {
log.warn("[支付确认] 查询微信订单失败: orderId={}, outTradeNumber={}", order.getId(), outTradeNumber);
return false;
}
JSONObject resultJson = JSONUtil.parseObj(queryResult);
String tradeState = resultJson.getStr("trade_state");
if ("SUCCESS".equalsIgnoreCase(tradeState)) {
return compensateSingleOrder(order, resultJson);
}
if ("CLOSED".equalsIgnoreCase(tradeState)) {
payOrderMapper.update(null,
new LambdaUpdateWrapper<PayOrder>()
.eq(PayOrder::getId, order.getId())
.ne(PayOrder::getOrderStatus, PayOrderStatus.SUCCESS)
.set(PayOrder::getOrderStatus, PayOrderStatus.CLOSED)
.set(PayOrder::getTradeState, "CLOSED")
.set(PayOrder::getTradeStateDescription, "微信侧已关闭")
);
}
log.info("[支付确认] 订单未支付成功: orderId={}, outTradeNumber={}, tradeState={}",
order.getId(), outTradeNumber, tradeState);
return false;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int repairPaidOrderDeviceDeadTime() {
Map<Long, PayOrder> paidOrderMap = new LinkedHashMap<>();
scanRecentPaidOrdersForRepair().forEach(order -> paidOrderMap.put(order.getId(), order));
scanHistoryPaidOrdersForRepair().forEach(order -> paidOrderMap.putIfAbsent(order.getId(), order));
if (paidOrderMap.isEmpty()) {
return 0;
}
int repairCount = 0;
for (PayOrder order : paidOrderMap.values()) {
repairCount += repairOrderDeviceDeadTime(order);
}
if (repairCount > 0) {
log.warn("[支付有效期修复] 本次修复 {} 台设备", repairCount);
} else {
log.info("[支付有效期修复] 本次扫描 {} 笔支付成功订单,无需修复", paidOrderMap.size());
}
return repairCount;
}
private List<PayOrder> scanRecentPaidOrdersForRepair() {
Page<PayOrder> page = payOrderMapper.selectPage(
new Page<>(1, RECENT_SCAN_SIZE, false),
buildPaidOrderRepairQuery()
.orderByDesc(PayOrder::getCreateTime)
.orderByDesc(PayOrder::getId)
);
return page.getRecords() != null ? page.getRecords() : Collections.emptyList();
}
private List<PayOrder> scanHistoryPaidOrdersForRepair() {
long cursorId = REPAIR_HISTORY_CURSOR_ID.get();
LambdaQueryWrapper<PayOrder> queryWrapper = buildPaidOrderRepairQuery();
if (cursorId > 0) {
queryWrapper.gt(PayOrder::getId, cursorId);
}
Page<PayOrder> page = payOrderMapper.selectPage(
new Page<>(1, HISTORY_SCAN_SIZE, false),
queryWrapper.orderByAsc(PayOrder::getId)
);
List<PayOrder> records = page.getRecords();
if (records == null || records.isEmpty()) {
if (cursorId > 0) {
REPAIR_HISTORY_CURSOR_ID.set(0L);
log.info("[支付有效期修复] 历史扫描游标已到末尾,下次从最早成功订单重新扫描");
}
return Collections.emptyList();
}
REPAIR_HISTORY_CURSOR_ID.set(records.get(records.size() - 1).getId());
return records;
}
private LambdaQueryWrapper<PayOrder> buildPaidOrderRepairQuery() {
return new LambdaQueryWrapper<PayOrder>()
.eq(PayOrder::getOrderStatus, PayOrderStatus.SUCCESS);
}
private int repairOrderDeviceDeadTime(PayOrder order) {
List<PayDevice> payDevices = queryPayDevicesByOrderId(order.getId());
if (payDevices == null || payDevices.isEmpty()) {
log.warn("[支付有效期修复] 支付成功订单没有设备充值记录: orderId={}", order.getId());
return 0;
}
int repairCount = 0;
for (PayDevice payDevice : payDevices) {
try {
if (payDevice.getDeadTime() == null) {
log.warn("[支付有效期修复] 设备充值记录没有目标到期时间: payDeviceId={}, orderId={}",
payDevice.getId(), order.getId());
continue;
}
Device device = queryDeviceByPayDevice(payDevice);
if (device == null) {
log.warn("[支付有效期修复] 设备不存在: orderId={}, serialNum={}",
order.getId(), payDevice.getSerialNum());
continue;
}
if (device.getDeadTime() != null && !device.getDeadTime().before(payDevice.getDeadTime())) {
continue;
}
Integer oldWarnCode = device.getWarnCode();
Integer newWarnCode = oldWarnCode != null
? (oldWarnCode & ~DefineDeviceWarnCode.DeviceTimeDead)
: 0;
int updateRows = TenantHelper.ignore(() -> deviceMapper.update(null,
new LambdaUpdateWrapper<Device>()
.eq(Device::getId, device.getId())
.set(Device::getDeadTime, payDevice.getDeadTime())
.set(Device::getWarnCode, newWarnCode)
));
if (updateRows > 0) {
repairCount++;
log.warn("[支付有效期修复] 已修复设备有效期: orderId={}, deviceId={}, serialNum={}, oldDeadTime={}, targetDeadTime={}",
order.getId(), device.getId(), device.getSerialNum(), device.getDeadTime(), payDevice.getDeadTime());
}
} catch (Exception e) {
log.error("[支付有效期修复] 修复单台设备异常: orderId={}, payDeviceId={}, serialNum={}, error={}",
order.getId(), payDevice.getId(), payDevice.getSerialNum(), e.getMessage(), e);
}
}
return repairCount;
}
}

View File

@@ -36,7 +36,6 @@ public class WxPayServiceImpl implements WxPayService {
private static final String JSAPI_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi";
private static final String CLOSE_URL_TEMPLATE = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/%s/close";
private static final String QUERY_ORDER_URL_TEMPLATE = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/%s?mchid=%s";
private final WxPayProperties wxPayProperties;
private final WxMaProperties wxMaProperties;
@@ -127,31 +126,6 @@ public class WxPayServiceImpl implements WxPayService {
return response.body();
}
// ----------------------------------------------------------------
// 发送 HTTP GET
// ----------------------------------------------------------------
private String doGet(String url) throws Exception {
URI uri = URI.create(url);
String urlPath = uri.getPath();
if (uri.getQuery() != null) {
urlPath = urlPath + "?" + uri.getQuery();
}
String authorization = buildAuthorization("GET", urlPath, "");
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Accept", "application/json")
.header("Authorization", authorization)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
log.info("微信支付查询响应: status={}, body={}", response.statusCode(), response.body());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException("微信支付查询失败: " + response.statusCode() + " " + response.body());
}
return response.body();
}
// ----------------------------------------------------------------
// 发送 HTTP POST无响应体如关闭订单
// ----------------------------------------------------------------
@@ -181,7 +155,7 @@ public class WxPayServiceImpl implements WxPayService {
@Override
public String createJsapiOrder(String openId, String outTradeNumber, Integer totalAmount,
String description, String notifyUrl) {
log.info("创建JSAPI支付订单: openId={}, outTradeNumber={}, amount={}, notifyUrl={}", openId, outTradeNumber, totalAmount, notifyUrl);
log.info("创建JSAPI支付订单: openId={}, outTradeNumber={}, amount={}", openId, outTradeNumber, totalAmount);
try {
String appId = wxMaProperties.getAppId();
String mchId = wxPayProperties.getMchId();
@@ -309,16 +283,4 @@ public class WxPayServiceImpl implements WxPayService {
if (value == null) return "";
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
@Override
public String queryOrderByOutTradeNo(String outTradeNumber) {
log.info("主动查询订单状态: outTradeNumber={}", outTradeNumber);
try {
String url = String.format(QUERY_ORDER_URL_TEMPLATE, outTradeNumber, wxPayProperties.getMchId());
return doGet(url);
} catch (Exception e) {
log.error("查询订单状态失败: outTradeNumber={}, error={}", outTradeNumber, e.getMessage());
return null;
}
}
}

View File

@@ -1,59 +0,0 @@
package com.intc.weixin.task;
import com.intc.weixin.service.PayOrderBusinessService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 支付订单补偿定时任务
* 定期扫描状态为"未支付/支付中/支付失败/已关闭"且有商户订单号的订单,
* 主动向微信支付查询实际状态,对已支付成功但回调丢失的订单进行补偿处理。
*
* 解决问题:用户实际已付款成功,但因网络/服务器等原因未收到微信支付回调通知,
* 导致订单一直停留在未支付状态。
*
* @author intc
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBean(PayOrderBusinessService.class)
public class PayOrderCompensationTask {
private final PayOrderBusinessService payOrderBusinessService;
/**
* 每1分钟执行一次订单补偿扫描
* 优先扫描最近订单,同时通过历史游标持续补扫老异常订单。
*/
// @Scheduled(fixedDelay = 60000, initialDelay = 60000)
public void compensateOrders() {
try {
int count = payOrderBusinessService.compensateUnpaidOrders();
if (count > 0) {
log.info("[支付补偿定时任务] 本次补偿了 {} 笔订单", count);
}
} catch (Exception e) {
log.error("[支付补偿定时任务] 执行异常: {}", e.getMessage(), e);
}
}
/**
* 每10分钟修复一次已支付订单的设备有效期。
* 优先扫描最近成功订单,同时通过历史游标持续补扫老成功订单。
*/
// @Scheduled(fixedDelay = 600000, initialDelay = 120000)
public void repairDeviceDeadTime() {
try {
int count = payOrderBusinessService.repairPaidOrderDeviceDeadTime();
if (count > 0) {
log.warn("[支付有效期修复任务] 本次修复了 {} 台设备", count);
}
} catch (Exception e) {
log.error("[支付有效期修复任务] 执行异常: {}", e.getMessage(), e);
}
}
}