20260326:数据推送

This commit is contained in:
zhugongping 2026-03-28 10:38:52 +08:00
parent b7e0984654
commit c9620a344c
6 changed files with 327 additions and 244 deletions

View File

@ -8,9 +8,9 @@ package com.fastbee.common.enums;
*/
public enum DataLevelEnum {
GREEN(1, "✅ 正常", "#009900"),
YELLOW(2, "⚠️ 告警", "#FF9900"),
RED(3, "🔴 警报", "#FF0000");
GREEN(1, "正常", "#009900"),
YELLOW(2, "告警", "#FF9900"),
RED(3, "警报", "#FF0000");
private final int code;
private final String label;

View File

@ -1,9 +1,11 @@
package com.fastbee.iot.data.push;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.fastbee.common.constant.FastBeeConstant;
import com.fastbee.common.core.notify.NotifySendResponse;
import com.fastbee.common.core.notify.config.WeChatConfigParams;
import com.fastbee.common.core.notify.msg.WechatMsgParams;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.core.redis.RedisKeyBuilder;
import com.fastbee.common.enums.DataLevelEnum;
@ -11,15 +13,17 @@ import com.fastbee.common.utils.StringUtils;
import com.fastbee.common.utils.wechat.WechatUtils;
import com.fastbee.common.wechat.WeChatAppResult;
import com.fastbee.iot.domain.PushModelItem;
import com.fastbee.iot.domain.SocialUser;
import com.fastbee.iot.domain.ThingsModelPushConfig;
import com.fastbee.iot.model.ThingsModels.ValueItem;
import com.fastbee.iot.service.IThingsModelPushConfigService;
import com.fastbee.notify.domain.NotifyChannel;
import com.fastbee.iot.service.ISocialUserService;
import com.fastbee.notify.core.wechat.vo.TemplateDataVo;
import com.fastbee.notify.core.wechat.vo.WeChatPublicAccountPushVO;
import com.fastbee.notify.domain.NotifyChannel;
import com.fastbee.notify.domain.NotifyTemplate;
import com.fastbee.notify.service.INotifyChannelService;
import com.fastbee.iot.service.ISocialUserService;
import com.fastbee.iot.domain.SocialUser;
import com.fastbee.notify.service.INotifyTemplateService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.http.HttpEntity;
@ -28,28 +32,31 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import com.alibaba.fastjson2.JSONObject;
import javax.annotation.Resource;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 物模型数据推送核心处理器
* 读取 Redis 中设备最新上报值,按配置的阈值评估数据等级,
* 同等级数据合并为一条微信服务号模板消息推送。
*
* @author fastbee
* <p>
* 复用"运维管理 - 通知模板"中已配置的微信公众号模板(notify_template),
* 从中读取 templateId、content(变量)、redirectUrl 等信息。
*/
@Slf4j
@Component
public class ThingsModelPushHandler {
private static final RestTemplate REST_TEMPLATE = new RestTemplate();
private static final Pattern VAR_PATTERN = Pattern.compile("\\{\\{([^}]+)}}");
@Resource
private IThingsModelPushConfigService pushConfigService;
@Resource
private INotifyTemplateService notifyTemplateService;
@Resource
private INotifyChannelService notifyChannelService;
@Resource
private ISocialUserService socialUserService;
@ -57,20 +64,35 @@ public class ThingsModelPushHandler {
private RedisCache redisCache;
/**
* 按推送配置ID执行推送(读取该配置涉及设备的最新 Redis 值)
*
* @param config 推送配置(含 items、userIds)
* 按推送配置执行推送
*/
public void doPush(ThingsModelPushConfig config) {
if (CollectionUtils.isEmpty(config.getItems())) {
log.warn("[Push] configId={} 没有配置物模型项,跳过", config.getId());
return;
}
if (config.getDeviceId() == null || config.getDeviceId() <= 0
|| StringUtils.isEmpty(config.getSerialNumber())) {
log.warn("[Push] configId={} 未指定具体设备,跳过", config.getId());
return;
}
// 获取通知渠道(公众号)
NotifyChannel channel = notifyChannelService.selectNotifyChannelById(config.getChannelId());
// ---------- 从通知模板获取微信配置 ----------
NotifyTemplate notifyTemplate = notifyTemplateService.selectNotifyTemplateById(config.getTemplateId());
if (notifyTemplate == null) {
log.warn("[Push] configId={} 通知模板不存在 templateId={}", config.getId(), config.getTemplateId());
return;
}
WechatMsgParams msgParams = JSONObject.parseObject(notifyTemplate.getMsgParams(), WechatMsgParams.class);
if (msgParams == null || StringUtils.isEmpty(msgParams.getTemplateId())) {
log.warn("[Push] configId={} 通知模板 msgParams 不完整", config.getId());
return;
}
// ---------- 从通知渠道获取 appId/appSecret ----------
NotifyChannel channel = notifyChannelService.selectNotifyChannelById(notifyTemplate.getChannelId());
if (channel == null) {
log.warn("[Push] configId={} 渠道不存在 channelId={}", config.getId(), config.getChannelId());
log.warn("[Push] configId={} 渠道不存在 channelId={}", config.getId(), notifyTemplate.getChannelId());
return;
}
WeChatConfigParams wxConfig = JSONObject.parseObject(channel.getConfigContent(), WeChatConfigParams.class);
@ -79,7 +101,7 @@ public class ThingsModelPushHandler {
return;
}
// 获取 access_token
// ---------- 获取 access_token ----------
WeChatAppResult tokenResult = WechatUtils.getAccessToken(wxConfig.getAppId(), wxConfig.getAppSecret());
if (tokenResult == null || StringUtils.isEmpty(tokenResult.getAccessToken())) {
log.error("[Push] configId={} 获取微信 access_token 失败", config.getId());
@ -87,14 +109,7 @@ public class ThingsModelPushHandler {
}
String pushUrl = FastBeeConstant.URL.WX_PUBLIC_ACCOUNT_TEMPLATE_SEND_URL_PREFIX + tokenResult.getAccessToken();
// 确定要查询的设备编号列表
List<String> serialNumbers = resolveSerialNumbers(config);
if (CollectionUtils.isEmpty(serialNumbers)) {
log.warn("[Push] configId={} 没有找到对应设备,跳过", config.getId());
return;
}
// 获取接收用户 openId 列表
// ---------- 获取接收用户 openId ----------
List<Long> userIds = config.getUserIds();
if (CollectionUtils.isEmpty(userIds)) {
log.warn("[Push] configId={} 没有配置接收用户,跳过", config.getId());
@ -103,28 +118,31 @@ public class ThingsModelPushHandler {
List<SocialUser> socialUsers = socialUserService.listWechatPublicAccountOpenId(
userIds.stream().map(Object::toString).collect(Collectors.toList()));
Map<Long, String> openIdMap = socialUsers.stream()
.filter(u -> StringUtils.isNotEmpty(u.getOpenId()))
.collect(Collectors.toMap(SocialUser::getSysUserId, SocialUser::getOpenId, (a, b) -> b));
// 对每台设备推送
for (String serialNumber : serialNumbers) {
pushForDevice(config, serialNumber, pushUrl, openIdMap);
if (openIdMap.isEmpty()) {
log.warn("[Push] configId={} 所有用户均未绑定微信公众号,跳过", config.getId());
return;
}
// ---------- 执行推送 ----------
pushForDevice(config, msgParams, pushUrl, openIdMap);
// 更新最近推送时间
pushConfigService.updateLastPushTime(config.getId());
}
/**
* 对单台设备执行推送
* 对单台设备按等级分组推送
*/
private void pushForDevice(ThingsModelPushConfig config, String serialNumber,
private void pushForDevice(ThingsModelPushConfig config, WechatMsgParams msgParams,
String pushUrl, Map<Long, String> openIdMap) {
String serialNumber = config.getSerialNumber().toUpperCase();
String redisKey = RedisKeyBuilder.buildTSLVCacheKey(config.getProductId(), serialNumber);
// 按等级分组收集显示文本
Map<DataLevelEnum, List<String>> levelTextMap = new LinkedHashMap<>();
for (DataLevelEnum level : DataLevelEnum.values()) {
levelTextMap.put(level, new ArrayList<>());
for (DataLevelEnum lvl : DataLevelEnum.values()) {
levelTextMap.put(lvl, new ArrayList<>());
}
for (PushModelItem item : config.getItems()) {
@ -134,8 +152,7 @@ public class ThingsModelPushHandler {
}
String value;
try {
ValueItem vi = JSON.parseObject(cacheValue, ValueItem.class);
value = vi.getValue();
value = JSON.parseObject(cacheValue, ValueItem.class).getValue();
} catch (Exception e) {
value = cacheValue;
}
@ -143,57 +160,69 @@ public class ThingsModelPushHandler {
value, item.getGreenMin(), item.getGreenMax(),
item.getYellowMin(), item.getYellowMax());
String displayName = StringUtils.isNotEmpty(item.getModelName()) ? item.getModelName() : item.getIdentifier();
String text = displayName + "=" + value + (StringUtils.isNotEmpty(item.getUnit()) ? item.getUnit() : "");
String displayName = StringUtils.isNotEmpty(item.getModelName())
? item.getModelName() : item.getIdentifier();
String text = displayName + "=" + value
+ (StringUtils.isNotEmpty(item.getUnit()) ? item.getUnit() : "");
levelTextMap.get(level).add(text);
}
// 对每个非空等级发一条消息
for (Map.Entry<DataLevelEnum, List<String>> entry : levelTextMap.entrySet()) {
if (CollectionUtils.isEmpty(entry.getValue())) {
continue;
}
DataLevelEnum level = entry.getKey();
String dataText = String.join(";", entry.getValue());
sendTemplateMsg(config, serialNumber, level, dataText, pushUrl, openIdMap);
sendTemplateMsg(config, msgParams, serialNumber, level, dataText, pushUrl, openIdMap);
}
}
/**
* 发送单条微信模板消息(带颜色标记)
* 发送单条微信模板消息。
* 从通知模板的 content 字段提取 {{变量.DATA}} 变量名顺序,
* 按顺序映射:第1个→设备名称,第2个→数据等级,第3个→监测数据。
*/
private void sendTemplateMsg(ThingsModelPushConfig config, String serialNumber,
DataLevelEnum level, String dataText,
private void sendTemplateMsg(ThingsModelPushConfig config, WechatMsgParams msgParams,
String serialNumber, DataLevelEnum level, String dataText,
String pushUrl, Map<Long, String> openIdMap) {
// 微信模板格式(需在公众号后台与此对应):
// {{first.DATA}}
// 设备编号:{{keyword1.DATA}}
// 数据等级:{{keyword2.DATA}}
// 监测数据:{{keyword3.DATA}}
// {{remark.DATA}}
Map<String, Object> data = new LinkedHashMap<>();
data.put("first", new TemplateDataVo("设备数据监测通知", "#333333"));
data.put("keyword1", new TemplateDataVo(serialNumber, "#333333"));
data.put("keyword2", new TemplateDataVo(level.getLabel(), level.getColor()));
data.put("keyword3", new TemplateDataVo(dataText, level.getColor()));
data.put("remark", new TemplateDataVo("请及时关注设备状态", "#999999"));
List<String> varNames = extractVarNames(msgParams.getContent());
LinkedHashMap<String, String> map = new LinkedHashMap<>();
String deviceDisplay = StringUtils.isNotEmpty(config.getDeviceName())
? config.getDeviceName() : serialNumber;
for (int i = 0; i < varNames.size(); i++) {
String varName = varNames.get(i);
if (i == 0) {
map.put(varName, deviceDisplay);
} else if (i == 1) {
map.put(varName, level.getLabel());
} else if (i == 2) {
map.put(varName, dataText);
} else {
map.put(varName, "");
}
}
// 与 WeChatPushServiceImpl 保持一致的 map 迭代方式
Map<String, Object> sendMap = new LinkedHashMap<>();
for (Map.Entry<String, String> m : map.entrySet()) {
sendMap.put(m.getKey(), new TemplateDataVo(m.getValue(), level.getColor()));
}
for (Map.Entry<Long, String> userEntry : openIdMap.entrySet()) {
String openId = userEntry.getValue();
if (StringUtils.isEmpty(openId)) {
continue;
}
WeChatPublicAccountPushVO vo = new WeChatPublicAccountPushVO();
vo.setTouser(openId);
vo.setTemplateId(config.getWxTemplateId());
vo.setData(data);
if (StringUtils.isNotEmpty(config.getRedirectUrl())) {
vo.setUrl(config.getRedirectUrl());
vo.setTemplateId(msgParams.getTemplateId());
vo.setData(sendMap);
if (StringUtils.isNotEmpty(msgParams.getRedirectUrl())) {
vo.setUrl(msgParams.getRedirectUrl());
}
try {
NotifySendResponse resp = doPost(JSON.toJSONString(vo), pushUrl);
log.info("[Push] configId={} sn={} userId={} level={} status={}",
config.getId(), serialNumber, userEntry.getKey(), level.getLabel(), resp.getStatus());
config.getId(), serialNumber, userEntry.getKey(),
level.getLabel(), resp.getStatus());
} catch (Exception e) {
log.error("[Push] configId={} sn={} userId={} 推送异常: {}",
config.getId(), serialNumber, userEntry.getKey(), e.getMessage());
@ -201,13 +230,25 @@ public class ThingsModelPushHandler {
}
}
private List<String> extractVarNames(String content) {
List<String> names = new ArrayList<>();
if (StringUtils.isEmpty(content)) {
return names;
}
Matcher matcher = VAR_PATTERN.matcher(content);
while (matcher.find()) {
names.add(matcher.group(1).replace(".DATA", ""));
}
return names;
}
private NotifySendResponse doPost(String json, String url) {
NotifySendResponse resp = new NotifySendResponse();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("application/json; charset=UTF-8"));
headers.add("Accept", MediaType.APPLICATION_JSON.toString());
ResponseEntity<String> result = REST_TEMPLATE.postForEntity(url,
new HttpEntity<>(json, headers), String.class);
ResponseEntity<String> result = REST_TEMPLATE.postForEntity(
url, new HttpEntity<>(json, headers), String.class);
String body = result.getBody();
resp.setResultContent(body);
try {
@ -217,28 +258,4 @@ public class ThingsModelPushHandler {
}
return resp;
}
/**
* 确定推送涉及的设备编号列表
* device_id=0 → 产品级,需查产品下所有设备
* device_id>0 → 指定设备
*/
private List<String> resolveSerialNumbers(ThingsModelPushConfig config) {
if (config.getDeviceId() != null && config.getDeviceId() > 0) {
if (StringUtils.isNotEmpty(config.getSerialNumber())) {
return Collections.singletonList(config.getSerialNumber().toUpperCase());
}
return Collections.emptyList();
}
// 产品级:从 Redis 中找该产品所有已有 TSLV 缓存的设备(通过前缀扫描)
String pattern = FastBeeConstant.REDIS.DEVICE_PRE_KEY + config.getProductId() + "_*";
Collection<String> keys = redisCache.keys(pattern);
if (keys == null || keys.isEmpty()) {
return Collections.emptyList();
}
String prefix = FastBeeConstant.REDIS.DEVICE_PRE_KEY + config.getProductId() + "_";
return keys.stream()
.map(k -> k.substring(prefix.length()))
.collect(Collectors.toList());
}
}

View File

@ -31,26 +31,26 @@ public class ThingsModelPushConfig extends BaseEntity {
@ApiModelProperty("产品ID")
private Long productId;
@ApiModelProperty("产品名称(非持久化字段)")
@ApiModelProperty("产品名称(非持久化,JOIN查询)")
private String productName;
@ApiModelProperty("设备ID,0=产品级")
@ApiModelProperty("设备ID(必须指定具体设备)")
private Long deviceId;
@ApiModelProperty("设备名称(非持久化,JOIN查询)")
private String deviceName;
@ApiModelProperty("设备类型:1=直连 2=网关(非持久化,JOIN查询)")
private Integer deviceType;
@ApiModelProperty("设备编号")
private String serialNumber;
@ApiModelProperty("通知渠道ID")
private Long channelId;
@ApiModelProperty("通知模板ID(关联 notify_template)")
private Long templateId;
@ApiModelProperty("通知渠道名称(非持久化字段)")
private String channelName;
@ApiModelProperty("微信公众号模板ID")
private String wxTemplateId;
@ApiModelProperty("消息点击跳转URL")
private String redirectUrl;
@ApiModelProperty("通知模板名称(非持久化,JOIN查询)")
private String templateName;
@ApiModelProperty("推送间隔(分钟),0=仅手动推送")
private Integer pushInterval;

View File

@ -9,11 +9,11 @@
<result property="productId" column="product_id"/>
<result property="productName" column="product_name"/>
<result property="deviceId" column="device_id"/>
<result property="deviceName" column="device_name"/>
<result property="deviceType" column="device_type"/>
<result property="serialNumber" column="serial_number"/>
<result property="channelId" column="channel_id"/>
<result property="channelName" column="channel_name"/>
<result property="wxTemplateId" column="wx_template_id"/>
<result property="redirectUrl" column="redirect_url"/>
<result property="templateId" column="template_id"/>
<result property="templateName" column="template_name"/>
<result property="pushInterval" column="push_interval"/>
<result property="lastPushTime" column="last_push_time"/>
<result property="status" column="status"/>
@ -52,36 +52,38 @@
<sql id="baseColumns">
c.id, c.config_name, c.product_id, c.device_id, c.serial_number,
c.channel_id, c.wx_template_id, c.redirect_url,
c.push_interval, c.last_push_time, c.status,
c.template_id, c.push_interval, c.last_push_time, c.status,
c.tenant_id, c.tenant_name,
c.create_by, c.create_time, c.update_by, c.update_time,
p.product_name, nc.name AS channel_name
p.product_name, p.device_type, d.device_name, nt.name AS template_name
</sql>
<sql id="baseJoins">
FROM iot_things_model_push_config c
LEFT JOIN iot_device d ON c.device_id = d.device_id
LEFT JOIN iot_product p ON c.product_id = p.product_id
LEFT JOIN notify_template nt ON c.template_id = nt.id
</sql>
<select id="selectById" resultMap="ConfigResult">
SELECT <include refid="baseColumns"/>
FROM iot_things_model_push_config c
LEFT JOIN iot_product p ON c.product_id = p.product_id
LEFT JOIN notify_channel nc ON c.channel_id = nc.id
<include refid="baseJoins"/>
WHERE c.id = #{id} AND c.del_flag = '0'
</select>
<select id="selectList" resultMap="ConfigResult">
SELECT <include refid="baseColumns"/>
FROM iot_things_model_push_config c
LEFT JOIN iot_product p ON c.product_id = p.product_id
LEFT JOIN notify_channel nc ON c.channel_id = nc.id
<include refid="baseJoins"/>
WHERE c.del_flag = '0'
<if test="configName != null and configName != ''">
AND c.config_name LIKE CONCAT('%', #{configName}, '%')
</if>
<if test="productId != null">
AND c.product_id = #{productId}
</if>
<if test="deviceId != null">
AND c.device_id = #{deviceId}
</if>
<if test="serialNumber != null and serialNumber != ''">
AND c.serial_number LIKE CONCAT('%', #{serialNumber}, '%')
</if>
<if test="status != null">
AND c.status = #{status}
</if>
@ -93,30 +95,25 @@
<select id="selectAllEnabled" resultMap="ConfigResult">
SELECT <include refid="baseColumns"/>
FROM iot_things_model_push_config c
LEFT JOIN iot_product p ON c.product_id = p.product_id
LEFT JOIN notify_channel nc ON c.channel_id = nc.id
<include refid="baseJoins"/>
WHERE c.del_flag = '0' AND c.status = 1 AND c.push_interval > 0
</select>
<select id="selectEnabledByProductAndDevice" resultMap="ConfigResult">
SELECT <include refid="baseColumns"/>
FROM iot_things_model_push_config c
LEFT JOIN iot_product p ON c.product_id = p.product_id
LEFT JOIN notify_channel nc ON c.channel_id = nc.id
<include refid="baseJoins"/>
WHERE c.del_flag = '0' AND c.status = 1
AND c.product_id = #{productId}
AND (c.device_id = 0 OR c.device_id = #{deviceId})
AND c.device_id = #{deviceId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO iot_things_model_push_config
(config_name, product_id, device_id, serial_number, channel_id,
wx_template_id, redirect_url, push_interval, status,
(config_name, product_id, device_id, serial_number, template_id,
push_interval, status,
tenant_id, tenant_name, create_by, create_time, del_flag)
VALUES
(#{configName}, #{productId}, #{deviceId}, #{serialNumber}, #{channelId},
#{wxTemplateId}, #{redirectUrl}, #{pushInterval}, #{status},
(#{configName}, #{productId}, #{deviceId}, #{serialNumber}, #{templateId},
#{pushInterval}, #{status},
#{tenantId}, #{tenantName}, #{createBy}, #{createTime}, '0')
</insert>
@ -127,9 +124,7 @@
<if test="productId != null">product_id = #{productId},</if>
<if test="deviceId != null">device_id = #{deviceId},</if>
<if test="serialNumber != null">serial_number = #{serialNumber},</if>
<if test="channelId != null">channel_id = #{channelId},</if>
<if test="wxTemplateId != null">wx_template_id = #{wxTemplateId},</if>
<if test="redirectUrl != null">redirect_url = #{redirectUrl},</if>
<if test="templateId != null">template_id = #{templateId},</if>
<if test="pushInterval != null">push_interval = #{pushInterval},</if>
<if test="status != null">status = #{status},</if>
update_by = #{updateBy},

View File

@ -7,11 +7,9 @@ CREATE TABLE IF NOT EXISTS `iot_things_model_push_config` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`config_name` VARCHAR(128) NOT NULL COMMENT '配置名称',
`product_id` BIGINT NOT NULL COMMENT '产品ID',
`device_id` BIGINT DEFAULT 0 COMMENT '设备ID,0=产品级(产品下所有设备)',
`serial_number` VARCHAR(64) DEFAULT NULL COMMENT '设备编号(device_id>0时有效)',
`channel_id` BIGINT NOT NULL COMMENT '通知渠道ID (notify_channel.id,类型为 public_account)',
`wx_template_id` VARCHAR(128) NOT NULL COMMENT '微信公众号模板ID(在微信公众平台配置)',
`redirect_url` VARCHAR(512) DEFAULT NULL COMMENT '消息点击跳转URL',
`device_id` BIGINT DEFAULT NULL COMMENT '设备ID(必须指定具体设备)',
`serial_number` VARCHAR(64) DEFAULT NULL COMMENT '设备编号',
`template_id` BIGINT NOT NULL COMMENT '通知模板ID(关联 notify_template.id)',
`push_interval` INT DEFAULT 60 COMMENT '推送间隔(分钟),0=仅手动推送',
`last_push_time` DATETIME DEFAULT NULL COMMENT '最近一次推送时间',
`status` TINYINT DEFAULT 1 COMMENT '状态:0=停用 1=启用',
@ -23,8 +21,8 @@ CREATE TABLE IF NOT EXISTS `iot_things_model_push_config` (
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
PRIMARY KEY (`id`),
KEY `idx_product_id` (`product_id`),
KEY `idx_device_id` (`device_id`)
KEY `idx_device_id` (`device_id`),
KEY `idx_template_id` (`template_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物模型数据推送配置';
@ -59,11 +57,9 @@ CREATE TABLE IF NOT EXISTS `iot_push_user` (
-- sys_menu 菜单权限(挂在"规则引擎"父菜单 parent_id=3051 下)
-- ============================================================
-- 主菜单:数据推送配置
INSERT INTO `sys_menu` (menu_id, menu_name, parent_id, order_num, `path`, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (3430, '数据推送配置', 3051, 9, 'pushConfig', 'iot/push/index', NULL, 1, 0, 'C', '0', '0', 'iot:push:list', 'notification', 'admin', now(), 'admin', now(), '微信服务号物模型数据推送');
-- 按钮权限
INSERT INTO `sys_menu` (menu_id, menu_name, parent_id, order_num, `path`, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (3431, '推送配置查询', 3430, 1, '#', '', NULL, 1, 0, 'F', '0', '0', 'iot:push:query', '#', 'admin', now(), 'admin', now(), '');
@ -78,3 +74,17 @@ VALUES (3434, '推送配置删除', 3430, 4, '#', '', NULL, 1, 0, 'F', '0', '0',
INSERT INTO `sys_menu` (menu_id, menu_name, parent_id, order_num, `path`, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (3435, '手动立即推送', 3430, 5, '#', '', NULL, 1, 0, 'F', '0', '0', 'iot:push:push', '#', 'admin', now(), 'admin', now(), '');
-- ============================================================
-- 升级脚本:如已执行过旧版本建表,执行以下语句迁移字段
-- ============================================================
ALTER TABLE `iot_things_model_push_config`
ADD COLUMN IF NOT EXISTS `template_id` BIGINT DEFAULT NULL COMMENT '通知模板ID(关联 notify_template.id)' AFTER `serial_number`,
ADD INDEX IF NOT EXISTS `idx_template_id` (`template_id`);
-- 迁移完成后可删除旧字段(按需执行):
-- ALTER TABLE `iot_things_model_push_config`
-- DROP COLUMN IF EXISTS `channel_id`,
-- DROP COLUMN IF EXISTS `wx_template_id`,
-- DROP COLUMN IF EXISTS `wx_msg_content`,
-- DROP COLUMN IF EXISTS `redirect_url`;

View File

@ -2,14 +2,15 @@
<div class="iot-push-config">
<!-- 搜索栏 -->
<el-card v-show="showSearch" style="margin-bottom: 10px">
<el-form @submit.native.prevent :model="queryParams" ref="queryForm" :inline="true" size="mini" label-width="80px" style="margin-bottom: -18px">
<el-form @submit.native.prevent :model="queryParams" ref="queryForm" :inline="true"
size="mini" label-width="80px" style="margin-bottom: -18px">
<el-form-item label="配置名称" prop="configName">
<el-input v-model="queryParams.configName" placeholder="请输入配置名称" clearable @keyup.enter.native="handleQuery" />
<el-input v-model="queryParams.configName" placeholder="请输入配置名称"
clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="产品" prop="productId">
<el-select v-model="queryParams.productId" placeholder="请选择产品" clearable filterable style="width:160px">
<el-option v-for="p in productOptions" :key="p.productId" :label="p.productName" :value="p.productId" />
</el-select>
<el-form-item label="设备编号" prop="serialNumber">
<el-input v-model="queryParams.serialNumber" placeholder="请输入设备编号"
clearable @keyup.enter.native="handleQuery" style="width:160px" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width:100px">
@ -27,15 +28,21 @@
<el-card>
<el-row :gutter="10" style="margin-bottom: 15px">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['iot:push:add']">新增</el-button>
<el-button type="primary" plain icon="el-icon-plus" size="mini"
@click="handleAdd" v-hasPermi="['iot:push:add']">新增</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="configList" border>
<el-table-column label="配置名称" align="center" prop="configName" min-width="140" show-overflow-tooltip />
<el-table-column label="产品" align="center" prop="productName" min-width="120" show-overflow-tooltip />
<el-table-column label="通知渠道" align="center" prop="channelName" min-width="120" show-overflow-tooltip />
<el-table-column label="配置名称" align="center" prop="configName"
min-width="140" show-overflow-tooltip />
<el-table-column label="设备名称" align="center" prop="deviceName"
min-width="120" show-overflow-tooltip />
<el-table-column label="设备编号" align="center" prop="serialNumber"
min-width="120" show-overflow-tooltip />
<el-table-column label="通知模板" align="center" prop="templateName"
min-width="120" show-overflow-tooltip />
<el-table-column label="推送间隔" align="center" width="110">
<template slot-scope="scope">
<span v-if="scope.row.pushInterval > 0">每 {{ scope.row.pushInterval }} 分钟</span>
@ -50,12 +57,14 @@
<el-table-column label="状态" align="center" width="80">
<template slot-scope="scope">
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0"
@change="handleStatusChange(scope.row)" v-hasPermi="['iot:push:edit']" />
@change="handleStatusChange(scope.row)"
v-hasPermi="['iot:push:edit']" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="200" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['iot:push:edit']">编辑</el-button>
<el-button size="mini" type="text" icon="el-icon-edit"
@click="handleUpdate(scope.row)" v-hasPermi="['iot:push:edit']">编辑</el-button>
<el-button size="mini" type="text" icon="el-icon-video-play" style="color:#67C23A"
@click="handleManualPush(scope.row)" v-hasPermi="['iot:push:push']">立即推送</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" style="color:#F56C6C"
@ -64,13 +73,16 @@
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<pagination v-show="total > 0" :total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList" />
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog :title="dialogTitle" :visible.sync="dialogOpen" width="900px" append-to-body
<el-dialog :title="dialogTitle" :visible.sync="dialogOpen" width="960px" append-to-body
:close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="配置名称" prop="configName">
@ -78,34 +90,37 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品" prop="productId">
<el-select v-model="form.productId" placeholder="请选择产品" filterable style="width:100%"
@change="onProductChange">
<el-option v-for="p in productOptions" :key="p.productId" :label="p.productName" :value="p.productId" />
<el-form-item label="选择设备" prop="deviceId">
<el-select v-model="form.deviceId" placeholder="输入设备名称或编号搜索"
filterable remote :remote-method="remoteSearchDevice"
:loading="deviceSearchLoading" style="width:100%"
@change="onDeviceChange">
<el-option v-for="d in deviceOptions" :key="d.deviceId"
:label="deviceLabel(d)" :value="d.deviceId">
<span>{{ d.deviceName }}</span>
<span style="color:#909399;font-size:12px;margin-left:6px">{{ d.serialNumber }}</span>
<el-tag size="mini" :type="d.deviceType === 2 ? 'warning' : 'success'"
style="margin-left:6px;zoom:0.85">
{{ d.deviceType === 2 ? '网关' : '直连' }}
</el-tag>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="通知渠道" prop="channelId">
<el-select v-model="form.channelId" placeholder="请选择公众号渠道" filterable style="width:100%">
<el-option v-for="c in channelOptions" :key="c.id" :label="c.name" :value="c.id" />
<el-form-item label="通知模板" prop="templateId">
<el-select v-model="form.templateId" placeholder="请选择通知模板(微信公众号类型)"
filterable style="width:100%">
<el-option v-for="t in templateOptions" :key="t.id"
:label="t.name" :value="t.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="微信模板ID" prop="wxTemplateId">
<el-input v-model="form.wxTemplateId" placeholder="在微信公众号后台获取模板ID" />
<div class="form-tip">请在「运维管理 - 通知模板」中配置微信公众号模板</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="推送间隔(分)" prop="pushInterval">
<el-input-number v-model="form.pushInterval" :min="0" :max="1440" style="width:100%"
placeholder="0=仅手动推送" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="跳转URL">
<el-input v-model="form.redirectUrl" placeholder="消息点击跳转链接(选填)" />
<el-input-number v-model="form.pushInterval" :min="0" :max="1440"
style="width:100%" placeholder="0=仅手动推送" />
</el-form-item>
</el-col>
<el-col :span="24">
@ -120,18 +135,21 @@
<!-- 物模型选择与阈值配置 -->
<el-divider content-position="left">监测物模型及数据等级阈值</el-divider>
<div style="margin-bottom:10px;color:#606266;font-size:12px">
绿色(✅ 正常):值在 [绿下限, 绿上限] 范围内;
黄色(⚠️ 告警):值在 [黄下限, 黄上限] 范围内;
红色(🔴 警报):其余情况。留空表示不限。
<div class="form-tip" style="margin-bottom:8px">
绿色(正常):值在 [绿下限, 绿上限] 范围内;
黄色(告警):值在 [黄下限, 黄上限] 范围内;
红色(警报):其余情况。留空表示不限。
</div>
<el-table :data="form.items" size="mini" border>
<el-table-column label="物模型" min-width="160">
<el-table-column label="物模型属性" min-width="170">
<template slot-scope="scope">
<el-select v-model="scope.row.identifier" placeholder="选择物模型属性" filterable
size="mini" @change="(v) => onModelItemChange(v, scope.row)">
<el-select v-model="scope.row.identifier" placeholder="选择属性"
filterable size="mini"
:disabled="!form.productId"
@change="(v) => onModelItemChange(v, scope.row)">
<el-option v-for="m in thingsModelOptions" :key="m.identifier"
:label="m.modelName + ' (' + m.identifier + ')'" :value="m.identifier" />
:label="m.modelName + ' (' + m.identifier + ')'"
:value="m.identifier" />
</el-select>
</template>
</el-table-column>
@ -160,19 +178,22 @@
<el-input v-model="scope.row.yellowMax" size="mini" placeholder="不限" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<el-table-column label="操作" width="60" align="center">
<template slot-scope="scope">
<el-button type="text" icon="el-icon-delete" style="color:#F56C6C"
size="mini" @click="removeModelItem(scope.$index)" />
</template>
</el-table-column>
</el-table>
<el-button size="mini" icon="el-icon-plus" style="margin-top:8px" @click="addModelItem">添加物模型</el-button>
<el-button size="mini" icon="el-icon-plus" style="margin-top:8px"
:disabled="!form.productId"
@click="addModelItem">添加物模型</el-button>
<span v-if="!form.productId" class="form-tip" style="margin-left:8px">请先选择设备</span>
<!-- 接收用户 -->
<el-divider content-position="left">接收用户(需已绑定微信公众号)</el-divider>
<el-select v-model="form.userIds" multiple filterable placeholder="请选择接收用户"
style="width:100%" size="small">
<el-select v-model="form.userIds" multiple filterable
placeholder="请选择接收用户" style="width:100%" size="small">
<el-option v-for="u in userOptions" :key="u.userId" :value="u.userId"
:label="u.nickName + ' (' + u.userName + ')'" />
</el-select>
@ -191,19 +212,18 @@ import {
listPushConfig, getPushConfig, addPushConfig, updatePushConfig,
changePushConfigStatus, delPushConfig, manualPush
} from '@/api/iot/pushConfig';
import { listProduct } from '@/api/iot/product';
import { listDevice } from '@/api/iot/device';
import { listModel } from '@/api/iot/model';
import { listChannel } from '@/api/notify/channel';
import { listTemplate } from '@/api/notify/template';
import { listUser } from '@/api/system/user';
const emptyForm = () => ({
id: null,
configName: '',
productId: null,
deviceId: 0,
channelId: null,
wxTemplateId: '',
redirectUrl: '',
deviceId: null,
serialNumber: '',
templateId: null,
pushInterval: 60,
status: 1,
items: [],
@ -218,28 +238,27 @@ export default {
showSearch: true,
total: 0,
configList: [],
queryParams: { pageNum: 1, pageSize: 10, configName: '', productId: null, status: null },
queryParams: { pageNum: 1, pageSize: 10, configName: '', serialNumber: '', status: null },
dialogOpen: false,
dialogTitle: '',
submitLoading: false,
form: emptyForm(),
rules: {
configName: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
channelId: [{ required: true, message: '请选择通知渠道', trigger: 'change' }],
wxTemplateId:[{ required: true, message: '请输入微信模板ID', trigger: 'blur' }],
pushInterval:[{ required: true, message: '请填写推送间隔', trigger: 'blur' }]
deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }],
templateId: [{ required: true, message: '请选择通知模板', trigger: 'change' }],
pushInterval: [{ required: true, message: '请填写推送间隔', trigger: 'blur' }]
},
productOptions: [],
channelOptions: [],
deviceOptions: [],
deviceSearchLoading: false,
templateOptions: [],
thingsModelOptions: [],
userOptions: []
};
},
created() {
this.getList();
this.loadProductOptions();
this.loadChannelOptions();
this.loadTemplateOptions();
this.loadUserOptions();
},
methods: {
@ -253,21 +272,54 @@ export default {
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
resetQuery() {
this.resetForm('queryForm');
this.queryParams = { pageNum: 1, pageSize: 10, configName: '', productId: null, status: null };
this.queryParams = { pageNum: 1, pageSize: 10, configName: '', serialNumber: '', status: null };
this.getList();
},
loadProductOptions() {
listProduct({ pageNum: 1, pageSize: 999 }).then(res => {
this.productOptions = res.rows || [];
remoteSearchDevice(keyword) {
if (!keyword) return;
this.deviceSearchLoading = true;
const baseQuery = { pageNum: 1, pageSize: 30 };
// 关键字同时匹配 deviceName 和 serialNumber
const byName = listDevice({ ...baseQuery, deviceType: null, deviceName: keyword });
const bySn = listDevice({ ...baseQuery, deviceType: null, serialNumber: keyword });
Promise.all([byName, bySn]).then(([r1, r2]) => {
const rows1 = r1.rows || [];
const rows2 = r2.rows || [];
// 合并去重(以 deviceId 为 key),只保留直连(1)和网关(2)设备
const map = new Map();
[...rows1, ...rows2].forEach(d => {
if ((d.deviceType === 1 || d.deviceType === 2) && !map.has(d.deviceId)) {
map.set(d.deviceId, d);
}
});
this.deviceOptions = Array.from(map.values());
}).finally(() => { this.deviceSearchLoading = false; });
},
deviceLabel(d) {
const typeStr = d.deviceType === 2 ? '[网关]' : '[直连]';
return typeStr + ' ' + d.deviceName + ' (' + d.serialNumber + ')';
},
onDeviceChange(deviceId) {
const device = this.deviceOptions.find(d => d.deviceId === deviceId);
if (!device) return;
this.form.productId = device.productId;
this.form.serialNumber = device.serialNumber;
this.form.items = [];
this.thingsModelOptions = [];
listModel({ productId: device.productId, type: 1, pageNum: 1, pageSize: 500 }).then(res => {
this.thingsModelOptions = res.rows || [];
});
},
loadChannelOptions() {
// 只加载微信公众号渠道
listChannel({ channelType: 'wechat', provider: 'public_account', pageNum: 1, pageSize: 100 }).then(res => {
this.channelOptions = res.rows || [];
loadTemplateOptions() {
listTemplate({ provider: 'public_account', pageNum: 1, pageSize: 200 }).then(res => {
this.templateOptions = res.rows || [];
}).catch(() => {
listChannel({ pageNum: 1, pageSize: 100 }).then(res => {
this.channelOptions = (res.rows || []).filter(c => c.provider === 'public_account');
listTemplate({ pageNum: 1, pageSize: 200 }).then(res => {
this.templateOptions = (res.rows || []).filter(t => t.provider === 'public_account');
});
});
},
@ -276,14 +328,7 @@ export default {
this.userOptions = res.rows || [];
});
},
onProductChange(productId) {
this.thingsModelOptions = [];
this.form.items = [];
if (!productId) return;
listModel({ productId, type: 1, pageNum: 1, pageSize: 500 }).then(res => {
this.thingsModelOptions = res.rows || [];
});
},
onModelItemChange(identifier, row) {
const model = this.thingsModelOptions.find(m => m.identifier === identifier);
if (model) {
@ -304,8 +349,10 @@ export default {
removeModelItem(index) {
this.form.items.splice(index, 1);
},
handleAdd() {
this.form = emptyForm();
this.deviceOptions = [];
this.thingsModelOptions = [];
this.dialogTitle = '新增推送配置';
this.dialogOpen = true;
@ -319,6 +366,14 @@ export default {
userIds: data.userIds || [],
items: data.items || []
};
if (data.deviceId) {
this.deviceOptions = [{
deviceId: data.deviceId,
deviceName: data.deviceName || data.serialNumber,
serialNumber: data.serialNumber || '',
deviceType: data.deviceType || 1
}];
}
if (data.productId) {
listModel({ productId: data.productId, type: 1, pageNum: 1, pageSize: 500 }).then(r => {
this.thingsModelOptions = r.rows || [];
@ -328,6 +383,7 @@ export default {
this.dialogOpen = true;
});
},
handleStatusChange(row) {
const text = row.status === 1 ? '启用' : '停用';
this.$confirm(`确认${text}【${row.configName}】?`, '提示', { type: 'warning' }).then(() => {
@ -352,6 +408,7 @@ export default {
});
});
},
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) return;
@ -359,8 +416,7 @@ export default {
this.$message.warning('请至少添加一个监测物模型');
return;
}
const hasEmptyIdentifier = this.form.items.some(i => !i.identifier);
if (hasEmptyIdentifier) {
if (this.form.items.some(i => !i.identifier)) {
this.$message.warning('存在未选择物模型的行,请完善');
return;
}
@ -381,4 +437,9 @@ export default {
.iot-push-config .el-divider {
margin: 15px 0 10px;
}
.form-tip {
color: #909399;
font-size: 12px;
line-height: 1.4;
}
</style>