From c291e47ae857758b2a57f1bc56aa83d1567d3a54 Mon Sep 17 00:00:00 2001 From: DragonWenLong <552045633@qq.com> Date: Tue, 26 May 2026 15:38:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(mqtt):=20=E6=B7=BB=E5=8A=A0=E6=8A=A5?= =?UTF-8?q?=E8=AD=A6=E6=A3=80=E6=9F=A5=E6=9C=8D=E5=8A=A1=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=A4=9A=E9=98=B6=E6=AE=B5=E6=8A=A5=E8=AD=A6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 AlarmCheckService 提供延迟队列消费者功能 - 添加 AlarmDelayProvider 接口定义延迟检查任务 - 集成 AlarmStageConfig 支持租户配置报警阶段延迟时间 - 重构 AliyunVoiceUtil 返回完整响应对象而非字符串 - 在 AppDeviceController 中新增 AlarmList 接口查询设备告警列表 - 扩展设备相关控制器支持数据来源枚举参数传递 - 新增 Xinghan 指令控制器提供 HBY018A 设备专用接口 - 定义 DataSourceEnum 枚举区分 APP 和 Web 数据来源 - 扩展 Device 实体类增加紧急联系人和通知配置字段 - 添加 DeviceAlarm 实体类告警状态和等级属性 - 新增 DeviceContactPhoneBo 处理设备联系人信息 - 优化设备操作记录日志支持数据来源标识 - 实现设备自定义语音短信消息编辑功能 - 添加设备通知开关和紧急联系人设置接口 --- .../app/controller/AppDeviceController.java | 13 + .../device/AppDeviceXinghanController.java | 24 +- .../device/Xinghan/AppHBY018AController.java | 86 +++++ .../global/Provider/AlarmDelayProvider.java | 10 + .../Provider/RedissonAlarmDelayProvider.java | 47 +++ .../global/mqtt/config/AlarmStageConfig.java | 56 +++ .../rule/xinghan/XinghanDeviceDataRule.java | 52 +-- .../mqtt/service/AlarmCheckService.java | 318 ++++++++++++++++++ .../global/mqtt/utils/FloorCalculateUtil.java | 262 +++++++++++++++ .../global/queue/RedissonAlarmConsumer.java | 110 ++++++ .../device/DeviceDebugController.java | 3 +- .../device/DeviceXinghanController.java | 17 +- .../device/xinghan/WebHBY018AController.java | 95 ++++++ .../web/domain/vo/DeviceXinghanDetailVo.java | 17 + .../web/enums/NotificationSwitchEnum.java | 60 ++++ .../device/DeviceXinghanBizService.java | 189 +++++++++-- .../fuyuanshen/web/util/AliyunVoiceUtil.java | 9 +- .../controller/SmsSendRecordController.java | 106 ++++++ .../fuyuanshen/equipment/domain/Device.java | 21 ++ .../equipment/domain/DeviceAlarm.java | 8 + .../equipment/domain/SmsSendRecord.java | 79 +++++ .../equipment/domain/bo/DeviceAlarmBo.java | 5 + .../domain/bo/DeviceContactPhoneBo.java | 20 ++ .../equipment/domain/bo/SmsSendRecordBo.java | 80 +++++ .../equipment/domain/vo/DeviceAlarmVo.java | 9 + .../equipment/domain/vo/SmsSendRecordVo.java | 87 +++++ .../equipment/enums/DataSourceEnum.java | 32 ++ .../equipment/mapper/SmsSendRecordMapper.java | 9 + .../service/ISmsSendRecordService.java | 68 ++++ .../impl/SmsSendRecordServiceImpl.java | 141 ++++++++ .../mapper/equipment/DeviceAlarmMapper.xml | 3 + .../mapper/equipment/SmsSendRecordMapper.xml | 7 + .../job/integration/SnailJobClient.java | 145 ++++++++ 33 files changed, 2116 insertions(+), 72 deletions(-) create mode 100644 fys-admin/src/main/java/com/fuyuanshen/app/controller/device/Xinghan/AppHBY018AController.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/global/Provider/AlarmDelayProvider.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/global/Provider/RedissonAlarmDelayProvider.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/global/mqtt/config/AlarmStageConfig.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/global/mqtt/service/AlarmCheckService.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/global/mqtt/utils/FloorCalculateUtil.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/global/queue/RedissonAlarmConsumer.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/web/controller/device/xinghan/WebHBY018AController.java create mode 100644 fys-admin/src/main/java/com/fuyuanshen/web/enums/NotificationSwitchEnum.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/controller/SmsSendRecordController.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/SmsSendRecord.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceContactPhoneBo.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/SmsSendRecordBo.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/SmsSendRecordVo.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/enums/DataSourceEnum.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/mapper/SmsSendRecordMapper.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/ISmsSendRecordService.java create mode 100644 fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/SmsSendRecordServiceImpl.java create mode 100644 fys-modules/fys-equipment/src/main/resources/mapper/equipment/SmsSendRecordMapper.xml create mode 100644 fys-modules/fys-job/src/main/java/com/fuyuanshen/job/integration/SnailJobClient.java diff --git a/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppDeviceController.java b/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppDeviceController.java index 02976bfc..6599aa78 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppDeviceController.java +++ b/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppDeviceController.java @@ -9,9 +9,12 @@ import com.fuyuanshen.common.mybatis.core.page.TableDataInfo; import com.fuyuanshen.common.satoken.utils.AppLoginHelper; import com.fuyuanshen.common.web.core.BaseController; import com.fuyuanshen.equipment.domain.Device; +import com.fuyuanshen.equipment.domain.bo.DeviceAlarmBo; import com.fuyuanshen.equipment.domain.dto.AppDeviceBo; import com.fuyuanshen.equipment.domain.query.DeviceQueryCriteria; import com.fuyuanshen.equipment.domain.vo.AppDeviceVo; +import com.fuyuanshen.equipment.domain.vo.DeviceAlarmVo; +import com.fuyuanshen.equipment.service.IDeviceAlarmService; import com.fuyuanshen.web.service.device.DeviceBizService; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; @@ -30,8 +33,18 @@ import java.util.Map; public class AppDeviceController extends BaseController { private final DeviceBizService appDeviceService; + private final IDeviceAlarmService deviceAlarmService; + /** + * 查询设备告警列表 + */ + // @SaCheckPermission("equipment:alarm:list") + @GetMapping("/AlarmList") + public TableDataInfo AlarmList(DeviceAlarmBo bo, PageQuery pageQuery) { + return deviceAlarmService.queryPageList(bo, pageQuery); + } + /** * 查询设备列表 */ diff --git a/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/AppDeviceXinghanController.java b/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/AppDeviceXinghanController.java index 6746d867..7aeb28f6 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/AppDeviceXinghanController.java +++ b/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/AppDeviceXinghanController.java @@ -13,6 +13,7 @@ import com.fuyuanshen.customer.mapper.CustomerMapper; import com.fuyuanshen.equipment.domain.DeviceType; import com.fuyuanshen.equipment.domain.dto.AppDeviceSendMsgBo; import com.fuyuanshen.equipment.domain.form.DeviceForm; +import com.fuyuanshen.equipment.enums.DataSourceEnum; import com.fuyuanshen.equipment.mapper.DeviceMapper; import com.fuyuanshen.equipment.service.DeviceService; import com.fuyuanshen.equipment.service.DeviceTypeService; @@ -48,7 +49,7 @@ public class AppDeviceXinghanController extends BaseController { @PostMapping(value = "/registerPersonInfo") // @FunctionAccessAnnotation("registerPersonInfo") public R registerPersonInfo(@Validated(AddGroup.class) @RequestBody AppPersonnelInfoBo bo) { - return toAjax(appDeviceService.registerPersonInfo(bo)); + return toAjax(appDeviceService.registerPersonInfo(bo, DataSourceEnum.APP)); } /** @@ -58,7 +59,16 @@ public class AppDeviceXinghanController extends BaseController { @PostMapping(value = "/sendAlarmMessage") @FunctionAccessBatcAnnotation(value = "sendAlarmMessage", timeOut = 5, batchMaxTimeOut = 10) public R sendAlarmMessage(@RequestBody AppDeviceSendMsgBo bo) { - return toAjax(appDeviceService.sendAlarmMessage(bo)); + return toAjax(appDeviceService.sendAlarmMessage(bo, DataSourceEnum.APP)); + } + + /** + * 保存设备日志 + */ + @PostMapping(value = "/saveDeviceLog") + public R saveRecordDeviceLog(@RequestBody AppPersonnelInfoBo bo) { + appDeviceService.saveRecordDeviceLog(bo, DataSourceEnum.APP); + return R.ok(); } /** @@ -73,7 +83,7 @@ public class AppDeviceXinghanController extends BaseController { if (file.getSize() > 1024 * 1024 * 2) { return R.warn("图片不能大于2M"); } - appDeviceService.uploadDeviceLogo(bo); + appDeviceService.uploadDeviceLogo(bo, DataSourceEnum.APP); return R.ok(); } @@ -86,7 +96,7 @@ public class AppDeviceXinghanController extends BaseController { @PostMapping("/DetectGradeSettings") public R DetectGradeSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - appDeviceService.upDetectGradeSettings(params); + appDeviceService.upDetectGradeSettings(params, DataSourceEnum.APP); return R.ok(); } @@ -98,7 +108,7 @@ public class AppDeviceXinghanController extends BaseController { @PostMapping("/LightGradeSettings") public R LightGradeSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - appDeviceService.upLightGradeSettings(params); + appDeviceService.upLightGradeSettings(params, DataSourceEnum.APP); return R.ok(); } @@ -110,7 +120,7 @@ public class AppDeviceXinghanController extends BaseController { @PostMapping("/SOSGradeSettings") public R SOSGradeSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - appDeviceService.upSOSGradeSettings(params); + appDeviceService.upSOSGradeSettings(params, DataSourceEnum.APP); return R.ok(); } @@ -122,7 +132,7 @@ public class AppDeviceXinghanController extends BaseController { @PostMapping("/ShakeBitSettings") public R ShakeBitSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - appDeviceService.upShakeBitSettings(params); + appDeviceService.upShakeBitSettings(params, DataSourceEnum.APP); return R.ok(); } diff --git a/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/Xinghan/AppHBY018AController.java b/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/Xinghan/AppHBY018AController.java new file mode 100644 index 00000000..efdf6599 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/app/controller/device/Xinghan/AppHBY018AController.java @@ -0,0 +1,86 @@ +package com.fuyuanshen.app.controller.device.Xinghan; + + +import com.fuyuanshen.app.domain.bo.AppPersonnelInfoBo; +import com.fuyuanshen.app.domain.dto.AppDeviceLogoUploadDto; +import com.fuyuanshen.common.core.domain.R; +import com.fuyuanshen.common.core.validate.AddGroup; +import com.fuyuanshen.common.log.annotation.Log; +import com.fuyuanshen.common.ratelimiter.annotation.FunctionAccessAnnotation; +import com.fuyuanshen.common.ratelimiter.annotation.FunctionAccessBatcAnnotation; +import com.fuyuanshen.common.web.core.BaseController; +import com.fuyuanshen.equipment.domain.bo.DeviceContactPhoneBo; +import com.fuyuanshen.equipment.domain.dto.AppDeviceSendMsgBo; +import com.fuyuanshen.equipment.enums.DataSourceEnum; +import com.fuyuanshen.equipment.service.DeviceService; +import com.fuyuanshen.web.domain.Dto.DeviceXinghanInstructDto; +import com.fuyuanshen.web.service.device.DeviceXinghanBizService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/app/hby018a/device") +public class AppHBY018AController extends BaseController { + private final DeviceXinghanBizService appDeviceService; + private final DeviceService deviceService; + + /** + * 照明档位 + * 照明档位,2,1,0,分别表示弱光/强光/关闭 + */ + @Log(title = "xinghan指令-照明档位") + @PostMapping("/SideLightSettings") + public R SideLightSettings(@RequestBody DeviceXinghanInstructDto params) { + // params 转 JSONObject + appDeviceService.upSideLightSettings(params, DataSourceEnum.APP); + return R.ok(); + } + + /** + * 强制报警状态 + * 强制报警状态,0-未报警,1-正在报警。 + */ + @Log(title = "xinghan指令-强制报警状态") + @PostMapping("/ShakeBitSettings") + public R ShakeBitSettings(@RequestBody DeviceXinghanInstructDto params) { + // params 转 JSONObject + appDeviceService.upShakeBitSettings(params, DataSourceEnum.APP); + return R.ok(); + } + + /** + * 自定义语音消息 + */ + @PostMapping("/SetVoiceMsg") + public R editSosVoiceMsg(@RequestBody AppDeviceSendMsgBo bo) { + return toAjax(appDeviceService.sendAlarmMessage(bo, DataSourceEnum.APP)); + } + + /** + * 自定义短信消息 + */ + @PostMapping("/SetSmsMsg") + public R editSosSmsMsg(@RequestBody AppDeviceSendMsgBo bo) { + return toAjax(appDeviceService.editSosSmsMsg(bo)); + } + + /** + * 设置消息通知开关 + */ + @PostMapping("/SetNotificationEnabled") + public R editNotificationEnabled(@RequestBody DeviceContactPhoneBo bo) { + return toAjax(appDeviceService.editNotificationEnabled(bo)); + } + + /** + * 添加设备紧急联系人 + */ + @PostMapping("/SetContactPhone") + public R editContactPhone(@RequestBody DeviceContactPhoneBo bo) { + return toAjax(appDeviceService.editContactPhone(bo)); + } +} diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/Provider/AlarmDelayProvider.java b/fys-admin/src/main/java/com/fuyuanshen/global/Provider/AlarmDelayProvider.java new file mode 100644 index 00000000..13e874c4 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/global/Provider/AlarmDelayProvider.java @@ -0,0 +1,10 @@ +package com.fuyuanshen.global.Provider; + +public interface AlarmDelayProvider { + /** + * 发送延迟检查任务 + * @param alarmId 报警表主键ID + * @param delayInSeconds 延迟秒数(如 180) + */ + void sendDelayCheck(Long alarmId, long delayInSeconds); +} diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/Provider/RedissonAlarmDelayProvider.java b/fys-admin/src/main/java/com/fuyuanshen/global/Provider/RedissonAlarmDelayProvider.java new file mode 100644 index 00000000..09069664 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/global/Provider/RedissonAlarmDelayProvider.java @@ -0,0 +1,47 @@ +package com.fuyuanshen.global.Provider; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RBlockingQueue; +import org.redisson.api.RDelayedQueue; +import org.redisson.api.RedissonClient; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@Primary +@RequiredArgsConstructor +@Slf4j +public class RedissonAlarmDelayProvider implements AlarmDelayProvider { + + private final RedissonClient redissonClient; + public static final String QUEUE_NAME = "PROD:ALARM:DELAY:CHECK"; + + private RDelayedQueue delayedQueue; + + @PostConstruct + public void init() { + // 初始化一次,内部转移任务会持续运行 + RBlockingQueue blockingQueue = redissonClient.getBlockingQueue(QUEUE_NAME); + delayedQueue = redissonClient.getDelayedQueue(blockingQueue); + log.info("Redisson 延迟报警队列初始化完成:{}", QUEUE_NAME); + } + + @Override + public void sendDelayCheck(Long alarmId, long delayInSeconds) { + // 复用已初始化的实例,避免创建多余对象 + delayedQueue.offer(alarmId, delayInSeconds, TimeUnit.SECONDS); + } + + @PreDestroy + public void destroy() { + log.info("正在销毁 Redisson 延迟报警队列..."); + if (delayedQueue != null) { + delayedQueue.destroy(); // 释放后台任务和资源 + } + } +} diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/config/AlarmStageConfig.java b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/config/AlarmStageConfig.java new file mode 100644 index 00000000..ae9b84f6 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/config/AlarmStageConfig.java @@ -0,0 +1,56 @@ +package com.fuyuanshen.global.mqtt.config; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fuyuanshen.common.mybatis.helper.DataBaseHelper; +import com.fuyuanshen.common.satoken.utils.LoginHelper; +import com.fuyuanshen.system.domain.SysDictData; +import com.fuyuanshen.system.mapper.SysDictDataMapper; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.SessionScope; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class AlarmStageConfig { + @Autowired + private SysDictDataMapper dictDataMapper; + + // 租户ID -> (阶段编号 -> 延迟秒数) + private final Map> tenantConfigCache = new ConcurrentHashMap<>(); + + public int getTotalStages(String tenantId) { + return getConfig(tenantId).size(); + } + + public long getDelayByStage(String tenantId, int stage) { + return getConfig(tenantId).getOrDefault(stage, 5 * 60L); + } + + private Map getConfig(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + throw new IllegalArgumentException("租户ID不能为空"); + } + return tenantConfigCache.computeIfAbsent(tenantId, this::loadConfigForTenant); + } + + private Map loadConfigForTenant(String tenantId) { + // 显式使用租户ID作为查询条件,不依赖任何 ThreadLocal + List delays = dictDataMapper.selectList( + new LambdaQueryWrapper() + .eq(SysDictData::getDictType, "alarm_stage_delay") + .eq(SysDictData::getTenantId, tenantId) // 假设实体有 getTenantId 字段 + .orderByAsc(SysDictData::getDictSort) + ); + Map config = new HashMap<>(); + for (SysDictData d : delays) { + config.put(d.getDictSort(), Long.parseLong(d.getDictValue())); + } + return config; + } +} \ No newline at end of file diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/rule/xinghan/XinghanDeviceDataRule.java b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/rule/xinghan/XinghanDeviceDataRule.java index f0898420..03b82858 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/rule/xinghan/XinghanDeviceDataRule.java +++ b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/rule/xinghan/XinghanDeviceDataRule.java @@ -31,6 +31,7 @@ import com.fuyuanshen.equipment.service.IDeviceGeoFenceService; import com.fuyuanshen.equipment.utils.map.AmapTrackUtil; import com.fuyuanshen.equipment.utils.map.GetAddressFromLatUtil; import com.fuyuanshen.equipment.utils.map.LngLonUtil; +import com.fuyuanshen.global.Provider.AlarmDelayProvider; import com.fuyuanshen.global.mqtt.base.MqttMessageRule; import com.fuyuanshen.global.mqtt.base.MqttRuleContext; import com.fuyuanshen.global.mqtt.base.MqttXinghanJson; @@ -93,6 +94,8 @@ public class XinghanDeviceDataRule implements MqttMessageRule { private final IDeviceFenceAccessRecordService deviceFenceAccessRecordService; private final DeviceFenceAccessRecordMapper deviceFenceAccessRecordMapper; private final DeviceGeoFenceMapper deviceGeoFenceMapper; + private final RedissonClient redissonClient; + private final AlarmDelayProvider alarmDelayProvider; // 注入你实现的延迟提供者接口 /** 位置未发生明显变化的距离阈值(米),可通过配置中心动态调整 */ private final double MOVEMENT_THRESHOLD_METER = 10.0; @@ -103,7 +106,9 @@ public class XinghanDeviceDataRule implements MqttMessageRule { // Latitude, longitude //主灯档位,激光灯档位,电量百分比,充电状态,电池剩余续航时间 MqttXinghanJson deviceStatus = objectMapper.convertValue(context.getPayloadDict(), MqttXinghanJson.class); - deviceStatus.setBatteryPercentage(deviceStatus.getStaPowerPercent().toString()); + if (deviceStatus.getStaPowerPercent() != null) { + deviceStatus.setBatteryPercentage(deviceStatus.getStaPowerPercent().toString()); + } // 发送设备状态和位置信息到Redis asyncSendDeviceDataToRedisWithFuture(context.getDeviceImei(),deviceStatus); RedisUtils.setCacheObject(functionAccess, FunctionAccessStatus.OK.getCode(), Duration.ofSeconds(20)); @@ -171,44 +176,46 @@ public class XinghanDeviceDataRule implements MqttMessageRule { */ private void handleSingleAlarm(String deviceImei, boolean nowAlarming, AlarmTypeEnum type) { String redisKey = buildAlarmRedisKey(deviceImei, type); - Long alarmId = RedisUtils.getCacheObject(redisKey); - String lockKey = redisKey + ":lock"; // 分布式锁 key - RedissonClient client = RedisUtils.getClient(); // 唯一用到的“旧”入口 - RLock lock = client.getLock(lockKey); - - // ---------- 情况 1:当前正在报警 ---------- if (nowAlarming) { - // 已存在未结束报警 -> 什么都不做(同一条报警) if (alarmId != null) { - // key 还在 -> 同一条报警,只续期 RedisUtils.setCacheObject(redisKey, alarmId, Duration.ofMinutes(10)); return; } - // 需要新建,抢锁 + + String lockKey = redisKey + ":lock"; + RedissonClient client = RedisUtils.getClient(); + RLock lock = client.getLock(lockKey); boolean locked = false; try { - locked = lock.tryLock(3, TimeUnit.SECONDS); // 最多等 3 s - if (!locked) { // 抢不到直接放弃 + locked = lock.tryLock(3, TimeUnit.SECONDS); + if (!locked) { + log.warn("抢锁失败,放弃创建报警 device={}, type={}", deviceImei, type.getDesc()); return; } - // 锁内二次校验(double-check) + + // 二次校验 alarmId = RedisUtils.getCacheObject(redisKey); if (alarmId != null) { - return; // 并发线程已建好 + return; } - // 不存在 -> 新建 DeviceAlarmBo bo = createAlarmBo(deviceImei, type); - if (bo == null){ + if (bo == null) { return; } deviceAlarmService.insertByBo(bo); - RedisUtils.setCacheObject(redisKey, bo.getId(), Duration.ofMinutes(10)); // 5分钟后结束过期 - }catch (InterruptedException ignore) { - // 立即中断并退出,禁止继续往下走 + + // 初始化阶段并投递第一个3分钟检查 + redissonClient.getBucket("alarm:stage:" + bo.getId()).set(0L, Duration.ofMinutes(10)); + alarmDelayProvider.sendDelayCheck(bo.getId(), 180); // 3分钟 + RedisUtils.setCacheObject(redisKey, bo.getId(), Duration.ofMinutes(10)); + + } catch (InterruptedException e) { Thread.currentThread().interrupt(); + log.warn("线程中断,放弃创建报警 device={}, type={}", deviceImei, type.getDesc()); + return; // 明确返回 } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); @@ -217,11 +224,11 @@ public class XinghanDeviceDataRule implements MqttMessageRule { return; } - // ---------- 情况 2:当前不报警 ---------- + // 不报警 if (alarmId != null) { - // 结束它 finishAlarm(alarmId); RedisUtils.deleteObject(redisKey); + log.info("设备[{}] {}警报已解除", deviceImei, type.getDesc()); } } @@ -237,6 +244,7 @@ public class XinghanDeviceDataRule implements MqttMessageRule { bo.setFinishTime(new Date()); bo.setDurationTime(DurationUtils.getDurationBetween(vo.getStartTime(), bo.getFinishTime())); bo.setTreatmentState(0); // 已处理 + bo.setAlarmState(1); deviceAlarmService.updateByBo(bo); } @@ -250,10 +258,12 @@ public class XinghanDeviceDataRule implements MqttMessageRule { } DeviceAlarmBo bo = new DeviceAlarmBo(); bo.setDeviceId(device.getId()); + bo.setDeviceName(device.getDeviceName()); bo.setDeviceImei(deviceImei); bo.setDeviceAction(2); // 自动报警 bo.setStartTime(new Date()); bo.setTreatmentState(1); // 未处理 + bo.setAlarmState(0); bo.setTenantId(device.getTenantId()); // 报警内容 diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/service/AlarmCheckService.java b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/service/AlarmCheckService.java new file mode 100644 index 00000000..924efe53 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/service/AlarmCheckService.java @@ -0,0 +1,318 @@ +package com.fuyuanshen.global.mqtt.service; + +import cn.hutool.core.bean.BeanUtil; +import com.aliyun.dyvmsapi20170525.models.SingleCallByTtsResponse; +import com.fuyuanshen.common.core.utils.date.DurationUtils; +import com.fuyuanshen.equipment.domain.Device; +import com.fuyuanshen.equipment.domain.SmsSendRecord; +import com.fuyuanshen.equipment.domain.bo.DeviceAlarmBo; +import com.fuyuanshen.equipment.domain.vo.DeviceAlarmVo; +import com.fuyuanshen.equipment.mapper.SmsSendRecordMapper; +import com.fuyuanshen.equipment.service.DeviceService; +import com.fuyuanshen.equipment.service.IDeviceAlarmService; +import com.fuyuanshen.global.Provider.AlarmDelayProvider; +import com.fuyuanshen.global.mqtt.config.AlarmStageConfig; +import com.fuyuanshen.web.enums.NotificationSwitchEnum; +import com.fuyuanshen.web.service.device.DeviceXinghanBizService; +import com.fuyuanshen.web.util.AliyunVoiceUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.sms4j.api.SmsBlend; +import org.dromara.sms4j.api.entity.SmsResponse; +import org.dromara.sms4j.core.factory.SmsFactory; +import org.redisson.api.RAtomicLong; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AlarmCheckService { + + private final IDeviceAlarmService deviceAlarmService; + private final DeviceXinghanBizService appDeviceService; + private final RedissonClient redissonClient; + private final AlarmDelayProvider alarmDelayProvider; + private final SmsSendRecordMapper smsSendRecordMapper; + private final DeviceService deviceService; + private final AlarmStageConfig stageConfig; + private final AliyunVoiceUtil voiceUtil; + + private static final String STAGE_KEY_PREFIX = "alarm:stage:"; + private static final String SMS_SENT_PREFIX = "alarm:sms:sent:"; + private static final String SMS_TEMPLATE_ID = "SMS_506445365"; + private static final String TTS_TEMPLATE_ID = "TTS_328730104"; + /** + * 延迟队列消费者入口 + */ + public void executeCheck(Long alarmId) { + // 1. 加载报警记录,判断是否仍有效 + DeviceAlarmVo alarm = deviceAlarmService.queryById(alarmId); + if (alarm == null || alarm.getTreatmentState() != 1 || alarm.getFinishTime() != null) { + cleanStage(alarmId); + log.info("报警[{}]已失效或已处理,流程终止", alarmId); + return; + } + + // 2. 设备离线 → 自动结束报警 + if (appDeviceService.isDeviceOffline(alarm.getDeviceImei())) { + finishAlarm(alarm); + cleanStage(alarmId); + log.info("设备[{}]离线,报警[{}]自动结束", alarm.getDeviceImei(), alarmId); + return; + } + + // 3. 原子推进阶段 + int currentStage = advanceStage(alarmId); + int totalStages = stageConfig.getTotalStages(alarm.getTenantId()); + log.info("报警[{}]进入第{}/{}阶段", alarmId, currentStage, totalStages); + + // 超出总阶段数 → 清理并结束 + if (currentStage > totalStages) { + cleanStage(alarmId); + log.info("报警[{}]已完成全部{}个阶段", alarmId, totalStages); + return; + } + + // 4. 执行当前阶段的动作 + executeStageAction(currentStage, totalStages, alarm); + + // 5. 如果不是最后一个阶段,则投递下一阶段延迟任务 + if (currentStage < totalStages) { + long delaySeconds = stageConfig.getDelayByStage(alarm.getTenantId(), currentStage); + scheduleNext(alarmId, currentStage + 1, delaySeconds); + } else { + // 最后一个阶段执行完毕,清理 Redis 标记 + cleanStage(alarmId); + } + } + + // ---------- 通用阶段动作:阶段1固定,中间阶段统一,最后阶段固定 ---------- + /** + * 阶段动作规则: + * - 阶段1:仅升级报警等级(如升至2) + * - 中间阶段(2 <= stage < totalStages):升级等级 + 发送短信 + * - 最后阶段(stage == totalStages):仅发送短信(不再升级,并结束流程) + */ + private void executeStageAction(int stage, int totalStages, DeviceAlarmVo alarm) { + boolean isFirst = (stage == 1); + boolean isLast = (stage == totalStages); + boolean isMiddle = (!isFirst && !isLast); + + // 升级等级(阶段1和中间阶段升级) + if (isFirst || isMiddle) { + // 等级递增:阶段1升到2,阶段2升到3,阶段3升到4... + int newLevel = stage + 1; // 因为 stage 从1开始,newLevel = stage+1 + updateAlarmLevel(alarm, newLevel); + log.info("报警[{}]等级上升至{}", alarm.getId(), newLevel); + } + + // 发送短信(中间阶段和最后阶段发短信) + if (isMiddle || isLast) { + if (sendSmsIfNeeded(alarm, stage)) { + log.info("报警[{}]第{}阶段短信已发送", alarm.getId(), stage); + } + } + + if (isLast) { + log.info("报警[{}]最后阶段执行完毕", alarm.getId()); + } + } + // ---------- 辅助方法 ---------- + + /** + * 原子推进阶段:Redis 中 key = alarm:stage:{alarmId} 的值 +1,返回新值。 + * 如果 key 不存在则初始化为 1。使用 Lua 保证原子并返回推进后的值。 + */ + private int advanceStage(Long alarmId) { + String key = STAGE_KEY_PREFIX + alarmId; + RAtomicLong stageCounter = redissonClient.getAtomicLong(key); + // 第一次调用:key 不存在 -> 视为0,incrementAndGet 返回 1 + // 后续调用:依次返回 2, 3, 4... + long newStage = stageCounter.incrementAndGet(); + return (int) newStage; + } + + /** + * 仅当该阶段未发送过短信时执行发送,并记录已发送标记。 + */ + private boolean sendSmsIfNeeded(DeviceAlarmVo alarm, int stage) { + String sentKey = SMS_SENT_PREFIX + alarm.getId() + ":stage" + stage; + boolean success = redissonClient.getBucket(sentKey).setIfAbsent("1", java.time.Duration.ofHours(2)); + if (!success) { + log.debug("报警[{}]阶段{}短信已发送过,跳过", alarm.getId(), stage); + return false; + } + sendTemplateSms(alarm, stage); + return true; + } + + /** + * 投递下一个延迟检查任务 + */ + private void scheduleNext(Long alarmId, int nextStage, long delaySeconds) { + // 投递下一个延迟检查任务(队列中依旧是 alarmId) + alarmDelayProvider.sendDelayCheck(alarmId, delaySeconds); + log.debug("报警[{}]已投递阶段{}延时{}秒", alarmId, nextStage, delaySeconds); + } + + /** + * 发送模板短信(自动重试到备用联系人) + */ + private void sendTemplateSms(DeviceAlarmVo alarm, int stage) { + Device device = deviceService.selectDeviceByImei(alarm.getDeviceImei()); + if (device == null) return; + + // 把数据库的数字转成枚举 + NotificationSwitchEnum notifyStatus = NotificationSwitchEnum.getByCode(device.getNotificationEnabled()); + // 判断是否关闭(0=关闭) + if (notifyStatus.isClosed()) { + log.info("设备{}已关闭通知,跳过发送", device.getId()); + return; + } + + String primaryPhone = device.getContact1Phone(); + String backupPhone = device.getContact2Phone(); + + // 下面你可以分别判断短信/语音 + if (notifyStatus.hasSms()) { + // 先尝试发送给主联系人 + boolean primarySuccess = sendSmsToPhone(primaryPhone, device, alarm, stage); + if (!primarySuccess && backupPhone != null && !backupPhone.isEmpty()) { + // 主联系人失败且有备用联系人,则发送给备用联系人 + sendSmsToPhone(backupPhone, device, alarm, stage); + } + } + + if (notifyStatus.hasVoice()) { + // 发送语音逻辑 + } + + + } + + /** + * 向指定手机号发送短信并记录发送结果 + * @return true-发送成功,false-发送失败 + */ + private boolean sendSmsToPhone(String phone, Device device, DeviceAlarmVo alarm, int stage) { + if (phone == null || phone.isEmpty()) { + log.warn("手机号为空,无法发送报警[{}]的短信", alarm.getId()); + return false; + } + SmsBlend smsBlend = SmsFactory.getSmsBlend("config1"); + // 预创建待发送记录 + SmsSendRecord record = new SmsSendRecord(); + record.setAlarmId(alarm.getId()); + record.setDeviceImei(alarm.getDeviceImei()); + record.setNotifyType("SMS"); + record.setPhone(phone); + record.setContent("报警阶段" + stage); + record.setTemplateId(SMS_TEMPLATE_ID); + record.setStatus(0L); // 待发送 + record.setCreateTime(new Date()); + record.setTenantId(alarm.getTenantId()); + record.setCreateTime(new Date()); + smsSendRecordMapper.insert(record); + + boolean success = false; + String responseMsg = null; + try { + String location = alarm.getLocation() != null ? alarm.getLocation() : ""; + LinkedHashMap vars = new LinkedHashMap<>(); + vars.put("name", device.getDeviceName()); + vars.put("address", String.format("%tT开始持续报警(第%d阶段)%s", alarm.getStartTime(), stage, location)); + vars.put("msg", device.getSosSmsMsg()); + SmsResponse response = smsBlend.sendMessage(phone, SMS_TEMPLATE_ID, vars); + success = response.isSuccess(); + responseMsg = success ? "成功" : "失败" + response.getData(); + } catch (Exception e) { + responseMsg = "异常:" + e.getMessage(); + log.error("短信发送异常,手机号:{}", phone, e); + } + + // 更新发送记录 + record.setStatus(success ? 1L : 2L); + record.setResponseMsg(responseMsg); + record.setSendTime(new Date()); + smsSendRecordMapper.updateById(record); + + return success; + } + + /** + * 向指定手机号发送语音并记录发送结果 + * @return true-发送成功,false-发送失败 + */ + private boolean sendVoiceToPhone(String phone, Device device, DeviceAlarmVo alarm, int stage) { + if (phone == null || phone.isEmpty()) { + log.warn("手机号为空,无法发送报警[{}]的语音", alarm.getId()); + return false; + } + // 预创建待发送记录 + SmsSendRecord record = new SmsSendRecord(); + record.setAlarmId(alarm.getId()); + record.setDeviceImei(alarm.getDeviceImei()); + record.setNotifyType("VOICE"); + record.setPhone(phone); + record.setContent("报警阶段" + stage); + record.setTemplateId(TTS_TEMPLATE_ID); + record.setStatus(0L); // 待发送 + record.setCreateTime(new Date()); + record.setTenantId(alarm.getTenantId()); + record.setCreateTime(new Date()); + smsSendRecordMapper.insert(record); + + boolean success = false; + String responseMsg = null; + try { + Map params = Map.of("device", alarm.getDeviceName()); + SingleCallByTtsResponse response = voiceUtil.sendTtsSync(phone, TTS_TEMPLATE_ID, params); + success = "OK".equalsIgnoreCase(response.getBody().getCode()); + responseMsg = success ? "成功" : "失败" + response.getBody().getMessage(); + } catch (Exception e) { + responseMsg = "异常:" + e.getMessage(); + log.error("语音发送异常,手机号:{}", phone, e); + } + + // 更新发送记录 + record.setStatus(success ? 1L : 2L); + record.setResponseMsg(responseMsg); + record.setSendTime(new Date()); + smsSendRecordMapper.updateById(record); + + return success; + } + + /** + * 更新报警等级 + */ + private void updateAlarmLevel(DeviceAlarmVo alarm, int level) { + // 调用 deviceAlarmService 更新等级字段 + log.info("报警[{}]等级已更新为{}", alarm.getId(), level); + if (alarm == null || alarm.getTreatmentState() == 0) return; + DeviceAlarmBo bo = BeanUtil.toBean(alarm, DeviceAlarmBo.class); + bo.setAlarmLevel(level); + deviceAlarmService.updateByBo(bo); + } + + private void cleanStage(Long alarmId) { + redissonClient.getBucket(STAGE_KEY_PREFIX + alarmId).deleteAsync(); + } + + private void finishAlarm(DeviceAlarmVo alarm) { + if (alarm == null || alarm.getTreatmentState() == 0) return; + DeviceAlarmBo bo = BeanUtil.toBean(alarm, DeviceAlarmBo.class); + bo.setFinishTime(new Date()); + bo.setDurationTime(DurationUtils.getDurationBetween(alarm.getStartTime(), bo.getFinishTime())); + bo.setTreatmentState(0); // 已处理 + bo.setAlarmState(1); + deviceAlarmService.updateByBo(bo); + } +} \ No newline at end of file diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/utils/FloorCalculateUtil.java b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/utils/FloorCalculateUtil.java new file mode 100644 index 00000000..559e087e --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/global/mqtt/utils/FloorCalculateUtil.java @@ -0,0 +1,262 @@ +package com.fuyuanshen.global.mqtt.utils; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 生产环境 - 楼层计算工具(优化版) + * 核心改进:直接使用Open-Meteo返回的surface_pressure地面气压 + * 无需再用海拔反推,简化计算且精度更高 + */ +public class FloorCalculateUtil { + + private static final double FLOOR_HEIGHT = 3.0; + private static final int HTTP_TIMEOUT = 3000; + + // API地址:同时获取地面气压和海平面气压 + private static final String WEATHER_API = + "https://api.open-meteo.com/v1/forecast?current=pressure_msl,surface_pressure&latitude=%s&longitude=%s"; + + private static final Map BASELINE_CACHE = new ConcurrentHashMap<>(); + private static final long CACHE_EXPIRE_TIME_MS = 30 * 60 * 1000; + + /** + * 内部类:存储位置的基准数据 + */ + private static class LocationBaseline { + double groundPressure; // 地面真实气压(hPa) - 直接来自API的surface_pressure + double seaPressure; // 海平面气压(hPa) - 保留备用 + double elevation; // 海拔高度(米) - 新增 + long updateTime; + + LocationBaseline(double groundPressure, double seaPressure, double elevation) { + this.groundPressure = groundPressure; + this.seaPressure = seaPressure; + this.elevation = elevation; + this.updateTime = System.currentTimeMillis(); + } + + boolean isExpired() { + return (System.currentTimeMillis() - this.updateTime) > CACHE_EXPIRE_TIME_MS; + } + } + + /** + * 楼层计算结果类(包含海拔信息) + */ + public static class FloorResult { + public final int floor; // 楼层号 + public final double elevation; // 海拔高度(米) + public final double relativeHeight; // 相对地面高度(米) + + public FloorResult(int floor, double elevation, double relativeHeight) { + this.floor = floor; + this.elevation = elevation; + this.relativeHeight = relativeHeight; + } + + @Override + public String toString() { + return String.format("楼层=%d, 海拔=%.1f米, 相对高度=%.1f米", + floor, elevation, relativeHeight); + } + } + + /** + * 计算当前楼层(带海拔输出) + * @param pressureList 设备采集的气压值列表(单位:hPa) + * @param latitude 纬度 + * @param longitude 经度 + * @return FloorResult 包含楼层、海拔、相对高度的结果对象 + */ + public static FloorResult getCurrentFloorWithElevation(List pressureList, double latitude, double longitude) { + try { + // 1. 计算设备采集的实时平均气压 + double avgPressure = getAndCheckAveragePressure(pressureList); + + // 2. 硬件校准:扣除设备固有的硬件正偏差 + double calibratedPressure = avgPressure - 2.1; + + // 3. 从API获取该位置的地面真实气压和海拔 + LocationBaseline baseline = getBaselineWithCache(latitude, longitude); + double groundPressure = baseline.groundPressure; + double elevation = baseline.elevation; // 获取海拔 + + System.out.printf("设备气压: %.2f hPa | 校准后: %.2f hPa | 地面气压: %.2f hPa | 海拔: %.1f米%n", + avgPressure, calibratedPressure, groundPressure, elevation); + + // 4. 核心计算:根据气压差计算相对高度 + double relativeHeight = 19411.7 * Math.log10(groundPressure / calibratedPressure); + + System.out.printf("相对高度:%.2f 米%n", relativeHeight); + + // 5. 将相对高度转换为楼层 + int floor = convertHeightToFloor(relativeHeight); + + // 6. 返回包含海拔的结果 + return new FloorResult(floor, elevation, relativeHeight); + + } catch (Exception e) { + System.err.println(String.format("楼层计算异常,坐标:%.4f,%.4f,错误:%s", + latitude, longitude, e.getMessage())); + return new FloorResult(0, 0, 0); + } + } + + /** + * 兼容旧版本:只返回楼层号 + */ + public static int getCurrentFloor(List pressureList, double latitude, double longitude) { + return getCurrentFloorWithElevation(pressureList, latitude, longitude).floor; + } + + private static double getAndCheckAveragePressure(List pressureList) { + if (pressureList == null || pressureList.isEmpty()) { + throw new IllegalArgumentException("气压采样数据不能为空"); + } + return pressureList.stream() + .filter(p -> p > 800 && p < 1100) + .mapToDouble(Double::doubleValue) + .average() + .orElseThrow(() -> new IllegalArgumentException("气压数据无效")); + } + + /** + * 获取基准数据(带本地缓存)- 现在包含海拔 + */ + private static LocationBaseline getBaselineWithCache(double lat, double lng) throws Exception { + String cacheKey = String.format("%.6f,%.6f", lat, lng); + LocationBaseline cached = BASELINE_CACHE.get(cacheKey); + + if (cached != null && !cached.isExpired()) { + return cached; + } + + // 请求API获取数据 + WeatherData weatherData = requestWeatherData(lat, lng); + + LocationBaseline newBaseline = new LocationBaseline( + weatherData.surfacePressure, + weatherData.pressureMsl, + weatherData.elevation // 新增海拔参数 + ); + BASELINE_CACHE.put(cacheKey, newBaseline); + return newBaseline; + } + + /** + * 请求 Open-Meteo API + */ + private static WeatherData requestWeatherData(double lat, double lng) throws Exception { + String apiUrl = String.format(WEATHER_API, lat, lng); + String json = executeHttpGet(apiUrl); + + // 解析JSON - elevation 在根对象,不在 current 块内 + double elevation = parseJsonValue(json, "\"elevation\":", null); + double pressureMsl = parseJsonValue(json, "\"pressure_msl\":", "\"current\":"); + double surfacePressure = parseJsonValue(json, "\"surface_pressure\":", "\"current\":"); + + System.out.printf("API返回: 海拔=%.1f米, 海平面气压=%.1f hPa, 地面气压=%.1f hPa%n", + elevation, pressureMsl, surfacePressure); + + return new WeatherData(pressureMsl, surfacePressure, elevation); + } + + private static double parseJsonValue(String json, String targetKey, String blockStartKey) { + int start; + + if (blockStartKey != null && json.contains(blockStartKey)) { + int blockStart = json.indexOf(blockStartKey); + start = json.indexOf(targetKey, blockStart); + } else { + start = json.indexOf(targetKey); + } + + if (start == -1) { + throw new RuntimeException("解析JSON失败,未找到键: " + targetKey); + } + + start += targetKey.length(); + int end = json.indexOf(",", start); + if (end == -1) end = json.indexOf("}", start); + if (end == -1) end = json.indexOf("]", start); + + String rawVal = json.substring(start, end); + String cleanVal = rawVal.replaceAll("[^0-9.\\-]", "").trim(); + + if (cleanVal.isEmpty()) { + throw new RuntimeException("清洗JSON数值后结果为空,原始片段: " + rawVal); + } + + return Double.parseDouble(cleanVal); + } + + private static String executeHttpGet(String apiUrl) throws Exception { + HttpURLConnection conn = null; + BufferedReader reader = null; + try { + URL url = new URL(apiUrl); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(HTTP_TIMEOUT); + conn.setReadTimeout(HTTP_TIMEOUT); + conn.setRequestProperty("User-Agent", "FloorCalculateUtil/1.0"); + + reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + return response.toString(); + } finally { + if (reader != null) reader.close(); + if (conn != null) conn.disconnect(); + } + } + + private static int convertHeightToFloor(double realHeight) { + if (realHeight < -0.5) { + return (int) Math.floor(realHeight / FLOOR_HEIGHT); + } + int floor = (int) Math.round(realHeight / FLOOR_HEIGHT); + return Math.max(floor, 1); + } + + /** + * 内部数据类 - 新增 elevation 字段 + */ + private static class WeatherData { + double pressureMsl; + double surfacePressure; + double elevation; // 新增 + + WeatherData(double pressureMsl, double surfacePressure, double elevation) { + this.pressureMsl = pressureMsl; + this.surfacePressure = surfacePressure; + this.elevation = elevation; + } + } + + // ====================== 测试验证 ====================== + public static void main(String[] args) { + // 测试数据 + List pressureList = List.of(1001.591, 1001.593, 1001.584, 1001.589, 1001.591); + double lat = 30.490020; + double lng = 114.415369; + + // 新方法:获取包含海拔的完整结果 + FloorResult result = getCurrentFloorWithElevation(pressureList, lat, lng); + System.out.println("【优化版】计算结果:" + result); + + // 也可以单独获取海拔 + System.out.println("海拔高度:" + result.elevation + " 米"); + System.out.println("相对高度:" + result.relativeHeight + " 米"); + System.out.println("楼层:" + result.floor); + } +} \ No newline at end of file diff --git a/fys-admin/src/main/java/com/fuyuanshen/global/queue/RedissonAlarmConsumer.java b/fys-admin/src/main/java/com/fuyuanshen/global/queue/RedissonAlarmConsumer.java new file mode 100644 index 00000000..ec46b485 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/global/queue/RedissonAlarmConsumer.java @@ -0,0 +1,110 @@ +package com.fuyuanshen.global.queue; + +import com.fuyuanshen.global.Provider.RedissonAlarmDelayProvider; +import com.fuyuanshen.global.mqtt.service.AlarmCheckService; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RBlockingQueue; +import org.redisson.api.RedissonClient; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RedissonAlarmConsumer implements CommandLineRunner { + + private final RedissonClient redissonClient; + private final AlarmCheckService alarmCheckService; + + private volatile boolean running = true; + private Thread consumerThread; + private ExecutorService bizExecutor; + private static final int BIZ_THREADS = 4; // 业务处理线程数 + private static final int BIZ_QUEUE_CAPACITY = 200; // 有界队列容量 + + @Override + public void run(String... args) { + // 初始化业务处理线程池(有界队列 + 调用者运行拒绝策略,避免 OOM) + bizExecutor = Executors.newFixedThreadPool( + BIZ_THREADS, + new ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(1); + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "Alarm-Biz-" + counter.getAndIncrement()); + t.setDaemon(false); + return t; + } + } + ); + + // 启动消费线程 + consumerThread = new Thread(() -> { + RBlockingQueue blockingQueue = redissonClient.getBlockingQueue(RedissonAlarmDelayProvider.QUEUE_NAME); + log.info("Redisson 延迟报警监听线程已启动..."); + while (running && !Thread.currentThread().isInterrupted()) { + try { + Long alarmId = blockingQueue.poll(1, TimeUnit.SECONDS); // 改用带超时的 poll,可响应中断 + if (alarmId != null) { + // 提交到业务线程池异步处理,避免阻塞队列拉取 + bizExecutor.submit(() -> processAlarm(alarmId)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.info("Redisson 消费线程被中断,退出循环"); + break; + } catch (Exception e) { + log.error("Redisson 延迟队列消费异常", e); + // 发生非中断异常时短暂休眠,避免日志风暴 + try { + TimeUnit.SECONDS.sleep(1); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + } + log.info("Redisson 延迟报警消费线程结束"); + }, "Alarm-Consumer-Thread"); + consumerThread.setDaemon(false); + consumerThread.start(); + } + + private void processAlarm(Long alarmId) { + try { + alarmCheckService.executeCheck(alarmId); + } catch (Exception e) { + log.error("处理报警 ID [{}] 时发生异常", alarmId, e); + // 可在此补充重试或死信逻辑 + } + } + + @PreDestroy + public void destroy() { + log.info("开始关闭 Redisson 报警消费者..."); + running = false; + if (consumerThread != null) { + consumerThread.interrupt(); // 中断阻塞在 poll 上的线程 + } + if (bizExecutor != null) { + bizExecutor.shutdown(); + try { + if (!bizExecutor.awaitTermination(60, TimeUnit.SECONDS)) { + bizExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + bizExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + log.info("Redisson 报警消费者已关闭"); + } +} diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceDebugController.java b/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceDebugController.java index e05a89c4..480d3a54 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceDebugController.java +++ b/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceDebugController.java @@ -11,6 +11,7 @@ import com.fuyuanshen.common.ratelimiter.annotation.FunctionAccessAnnotation; import com.fuyuanshen.common.web.core.BaseController; import com.fuyuanshen.equipment.domain.query.DeviceQueryCriteria; import com.fuyuanshen.equipment.domain.vo.WebDeviceVo; +import com.fuyuanshen.equipment.enums.DataSourceEnum; import com.fuyuanshen.web.domain.Dto.DeviceDebugEditDto; import com.fuyuanshen.web.domain.Dto.DeviceDebugLogoUploadDto; import com.fuyuanshen.web.domain.vo.DeviceInfoVo; @@ -79,7 +80,7 @@ public class DeviceDebugController extends BaseController { if(file.getSize()>1024*1024*2){ return R.warn("图片不能大于2M"); } - deviceXinghanBizService.uploadDeviceLogoBatch(bo); + deviceXinghanBizService.uploadDeviceLogoBatch(bo, DataSourceEnum.Web); return R.ok(); } diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceXinghanController.java b/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceXinghanController.java index 7ab4f902..6001fd45 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceXinghanController.java +++ b/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/DeviceXinghanController.java @@ -14,6 +14,7 @@ import com.fuyuanshen.common.ratelimiter.annotation.FunctionAccessBatcAnnotation import com.fuyuanshen.common.redis.utils.RedisUtils; import com.fuyuanshen.common.web.core.BaseController; import com.fuyuanshen.equipment.domain.dto.AppDeviceSendMsgBo; +import com.fuyuanshen.equipment.enums.DataSourceEnum; import com.fuyuanshen.web.domain.Dto.DeviceXinghanInstructDto; import com.fuyuanshen.web.domain.Dto.SystemVersionDto; import com.fuyuanshen.web.domain.vo.DeviceXinghanDetailVo; @@ -60,7 +61,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping(value = "/registerPersonInfo") // @FunctionAccessAnnotation("registerPersonInfo") public R registerPersonInfo(@Validated(AddGroup.class) @RequestBody AppPersonnelInfoBo bo) { - return toAjax(deviceXinghanBizService.registerPersonInfo(bo)); + return toAjax(deviceXinghanBizService.registerPersonInfo(bo, DataSourceEnum.Web)); } /** @@ -69,7 +70,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping(value = "/sendAlarmMessage") @FunctionAccessBatcAnnotation(value = "sendAlarmMessage", timeOut = 5, batchMaxTimeOut = 10) public R sendAlarmMessage(@RequestBody AppDeviceSendMsgBo bo) { - return toAjax(deviceXinghanBizService.sendAlarmMessage(bo)); + return toAjax(deviceXinghanBizService.sendAlarmMessage(bo, DataSourceEnum.Web)); } /** @@ -83,7 +84,7 @@ public class DeviceXinghanController extends BaseController { if(file.getSize()>1024*1024*2){ return R.warn("图片不能大于2M"); } - deviceXinghanBizService.uploadDeviceLogo(bo); + deviceXinghanBizService.uploadDeviceLogo(bo, DataSourceEnum.Web); return R.ok(); } @@ -95,7 +96,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping("/DetectGradeSettings") public R DetectGradeSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - deviceXinghanBizService.upDetectGradeSettings(params); + deviceXinghanBizService.upDetectGradeSettings(params, DataSourceEnum.Web); return R.ok(); } @@ -106,7 +107,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping("/LightGradeSettings") public R LightGradeSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - deviceXinghanBizService.upLightGradeSettings(params); + deviceXinghanBizService.upLightGradeSettings(params, DataSourceEnum.Web); return R.ok(); } @@ -117,7 +118,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping("/SOSGradeSettings") public R SOSGradeSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - deviceXinghanBizService.upSOSGradeSettings(params); + deviceXinghanBizService.upSOSGradeSettings(params, DataSourceEnum.Web); return R.ok(); } @@ -128,7 +129,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping("/SOSGradeSettingsBatch") public R SOSGradeSettingsBatch(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - deviceXinghanBizService.sendCommandBatch(params,"ins_SOSGrade","SOS档位"); + deviceXinghanBizService.sendCommandBatch(params,"ins_SOSGrade","SOS档位", DataSourceEnum.Web); return R.ok(); } @@ -139,7 +140,7 @@ public class DeviceXinghanController extends BaseController { @PostMapping("/ShakeBitSettings") public R ShakeBitSettings(@RequestBody DeviceXinghanInstructDto params) { // params 转 JSONObject - deviceXinghanBizService.upShakeBitSettings(params); + deviceXinghanBizService.upShakeBitSettings(params, DataSourceEnum.Web); return R.ok(); } diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/xinghan/WebHBY018AController.java b/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/xinghan/WebHBY018AController.java new file mode 100644 index 00000000..4c6b4a6b --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/web/controller/device/xinghan/WebHBY018AController.java @@ -0,0 +1,95 @@ +package com.fuyuanshen.web.controller.device.xinghan; + + +import com.fuyuanshen.common.core.domain.R; +import com.fuyuanshen.common.log.annotation.Log; +import com.fuyuanshen.common.web.core.BaseController; +import com.fuyuanshen.equipment.domain.bo.DeviceContactPhoneBo; +import com.fuyuanshen.equipment.domain.dto.AppDeviceSendMsgBo; +import com.fuyuanshen.equipment.enums.DataSourceEnum; +import com.fuyuanshen.web.domain.Dto.DeviceXinghanInstructDto; +import com.fuyuanshen.web.domain.vo.DeviceXinghanDetailVo; +import com.fuyuanshen.web.service.device.DeviceXinghanBizService; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 设备控制类 HBY018A + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/hby018a/device") +public class WebHBY018AController extends BaseController { + + private final DeviceXinghanBizService deviceXinghanBizService; + + /** + * 获取设备详细信息 + * + * @param id 主键 + */ + @GetMapping("/{id}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(deviceXinghanBizService.getInfo(id)); + } + + /** + * 照明档位 + * 照明档位,2,1,0,分别表示弱光/强光/关闭 + */ + @PostMapping("/SideLightSettings") + public R SideLightSettings(@RequestBody DeviceXinghanInstructDto params) { + // params 转 JSONObject + deviceXinghanBizService.upSideLightSettings(params, DataSourceEnum.Web); + return R.ok(); + } + + /** + * 强制报警状态 + * 强制报警状态,0-未报警,1-正在报警。 + */ + @PostMapping("/ShakeBitSettings") + public R ShakeBitSettings(@RequestBody DeviceXinghanInstructDto params) { + // params 转 JSONObject + deviceXinghanBizService.upShakeBitSettings(params, DataSourceEnum.Web); + return R.ok(); + } + + /** + * 自定义语音消息 + */ + @PostMapping("/SetVoiceMsg") + public R editSosVoiceMsg(@RequestBody AppDeviceSendMsgBo bo) { + return toAjax(deviceXinghanBizService.sendAlarmMessage(bo, DataSourceEnum.Web)); + } + + /** + * 自定义短信消息 + */ + @PostMapping("/SetSmsMsg") + public R editSosSmsMsg(@RequestBody AppDeviceSendMsgBo bo) { + return toAjax(deviceXinghanBizService.editSosSmsMsg(bo)); + } + + /** + * 设置消息通知开关 + */ + @PostMapping("/SetNotificationEnabled") + public R editNotificationEnabled(@RequestBody DeviceContactPhoneBo bo) { + return toAjax(deviceXinghanBizService.editNotificationEnabled(bo)); + } + + /** + * 添加设备紧急联系人 + */ + @PostMapping("/SetContactPhone") + public R editContactPhone(@RequestBody DeviceContactPhoneBo bo) { + return toAjax(deviceXinghanBizService.editContactPhone(bo)); + } + + +} diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/domain/vo/DeviceXinghanDetailVo.java b/fys-admin/src/main/java/com/fuyuanshen/web/domain/vo/DeviceXinghanDetailVo.java index 62c4e3e1..111f8d27 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/web/domain/vo/DeviceXinghanDetailVo.java +++ b/fys-admin/src/main/java/com/fuyuanshen/web/domain/vo/DeviceXinghanDetailVo.java @@ -100,4 +100,21 @@ public class DeviceXinghanDetailVo { * 第六键值对, 近电预警级别, 0-无预警,1-弱预警,2-中预警,3-强预警,4-非常强预警。 */ public Integer staDetectResult; + + /** + * 联系人1手机 + */ + private String contact1Phone; + /** + * 联系人2手机 + */ + private String contact2Phone; + /** + * 自定义语音文本 + */ + private String sosVoiceMsg; + /** + * 自定义短信文本 + */ + private String sosSmsMsg; } diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/enums/NotificationSwitchEnum.java b/fys-admin/src/main/java/com/fuyuanshen/web/enums/NotificationSwitchEnum.java new file mode 100644 index 00000000..0b4e7cfe --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/web/enums/NotificationSwitchEnum.java @@ -0,0 +1,60 @@ +package com.fuyuanshen.web.enums; + +import lombok.Getter; + +/** + * 设备通知开关枚举 + * 0=关闭 1=短信 2=语音 3=全开 + */ +@Getter +public enum NotificationSwitchEnum { + + CLOSED(0, "关闭通知"), + SMS_ONLY(1, "仅短信"), + VOICE_ONLY(2, "仅语音"), + SMS_VOICE(3, "短信+语音全开"); + + private final Integer code; + private final String desc; + + NotificationSwitchEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + /** + * 根据code获取枚举 + */ + public static NotificationSwitchEnum getByCode(Integer code) { + if (code == null) { + return CLOSED; // 空值默认关闭 + } + for (NotificationSwitchEnum status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return CLOSED; // 非法值默认关闭 + } + + /** + * 是否完全关闭通知 + */ + public boolean isClosed() { + return this == CLOSED; + } + + /** + * 是否开启短信 + */ + public boolean hasSms() { + return this == SMS_ONLY || this == SMS_VOICE; + } + + /** + * 是否开启语音 + */ + public boolean hasVoice() { + return this == VOICE_ONLY || this == SMS_VOICE; + } +} diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/service/device/DeviceXinghanBizService.java b/fys-admin/src/main/java/com/fuyuanshen/web/service/device/DeviceXinghanBizService.java index 0ac79432..90fc4b66 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/web/service/device/DeviceXinghanBizService.java +++ b/fys-admin/src/main/java/com/fuyuanshen/web/service/device/DeviceXinghanBizService.java @@ -5,6 +5,7 @@ import cn.hutool.core.lang.UUID; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import com.aliyun.dyvmsapi20170525.models.SingleCallByTtsResponse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; @@ -18,8 +19,9 @@ import com.fuyuanshen.app.domain.dto.AppDeviceLogoUploadDto; import com.fuyuanshen.app.domain.vo.AppPersonnelInfoVo; import com.fuyuanshen.app.mapper.AppPersonnelInfoMapper; import com.fuyuanshen.app.mapper.AppPersonnelInfoRecordsMapper; -import com.fuyuanshen.equipment.service.IAppBusinessFileService; -import com.fuyuanshen.equipment.service.IAppOperationVideoService; +import com.fuyuanshen.equipment.domain.bo.DeviceContactPhoneBo; +import com.fuyuanshen.equipment.enums.DataSourceEnum; +import com.fuyuanshen.equipment.service.*; import com.fuyuanshen.common.core.constant.GlobalConstants; import com.fuyuanshen.common.core.domain.model.AppLoginUser; import com.fuyuanshen.common.core.exception.BadRequestException; @@ -41,8 +43,6 @@ import com.fuyuanshen.equipment.mapper.DeviceLogMapper; import com.fuyuanshen.equipment.mapper.DeviceMapper; import com.fuyuanshen.equipment.mapper.DeviceTypeGrantsMapper; import com.fuyuanshen.equipment.mapper.DeviceTypeMapper; -import com.fuyuanshen.equipment.service.DeviceAssignmentsService; -import com.fuyuanshen.equipment.service.IDeviceAlarmService; import com.fuyuanshen.global.mqtt.base.MqttXinghanJson; import com.fuyuanshen.global.mqtt.config.MqttGateway; import com.fuyuanshen.global.mqtt.constants.DeviceRedisKeyConstants; @@ -64,6 +64,7 @@ import java.io.IOException; import java.time.Duration; import java.time.LocalDateTime; import java.util.*; +import java.util.stream.Collectors; import static com.fuyuanshen.common.core.constant.GlobalConstants.GLOBAL_REDIS_KEY; import static com.fuyuanshen.common.core.utils.ImageToCArrayConverter.convertHexToDecimal; @@ -89,6 +90,7 @@ public class DeviceXinghanBizService { @Autowired private ObjectMapper objectMapper; private final AliyunVoiceUtil voiceUtil; + private final DeviceService deviceService; /** * 所有档位的描述表 @@ -114,21 +116,28 @@ public class DeviceXinghanBizService { /** * 设置静电预警档位 */ - public void upDetectGradeSettings(DeviceXinghanInstructDto dto) { - sendCommand(dto, "ins_DetectGrade","静电预警档位"); + public void upDetectGradeSettings(DeviceXinghanInstructDto dto, DataSourceEnum sourceEnum) { + sendCommand(dto, "ins_DetectGrade","静电预警档位", sourceEnum); + } + + /** + * 670设置照明档位 + */ + public void upLightGradeSettings(DeviceXinghanInstructDto dto, DataSourceEnum sourceEnum) { + sendCommand(dto, "ins_LightGrade","照明档位", sourceEnum); } /** * 设置照明档位 */ - public void upLightGradeSettings(DeviceXinghanInstructDto dto) { - sendCommand(dto, "ins_LightGrade","照明档位"); + public void upSideLightSettings(DeviceXinghanInstructDto dto, DataSourceEnum sourceEnum) { + sendCommand(dto, "ins_Side_Light","照明档位", sourceEnum); } /** * 设置SOS档位 */ - public void upSOSGradeSettings(DeviceXinghanInstructDto dto) { + public void upSOSGradeSettings(DeviceXinghanInstructDto dto, DataSourceEnum sourceEnum) { if(dto.getIsBluetooth()){ long deviceId = dto.getDeviceId(); // 1. 使用Optional简化空值检查,使代码更简洁 @@ -138,7 +147,7 @@ public class DeviceXinghanBizService { // 6. 新建报警信息 createAlarm(device.getId(),device.getDeviceImei(),"ins_SOSGrade",sosGrade); }else { - sendCommand(dto, "ins_SOSGrade","SOS档位"); + sendCommand(dto, "ins_SOSGrade","SOS档位", sourceEnum); } } @@ -150,10 +159,10 @@ public class DeviceXinghanBizService { public void executeSosCall(String phone) { log.info("[SOS业务] 准备发起语音拨号 -> 目标: {}", phone); Map params = Map.of("device", "670"); - String callId = voiceUtil.sendTtsSync(phone, "TTS_328730104", params); + SingleCallByTtsResponse response = voiceUtil.sendTtsSync(phone, "TTS_328730104", params); - if (callId != null) { - log.info("[SOS业务] 拨号指令下发成功, callId: {}", callId); + if ("OK".equalsIgnoreCase(response.getBody().getCode())) { + log.info("[SOS业务] 拨号指令下发成功, callId: {}", response.getBody().getCallId()); // 这里可以记录拨打日志到数据库 } else { log.error("[SOS业务] 拨号指令下发失败,请检查配置或余额"); @@ -163,8 +172,8 @@ public class DeviceXinghanBizService { /** * 设置强制报警 */ - public void upShakeBitSettings(DeviceXinghanInstructDto dto) { - sendCommand(dto, "ins_ShakeBit","强制报警"); + public void upShakeBitSettings(DeviceXinghanInstructDto dto, DataSourceEnum sourceEnum) { + sendCommand(dto, "ins_ShakeBit","强制报警", sourceEnum); } public DeviceXinghanDetailVo getInfo(Long id) { @@ -180,6 +189,11 @@ public class DeviceXinghanBizService { vo.setDeviceImei(device.getDeviceImei()); vo.setDeviceMac(device.getDeviceMac()); vo.setDeviceStatus(device.getDeviceStatus()); + // 2026-04-13 新增 紧急联系人与紧急联系信息 + vo.setSosSmsMsg(device.getSosSmsMsg()); + vo.setSosVoiceMsg(device.getSosVoiceMsg()); + vo.setContact1Phone(device.getContact1Phone()); + vo.setContact2Phone(device.getContact2Phone()); DeviceType deviceType = deviceTypeMapper.selectById(device.getDeviceType()); if (deviceType != null) { vo.setCommunicationMode(Integer.valueOf(deviceType.getCommunicationMode())); @@ -235,7 +249,7 @@ public class DeviceXinghanBizService { /** * 上传设备logo */ - public void uploadDeviceLogo(AppDeviceLogoUploadDto bo) { + public void uploadDeviceLogo(AppDeviceLogoUploadDto bo, DataSourceEnum sourceEnum) { try { Device device = deviceMapper.selectById(bo.getDeviceId()); if (device == null) { @@ -267,7 +281,7 @@ public class DeviceXinghanBizService { } log.info("发送上传开机画面到设备消息=>topic:{},payload:{}", MqttConstants.GLOBAL_PUB_KEY+device.getDeviceImei(),json); - recordDeviceLog(device.getId(), device.getDeviceName(), "上传开机画面", "上传开机画面", AppLoginHelper.getUserId()); + recordDeviceLog(device.getId(), device.getDeviceName(), "上传开机画面", "上传开机画面", AppLoginHelper.getUserId(), sourceEnum.getName()); } catch (Exception e){ throw new ServiceException("发送指令失败"); } @@ -277,7 +291,7 @@ public class DeviceXinghanBizService { * 批量上传设备logo */ @Transactional(rollbackFor = Exception.class) - public void uploadDeviceLogoBatch(DeviceDebugLogoUploadDto batchDto) { + public void uploadDeviceLogoBatch(DeviceDebugLogoUploadDto batchDto, DataSourceEnum sourceEnum) { if (CollectionUtils.isEmpty(batchDto.getDeviceIds())) { throw new ServiceException("设备列表为空"); } @@ -329,7 +343,7 @@ public class DeviceXinghanBizService { throw new ServiceException("上传LOGO失败:" + e.getMessage()); } - recordDeviceLog(d.getId(), d.getDeviceName(), "上传开机画面", "上传开机画面", AppLoginHelper.getUserId()); + recordDeviceLog(d.getId(), d.getDeviceName(), "上传开机画面", "上传开机画面", AppLoginHelper.getUserId(), sourceEnum.getName()); }); } @@ -339,7 +353,7 @@ public class DeviceXinghanBizService { * @param bo */ @Transactional(rollbackFor = Exception.class) // 1. 事务 - public boolean registerPersonInfo(AppPersonnelInfoBo bo) { + public boolean registerPersonInfo(AppPersonnelInfoBo bo, DataSourceEnum sourceEnum) { Long deviceId = bo.getDeviceId(); Device deviceObj = deviceMapper.selectById(deviceId); if (deviceObj == null) { @@ -375,7 +389,7 @@ public class DeviceXinghanBizService { } log.info("发送人员信息登记到设备消息=>topic:{},payload:{}", MqttConstants.GLOBAL_PUB_KEY + deviceObj.getDeviceImei(), bo); - recordDeviceLog(deviceId, deviceObj.getDeviceName(), "人员信息登记", JSON.toJSONString(bo), AppLoginHelper.getUserId()); + recordDeviceLog(deviceId, deviceObj.getDeviceName(), "人员信息登记", JSON.toJSONString(bo), AppLoginHelper.getUserId(), sourceEnum.getName()); return saveOrUpdatePersonnelInfo(bo, deviceId); } @@ -424,7 +438,7 @@ public class DeviceXinghanBizService { * @param bo * @return */ - public int sendAlarmMessage(AppDeviceSendMsgBo bo) { + public int sendAlarmMessage(AppDeviceSendMsgBo bo, DataSourceEnum sourceEnum) { List deviceIds = bo.getDeviceIds(); // 1. 简化非空检查和抛出异常 @@ -467,7 +481,7 @@ public class DeviceXinghanBizService { deviceMapper.update(updateWrapper); // 6. 记录操作日志 - recordDeviceLog(device.getId(), deviceName, "发送紧急通知", bo.getSendMsg(), AppLoginHelper.getUserId()); + recordDeviceLog(device.getId(), deviceName, "发送紧急通知", bo.getSendMsg(), AppLoginHelper.getUserId(),sourceEnum.getName()); } } catch (ServiceException e) { // 捕获并重新抛出自定义异常,避免内层异常被外层泛化捕获 @@ -480,8 +494,123 @@ public class DeviceXinghanBizService { return 1; } + + /* * + * 保存设备日志 + */ + public void saveRecordDeviceLog(AppPersonnelInfoBo bo, DataSourceEnum sourceEnum) { + Long deviceId = bo.getDeviceId(); + Device deviceObj = deviceMapper.selectById(deviceId); + if(!StringUtils.isNotEmpty(bo.getSendMsg())) + { + throw new ServiceException("请输入保存内容"); + } + if (deviceObj == null) { + throw new RuntimeException("请先将设备入库!!!"); + } + bo.setSendMsg("APP:"+bo.getSendMsg()); + recordDeviceLog(deviceObj.getId(), deviceObj.getDeviceName(), bo.getName(), bo.getSendMsg(), AppLoginHelper.getUserId(),sourceEnum.getName()); + } + + /** + * 自定义语音内容 + * @param bo + * @return + */ + public int editSosVoiceMsg(AppDeviceSendMsgBo bo) { + // 1. 参数校验 + List deviceIds = bo.getDeviceIds(); + validateMsg(deviceIds, bo.getSendMsg(), "短信提示内容"); + + // 不查数据库,直接构造只有 id 和待更新字段的实体 + List devices = deviceIds.stream() + .map(id -> { + Device d = new Device(); + d.setId(id); + d.setSosVoiceMsg(bo.getSendMsg()); + return d; + }) + .collect(Collectors.toList()); + + // 此时生成的 SQL 只有 sos_voice_msg 一个 SET 字段 + deviceService.updateBatchById(devices); + return devices.size(); + } + + /** + * 自定义短信内容 + * @param bo + * @return + */ + public int editSosSmsMsg(AppDeviceSendMsgBo bo) { + // 1. 参数校验 + List deviceIds = bo.getDeviceIds(); + validateMsg(deviceIds, bo.getSendMsg(), "短信提示内容"); + // 不查数据库,直接构造只有 id 和待更新字段的实体 + List devices = deviceIds.stream() + .map(id -> { + Device d = new Device(); + d.setId(id); + d.setSosSmsMsg(bo.getSendMsg()); + return d; + }) + .collect(Collectors.toList()); + + // 此时生成的 SQL 只有 sos_sms_msg 一个 SET 字段 + deviceService.updateBatchById(devices); + return devices.size(); + } + + /** + * 修改通知开关设置 + * @param bo + * @return + */ + public boolean editNotificationEnabled(DeviceContactPhoneBo bo){ + if(bo.getDeviceId() == null){ + throw new ServiceException("请选择设备"); + } + Device d = new Device(); + d.setId(bo.getDeviceId()); + d.setNotificationEnabled(bo.getNotificationEnabled()); + return deviceService.updateById(d); + } + + /** + * 修改紧急联系人手机 + * @param bo + * @return + */ + public boolean editContactPhone(DeviceContactPhoneBo bo){ + if(bo.getDeviceId() == null){ + throw new ServiceException("请选择设备"); + } + Device d = new Device(); + d.setId(bo.getDeviceId()); + d.setContact1Phone(bo.getContact1Phone()); + d.setContact2Phone(bo.getContact2Phone()); + return deviceService.updateById(d); + } + /* ---------------------------------- 私有通用方法 ---------------------------------- */ + /** + * 统一校验:设备ID + 消息内容 + */ + private void validateMsg(List deviceIds, String sendMsg, String typeName) { + // 校验设备 + if (deviceIds == null || deviceIds.isEmpty()) { + throw new ServiceException("请选择设备"); + } + // 校验消息内容 + if (!StringUtils.isNotEmpty(sendMsg)) { + throw new ServiceException("请输入" + typeName); + } + if (sendMsg.length() > 20) { + throw new ServiceException(typeName + "不能超过20字"); + } + } + /** * 封装单个设备发送告警信息的逻辑 */ @@ -514,7 +643,7 @@ public class DeviceXinghanBizService { * @param payloadKey 指令负载数据的键名 * @param deviceAction 设备操作类型描述 */ - private void sendCommand(DeviceXinghanInstructDto dto, String payloadKey, String deviceAction) { + private void sendCommand(DeviceXinghanInstructDto dto, String payloadKey, String deviceAction, DataSourceEnum sourceEnum) { long deviceId = dto.getDeviceId(); // 1. 使用Optional简化空值检查,使代码更简洁 @@ -557,7 +686,8 @@ public class DeviceXinghanBizService { deviceName, deviceAction, content, - AppLoginHelper.getUserId()); + AppLoginHelper.getUserId(), + sourceEnum.getName()); // 6. 新建报警信息 createAlarm(device.getId(),deviceImei,payloadKey,value); @@ -571,7 +701,7 @@ public class DeviceXinghanBizService { * @param deviceAction 设备操作类型描述 */ @Transactional(rollbackFor = Exception.class) // 1. 事务注解 - public void sendCommandBatch(DeviceXinghanInstructDto dto, String payloadKey, String deviceAction) { + public void sendCommandBatch(DeviceXinghanInstructDto dto, String payloadKey, String deviceAction, DataSourceEnum sourceEnum) { List errorMessages = Collections.synchronizedList(new ArrayList<>()); int value; try { @@ -609,6 +739,7 @@ public class DeviceXinghanBizService { DeviceLog deviceLog = new DeviceLog(); deviceLog.setDeviceId(device.getId()); deviceLog.setDeviceAction(deviceAction); + deviceLog.setDataSource(sourceEnum.getName()); deviceLog.setContent(contentText); deviceLog.setCreateBy(AppLoginHelper.getUserId()); deviceLog.setDeviceName(deviceName); @@ -662,6 +793,7 @@ public class DeviceXinghanBizService { bo.setDeviceAction(0); // 强制报警 bo.setStartTime(new Date()); bo.setTreatmentState(1); // 未处理 + bo.setAlarmState(0); bo.setContent("强制报警:" + type.getDesc()); String location = RedisUtils.getCacheObject(GLOBAL_REDIS_KEY + DEVICE_KEY_PREFIX + deviceImei + DEVICE_LOCATION_KEY_PREFIX); if (StrUtil.isNotBlank(location)) { @@ -685,12 +817,13 @@ public class DeviceXinghanBizService { * @param content 日志内容 * @param operator 操作人 */ - private void recordDeviceLog(Long deviceId,String deviceName, String deviceAction, String content, Long operator) { + private void recordDeviceLog(Long deviceId,String deviceName, String deviceAction, String content, Long operator, String source) { try { // 创建设备日志实体 com.fuyuanshen.equipment.domain.DeviceLog deviceLog = new com.fuyuanshen.equipment.domain.DeviceLog(); deviceLog.setDeviceId(deviceId); deviceLog.setDeviceAction(deviceAction); + deviceLog.setDataSource(source); deviceLog.setContent(content); deviceLog.setCreateBy(operator); deviceLog.setDeviceName(deviceName); @@ -703,7 +836,7 @@ public class DeviceXinghanBizService { } } - private boolean isDeviceOffline(String deviceImei) { + public boolean isDeviceOffline(String deviceImei) { String deviceOnlineStatusRedisKey = GlobalConstants.GLOBAL_REDIS_KEY+ DEVICE_KEY_PREFIX+ deviceImei + DeviceRedisKeyConstants.DEVICE_ONLINE_STATUS_KEY_PREFIX ; return RedisUtils.getCacheObject(deviceOnlineStatusRedisKey)==null; } diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/util/AliyunVoiceUtil.java b/fys-admin/src/main/java/com/fuyuanshen/web/util/AliyunVoiceUtil.java index 1aac0670..98a43a6d 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/web/util/AliyunVoiceUtil.java +++ b/fys-admin/src/main/java/com/fuyuanshen/web/util/AliyunVoiceUtil.java @@ -52,7 +52,7 @@ public class AliyunVoiceUtil { /** * 同步发送方法:由异步架构调用 */ - public String sendTtsSync(String phone, String templateCode, Map params) { + public SingleCallByTtsResponse sendTtsSync(String phone, String templateCode, Map params) { try { // 1. 获取(或初始化)单例客户端 @@ -72,13 +72,8 @@ public class AliyunVoiceUtil { runtime.setConnectTimeout(5000); runtime.setReadTimeout(10000); - SingleCallByTtsResponse response = voiceClient.singleCallByTtsWithOptions(request, runtime); + return voiceClient.singleCallByTtsWithOptions(request, runtime); - if ("OK".equalsIgnoreCase(response.getBody().getCode())) { - return response.getBody().getCallId(); - } else { - log.error("[AliyunVoice] 拨号失败: {}", response.getBody().getMessage()); - } } catch (Exception e) { log.error("[AliyunVoice] 接口异常", e); } diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/controller/SmsSendRecordController.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/controller/SmsSendRecordController.java new file mode 100644 index 00000000..c092d35e --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/controller/SmsSendRecordController.java @@ -0,0 +1,106 @@ +package com.fuyuanshen.equipment.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.fuyuanshen.common.core.domain.R; +import com.fuyuanshen.common.core.validate.AddGroup; +import com.fuyuanshen.common.core.validate.EditGroup; +import com.fuyuanshen.common.excel.utils.ExcelUtil; +import com.fuyuanshen.common.idempotent.annotation.RepeatSubmit; +import com.fuyuanshen.common.log.annotation.Log; +import com.fuyuanshen.common.log.enums.BusinessType; +import com.fuyuanshen.common.mybatis.core.page.PageQuery; +import com.fuyuanshen.common.mybatis.core.page.TableDataInfo; +import com.fuyuanshen.common.web.core.BaseController; +import com.fuyuanshen.equipment.domain.bo.SmsSendRecordBo; +import com.fuyuanshen.equipment.domain.vo.SmsSendRecordVo; +import com.fuyuanshen.equipment.service.ISmsSendRecordService; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 报警通知记录(短信/语音) + * + * @author Lion Li + * @date 2026-05-08 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/sendRecord") +public class SmsSendRecordController extends BaseController { + + private final ISmsSendRecordService smsSendRecordService; + + /** + * 查询报警通知记录(短信/语音)列表 + */ + @SaCheckPermission("system:sendRecord:list") + @GetMapping("/list") + public TableDataInfo list(SmsSendRecordBo bo, PageQuery pageQuery) { + return smsSendRecordService.queryPageList(bo, pageQuery); + } + + /** + * 报警通知记录(短信/语音)列表 + */ + @SaCheckPermission("system:sendRecord:export") + @Log(title = "报警通知记录(短信/语音)", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(SmsSendRecordBo bo, HttpServletResponse response) { + List list = smsSendRecordService.queryList(bo); + ExcelUtil.exportExcel(list, "报警通知记录(短信/语音)", SmsSendRecordVo.class, response); + } + + /** + * 获取报警通知记录(短信/语音)详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("system:sendRecord:query") + @GetMapping("/{id}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Long id) { + return R.ok(smsSendRecordService.queryById(id)); + } + + /** + * 新增报警通知记录(短信/语音) + */ + @SaCheckPermission("system:sendRecord:add") + @Log(title = "报警通知记录(短信/语音)", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody SmsSendRecordBo bo) { + return toAjax(smsSendRecordService.insertByBo(bo)); + } + + /** + * 修改报警通知记录(短信/语音) + */ + @SaCheckPermission("system:sendRecord:edit") + @Log(title = "报警通知记录(短信/语音)", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody SmsSendRecordBo bo) { + return toAjax(smsSendRecordService.updateByBo(bo)); + } + + /** + * 删除报警通知记录(短信/语音) + * + * @param ids 主键串 + */ + @SaCheckPermission("system:sendRecord:remove") + @Log(title = "报警通知记录(短信/语音)", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Long[] ids) { + return toAjax(smsSendRecordService.deleteWithValidByIds(List.of(ids), true)); + } +} \ No newline at end of file diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/Device.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/Device.java index 9ef76303..9684664d 100644 --- a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/Device.java +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/Device.java @@ -183,4 +183,25 @@ public class Device extends TenantEntity { @Schema(title = "轨迹ID(高德)") private Long trid; + /** + * 联系人1手机 + */ + private String contact1Phone; + /** + * 联系人2手机 + */ + private String contact2Phone; + /** + * 自定义语音文本 + */ + private String sosVoiceMsg; + /** + * 自定义短信文本 + */ + private String sosSmsMsg; + /** + * 通知开关,0关闭,1短信,2语音,3全开 + */ + private Integer notificationEnabled; + } diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/DeviceAlarm.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/DeviceAlarm.java index 514f3cf4..a1d3a71b 100644 --- a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/DeviceAlarm.java +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/DeviceAlarm.java @@ -109,5 +109,13 @@ public class DeviceAlarm extends TenantEntity { @Schema(title = "设备IMEI") @AutoMapping(target = "deviceImei") private String deviceImei; + /** + * 告警状态,0 解除告警, 1 告警中 + */ + private Integer alarmState; + /** + * 警报等级 + */ + private Integer alarmLevel; } diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/SmsSendRecord.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/SmsSendRecord.java new file mode 100644 index 00000000..050374c0 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/SmsSendRecord.java @@ -0,0 +1,79 @@ +package com.fuyuanshen.equipment.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fuyuanshen.common.tenant.core.TenantEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 报警通知记录(短信/语音)对象 sms_send_record + * + * @author Lion Li + * @date 2026-05-08 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sms_send_record") +public class SmsSendRecord extends TenantEntity { + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 关联的报警记录ID + */ + private Long alarmId; + + /** + * 设备IMEI(冗余方便查询) + */ + private String deviceImei; + + /** + * 通知类型:SMS-短信, VOICE-语音呼叫 + */ + private String notifyType; + + /** + * 接收通知的手机号 + */ + private String phone; + + /** + * 消息内容(短信内容或语音模板参数) + */ + private String content; + + /** + * 短信/语音模板ID + */ + private String templateId; + + /** + * 发送状态:0-待发送, 1-成功, 2-失败 + */ + private Long status; + + /** + * 平台返回的状态码 + */ + private String responseCode; + + /** + * 平台返回的描述信息 + */ + private String responseMsg; + + /** + * 实际发送时间(调用平台时间) + */ + private Date sendTime; + + +} diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceAlarmBo.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceAlarmBo.java index 5e3271f2..930d371e 100644 --- a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceAlarmBo.java +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceAlarmBo.java @@ -119,4 +119,9 @@ public class DeviceAlarmBo extends TenantEntity { */ private Integer alarmState; + /** + * 警报等级 + */ + private Integer alarmLevel; + } diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceContactPhoneBo.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceContactPhoneBo.java new file mode 100644 index 00000000..75542010 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/DeviceContactPhoneBo.java @@ -0,0 +1,20 @@ +package com.fuyuanshen.equipment.domain.bo; + +import lombok.Data; + +@Data +public class DeviceContactPhoneBo { + private Long deviceId; + /** + * 联系人1手机 + */ + private String contact1Phone; + /** + * 联系人2手机 + */ + private String contact2Phone; + /** + * 通知开关 + */ + private Integer notificationEnabled; +} diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/SmsSendRecordBo.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/SmsSendRecordBo.java new file mode 100644 index 00000000..106b6867 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/bo/SmsSendRecordBo.java @@ -0,0 +1,80 @@ +package com.fuyuanshen.equipment.domain.bo; + +import com.fuyuanshen.common.core.validate.AddGroup; +import com.fuyuanshen.common.core.validate.EditGroup; +import com.fuyuanshen.common.mybatis.core.domain.BaseEntity; +import com.fuyuanshen.equipment.domain.DeviceLog; +import com.fuyuanshen.equipment.domain.SmsSendRecord; +import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = true) +@AutoMapper(target = SmsSendRecord.class, reverseConvertGenerate = false) +public class SmsSendRecordBo extends BaseEntity { + /** + * 主键 + */ + @NotNull(message = "主键不能为空", groups = { EditGroup.class }) + private Long id; + + /** + * 关联的报警记录ID + */ + @NotNull(message = "关联的报警记录ID不能为空", groups = { AddGroup.class, EditGroup.class }) + private Long alarmId; + + /** + * 设备IMEI(冗余方便查询) + */ + @NotBlank(message = "设备IMEI(冗余方便查询)不能为空", groups = { AddGroup.class, EditGroup.class }) + private String deviceImei; + + /** + * 通知类型:SMS-短信, VOICE-语音呼叫 + */ + @NotBlank(message = "通知类型:SMS-短信, VOICE-语音呼叫不能为空", groups = { AddGroup.class, EditGroup.class }) + private String notifyType; + + /** + * 接收通知的手机号 + */ + @NotBlank(message = "接收通知的手机号不能为空", groups = { AddGroup.class, EditGroup.class }) + private String phone; + + /** + * 消息内容(短信内容或语音模板参数) + */ + private String content; + + /** + * 短信/语音模板ID + */ + private String templateId; + + /** + * 发送状态:0-待发送, 1-成功, 2-失败 + */ + @NotNull(message = "发送状态:0-待发送, 1-成功, 2-失败不能为空", groups = { AddGroup.class, EditGroup.class }) + private Long status; + + /** + * 平台返回的状态码 + */ + private String responseCode; + + /** + * 平台返回的描述信息 + */ + private String responseMsg; + + /** + * 实际发送时间(调用平台时间) + */ + private Date sendTime; +} diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/DeviceAlarmVo.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/DeviceAlarmVo.java index b1e68b3c..d6a8492b 100644 --- a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/DeviceAlarmVo.java +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/DeviceAlarmVo.java @@ -136,4 +136,13 @@ public class DeviceAlarmVo implements Serializable { @Schema(name = "设备图片") private String devicePic; + /** + * 警报等级 + */ + private Integer alarmLevel; + /** + * 租户编号 + */ + private String tenantId; + } diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/SmsSendRecordVo.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/SmsSendRecordVo.java new file mode 100644 index 00000000..d6ae7baa --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/domain/vo/SmsSendRecordVo.java @@ -0,0 +1,87 @@ +package com.fuyuanshen.equipment.domain.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import com.fuyuanshen.common.excel.annotation.ExcelDictFormat; +import com.fuyuanshen.common.excel.convert.ExcelDictConvert; +import com.fuyuanshen.equipment.domain.DeviceLog; +import com.fuyuanshen.equipment.domain.SmsSendRecord; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = SmsSendRecord.class) +public class SmsSendRecordVo implements Serializable { + /** + * 主键 + */ + @ExcelProperty(value = "主键") + private Long id; + + /** + * 关联的报警记录ID + */ + @ExcelProperty(value = "关联的报警记录ID") + private Long alarmId; + + /** + * 设备IMEI(冗余方便查询) + */ + @ExcelProperty(value = "设备IMEI", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "冗=余方便查询") + private String deviceImei; + + /** + * 通知类型:SMS-短信, VOICE-语音呼叫 + */ + @ExcelProperty(value = "通知类型:SMS-短信, VOICE-语音呼叫") + private String notifyType; + + /** + * 接收通知的手机号 + */ + @ExcelProperty(value = "接收通知的手机号") + private String phone; + + /** + * 消息内容(短信内容或语音模板参数) + */ + @ExcelProperty(value = "消息内容", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "短=信内容或语音模板参数") + private String content; + + /** + * 短信/语音模板ID + */ + @ExcelProperty(value = "短信/语音模板ID") + private String templateId; + + /** + * 发送状态:0-待发送, 1-成功, 2-失败 + */ + @ExcelProperty(value = "发送状态:0-待发送, 1-成功, 2-失败") + private Long status; + + /** + * 平台返回的状态码 + */ + @ExcelProperty(value = "平台返回的状态码") + private String responseCode; + + /** + * 平台返回的描述信息 + */ + @ExcelProperty(value = "平台返回的描述信息") + private String responseMsg; + + /** + * 实际发送时间(调用平台时间) + */ + @ExcelProperty(value = "实际发送时间", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "调=用平台时间") + private Date sendTime; +} diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/enums/DataSourceEnum.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/enums/DataSourceEnum.java new file mode 100644 index 00000000..b7673530 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/enums/DataSourceEnum.java @@ -0,0 +1,32 @@ +package com.fuyuanshen.equipment.enums; + +/** + * 数据来源枚举 + */ +public enum DataSourceEnum { + /** + * 默认数据源 + */ + APP(0, "app"), + /** + * 默认数据源 + */ + Web(1, "web"); + + + private final Integer code; + private final String name; + + DataSourceEnum(Integer code, String name) { + this.code = code; + this.name = name; + } + + public Integer getCode() { + return code; + } + + public String getName() { + return name; + } +} diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/mapper/SmsSendRecordMapper.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/mapper/SmsSendRecordMapper.java new file mode 100644 index 00000000..6b3bd2b5 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/mapper/SmsSendRecordMapper.java @@ -0,0 +1,9 @@ +package com.fuyuanshen.equipment.mapper; + +import com.fuyuanshen.common.mybatis.core.mapper.BaseMapperPlus; +import com.fuyuanshen.equipment.domain.SmsSendRecord; +import com.fuyuanshen.equipment.domain.vo.SmsSendRecordVo; + +public interface SmsSendRecordMapper extends BaseMapperPlus { + +} diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/ISmsSendRecordService.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/ISmsSendRecordService.java new file mode 100644 index 00000000..b075da83 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/ISmsSendRecordService.java @@ -0,0 +1,68 @@ +package com.fuyuanshen.equipment.service; + +import com.fuyuanshen.common.mybatis.core.page.PageQuery; +import com.fuyuanshen.common.mybatis.core.page.TableDataInfo; +import com.fuyuanshen.equipment.domain.bo.SmsSendRecordBo; +import com.fuyuanshen.equipment.domain.vo.SmsSendRecordVo; + +import java.util.Collection; +import java.util.List; + +/** + * 报警通知记录(短信/语音)Service接口 + * + * @author Lion Li + * @date 2026-05-08 + */ +public interface ISmsSendRecordService { + + /** + * 查询报警通知记录(短信/语音) + * + * @param id 主键 + * @return 报警通知记录(短信/语音) + */ + SmsSendRecordVo queryById(Long id); + + /** + * 分页查询报警通知记录(短信/语音)列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return 报警通知记录(短信/语音)分页列表 + */ + TableDataInfo queryPageList(SmsSendRecordBo bo, PageQuery pageQuery); + + /** + * 查询符合条件的报警通知记录(短信/语音)列表 + * + * @param bo 查询条件 + * @return 报警通知记录(短信/语音)列表 + */ + List queryList(SmsSendRecordBo bo); + + /** + * 新增报警通知记录(短信/语音) + * + * @param bo 报警通知记录(短信/语音) + * @return 是否新增成功 + */ + Boolean insertByBo(SmsSendRecordBo bo); + + /** + * 修改报警通知记录(短信/语音) + * + * @param bo 报警通知记录(短信/语音) + * @return 是否修改成功 + */ + Boolean updateByBo(SmsSendRecordBo bo); + + /** + * 校验并批量删除报警通知记录(短信/语音)信息 + * + * @param ids 待删除的主键集合 + * @param isValid 是否进行有效性校验 + * @return 是否删除成功 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); +} \ No newline at end of file diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/SmsSendRecordServiceImpl.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/SmsSendRecordServiceImpl.java new file mode 100644 index 00000000..b54e8912 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/SmsSendRecordServiceImpl.java @@ -0,0 +1,141 @@ +package com.fuyuanshen.equipment.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fuyuanshen.common.core.utils.MapstructUtils; +import com.fuyuanshen.common.core.utils.StringUtils; +import com.fuyuanshen.common.mybatis.core.page.PageQuery; +import com.fuyuanshen.common.mybatis.core.page.TableDataInfo; +import com.fuyuanshen.equipment.domain.SmsSendRecord; +import com.fuyuanshen.equipment.domain.bo.SmsSendRecordBo; +import com.fuyuanshen.equipment.domain.vo.SmsSendRecordVo; +import com.fuyuanshen.equipment.mapper.SmsSendRecordMapper; +import com.fuyuanshen.equipment.service.ISmsSendRecordService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 报警通知记录(短信/语音)Service业务层处理 + * + * @author Lion Li + * @date 2026-05-08 + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class SmsSendRecordServiceImpl implements ISmsSendRecordService { + + private final SmsSendRecordMapper baseMapper; + + /** + * 查询报警通知记录(短信/语音) + * + * @param id 主键 + * @return 报警通知记录(短信/语音) + */ + @Override + public SmsSendRecordVo queryById(Long id){ + return baseMapper.selectVoById(id); + } + + /** + * 分页查询报警通知记录(短信/语音)列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return 报警通知记录(短信/语音)分页列表 + */ + @Override + public TableDataInfo queryPageList(SmsSendRecordBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询符合条件的报警通知记录(短信/语音)列表 + * + * @param bo 查询条件 + * @return 报警通知记录(短信/语音)列表 + */ + @Override + public List queryList(SmsSendRecordBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(SmsSendRecordBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.orderByAsc(SmsSendRecord::getId); + lqw.eq(bo.getAlarmId() != null, SmsSendRecord::getAlarmId, bo.getAlarmId()); + lqw.eq(StringUtils.isNotBlank(bo.getDeviceImei()), SmsSendRecord::getDeviceImei, bo.getDeviceImei()); + lqw.eq(StringUtils.isNotBlank(bo.getNotifyType()), SmsSendRecord::getNotifyType, bo.getNotifyType()); + lqw.eq(StringUtils.isNotBlank(bo.getPhone()), SmsSendRecord::getPhone, bo.getPhone()); + lqw.eq(StringUtils.isNotBlank(bo.getContent()), SmsSendRecord::getContent, bo.getContent()); + lqw.eq(StringUtils.isNotBlank(bo.getTemplateId()), SmsSendRecord::getTemplateId, bo.getTemplateId()); + lqw.eq(bo.getStatus() != null, SmsSendRecord::getStatus, bo.getStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getResponseCode()), SmsSendRecord::getResponseCode, bo.getResponseCode()); + lqw.eq(StringUtils.isNotBlank(bo.getResponseMsg()), SmsSendRecord::getResponseMsg, bo.getResponseMsg()); + lqw.eq(bo.getSendTime() != null, SmsSendRecord::getSendTime, bo.getSendTime()); + return lqw; + } + + /** + * 新增报警通知记录(短信/语音) + * + * @param bo 报警通知记录(短信/语音) + * @return 是否新增成功 + */ + @Override + public Boolean insertByBo(SmsSendRecordBo bo) { + SmsSendRecord add = MapstructUtils.convert(bo, SmsSendRecord.class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改报警通知记录(短信/语音) + * + * @param bo 报警通知记录(短信/语音) + * @return 是否修改成功 + */ + @Override + public Boolean updateByBo(SmsSendRecordBo bo) { + SmsSendRecord update = MapstructUtils.convert(bo, SmsSendRecord.class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(SmsSendRecord entity){ + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 校验并批量删除报警通知记录(短信/语音)信息 + * + * @param ids 待删除的主键集合 + * @param isValid 是否进行有效性校验 + * @return 是否删除成功 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if(isValid){ + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteByIds(ids) > 0; + } +} diff --git a/fys-modules/fys-equipment/src/main/resources/mapper/equipment/DeviceAlarmMapper.xml b/fys-modules/fys-equipment/src/main/resources/mapper/equipment/DeviceAlarmMapper.xml index 034cd1db..3b1692fa 100644 --- a/fys-modules/fys-equipment/src/main/resources/mapper/equipment/DeviceAlarmMapper.xml +++ b/fys-modules/fys-equipment/src/main/resources/mapper/equipment/DeviceAlarmMapper.xml @@ -20,6 +20,9 @@ and d.device_name like concat('%', #{bo.deviceName}, '%') + + and da.device_id = #{bo.deviceId} + and dt.id = #{bo.deviceType} diff --git a/fys-modules/fys-equipment/src/main/resources/mapper/equipment/SmsSendRecordMapper.xml b/fys-modules/fys-equipment/src/main/resources/mapper/equipment/SmsSendRecordMapper.xml new file mode 100644 index 00000000..d288ecbb --- /dev/null +++ b/fys-modules/fys-equipment/src/main/resources/mapper/equipment/SmsSendRecordMapper.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/fys-modules/fys-job/src/main/java/com/fuyuanshen/job/integration/SnailJobClient.java b/fys-modules/fys-job/src/main/java/com/fuyuanshen/job/integration/SnailJobClient.java new file mode 100644 index 00000000..8ea7aab1 --- /dev/null +++ b/fys-modules/fys-job/src/main/java/com/fuyuanshen/job/integration/SnailJobClient.java @@ -0,0 +1,145 @@ +package com.fuyuanshen.job.integration; + +import com.aizuda.snailjob.client.job.core.openapi.SnailJobOpenApi; +import com.aizuda.snailjob.client.job.core.enums.AllocationAlgorithmEnum; +import com.aizuda.snailjob.common.core.enums.JobBlockStrategyEnum; +import com.aizuda.snailjob.client.job.core.enums.TriggerTypeEnum; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * SnailJob 1.8.1 业务集成客户端 + * 优化点:增强健壮性、参数校验、日志标准化 + */ +@Slf4j +@Component +public class SnailJobClient { + + //执行超时时间 + private static final int DEFAULT_TIMEOUT = 60; + //如果重试开启,两次重试之间间隔几秒 + private static final int RETRY_INTERVAL = 30; + + /** + * 创建离线自动关单任务 + * 优化:引入 Assert 校验,防止非法参数进入 OpenAPI 导致报错 + */ + public void addRetryTask(Long alarmId, String businessNo, String executorName, int delayMinutes) { + Assert.notNull(alarmId, "alarmId cannot be null"); + Assert.hasText(businessNo, "businessNo cannot be empty"); + + try { + // 计算新的延迟秒数 + String triggerInterval = String.valueOf(delayMinutes * 60L); + // 1. 计算 5 分钟后时间戳 + LocalDateTime execTime = LocalDateTime.now().plusMinutes(delayMinutes); + long triggerTime = execTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + + SnailJobOpenApi.addClusterJob() + // 设置任务路由策略:随机选择执行器节点 + .setRouteKey(AllocationAlgorithmEnum.RANDOM) + // 设置任务名称(使用业务编号唯一标识) + .setJobName(businessNo) + // 设置执行器名称(指定任务由哪个执行器执行) + .setExecutorInfo(executorName) + // 设置执行器超时时间(使用系统默认超时配置) + .setExecutorTimeout(DEFAULT_TIMEOUT) + // 设置任务描述:设备报警离线自动关单任务 + .setDescription("设备报警离线自动关单:" + businessNo) + // 设置任务阻塞策略:丢弃后续并发请求 + .setBlockStrategy(JobBlockStrategyEnum.OVERLAY) + // 设置最大重试次数:0 代表不重试 + .setMaxRetryTimes(0) + // 设置任务触发类型:定时触发 + // 改为 CRON 触发 + .setTriggerType(TriggerTypeEnum.SCHEDULED_TIME) + // 毫秒级时间戳 + .setTriggerInterval(triggerInterval) + // 添加任务入参:传入报警ID字符串 + .addArgsStr("alarmId", alarmId.toString()) + // 设置重试间隔时间(重试时的等待时间) + .setRetryInterval(RETRY_INTERVAL) + // 执行任务创建/提交操作 + .execute(); + + log.info("[SnailJob] 创建关单任务成功 | businessNo: {} | delay: {}m", businessNo, delayMinutes); + } catch (Exception e) { + log.error("[SnailJob] 创建关单任务异常 | businessNo: {}", businessNo, e); + } + } + + /** + * 更新任务触发时间(续期) + * 优化:增加原子性思考。虽然 1.8.1 必须删后再加,但通过 try-catch 确保删除失败不中断逻辑(可能任务本就不存在) + */ + public void updateRetryTaskNextTriggerTime(Long alarmId, String businessNo, String executorName, int delayMinutes) { + try { + // 计算新的延迟秒数 + String triggerInterval = String.valueOf(delayMinutes * 60L); + + // 只要 JobName (businessNo) 一致,SnailJob 服务端会识别为同一个任务进行更新 + SnailJobOpenApi.addClusterJob() + .setJobName(businessNo) // 关键:保持 JobName 不变 + .setExecutorInfo(executorName) + .setTriggerType(TriggerTypeEnum.SCHEDULED_TIME) + .setTriggerInterval(triggerInterval) + .addArgsStr("alarmId", alarmId.toString()) + .setRouteKey(AllocationAlgorithmEnum.RANDOM) + .setMaxRetryTimes(0) + // 强制开启覆盖更新(如果 SDK 支持,部分版本需显式指定,1.8.1 默认通常为 saveOrUpdate 逻辑) + .execute(); + + log.info("[SnailJob] 任务续期成功(覆盖方式) → businessNo:{}", businessNo); + } catch (Exception e) { + log.error("[SnailJob] 任务续期失败", e); + } + } + + /** + * 删除任务 + * 优化:针对 hashCode() 可能产生的负值进行处理,并使用更稳健的 Set 构造 + */ + public void deleteRetryTask(String businessNo) { + if (businessNo == null) return; + + try { + // 注意:hashCode 存在冲突可能。在 SnailJob 中若需绝对精确删除,建议存储 addClusterJob 返回的 ID + // 这里保留你的逻辑,但对负数哈希取绝对值以符合一般 ID 预期(取决于服务端接收逻辑) + long jobId = Math.abs((long) businessNo.hashCode()); + + SnailJobOpenApi.deleteJob(Set.of(jobId)).execute(); + log.info("[SnailJob] 删除任务成功 | businessNo: {} | jobId: {}", businessNo, jobId); + } catch (Exception e) { + // 删除通常作为补偿操作,记录 warn 即可,无需抛出异常中断业务 + log.warn("[SnailJob] 删除任务异常 | businessNo: {} | msg: {}", businessNo, e.getMessage()); + } + } + + /** + * 触发工作流 + * 优化:增加对 args 内容的空值保护 + */ + public void startWorkflow(String workflowId, Map args) { + Assert.hasText(workflowId, "workflowId cannot be empty"); + + try { + var request = SnailJobOpenApi.triggerWorkFlow(Long.parseLong(workflowId)); + + if (args != null) { + args.forEach((k, v) -> request.addArgsStr(k, Objects.toString(v, ""))); + } + + request.execute(); + log.info("[SnailJob] 触发工作流成功 | workflowId: {}", workflowId); + } catch (Exception e) { + log.error("[SnailJob] 触发工作流失败 | workflowId: {}", workflowId, e); + } + } +} \ No newline at end of file