feat(K): 规则保存时校验动作参数必须用物模型 code, 禁止 dpId
背景: 规则动作 params 的 key 会被原样当作涂鸦 code 下发(dp_id 从不参与下发), 历史测试数据里出现过 switch_1 与 1 混用, 导致下发失败或指令相互矛盾, 且保存时无任何校验。 后端: 新增 RuleActionValidator, createRule/updateRule 前校验动作参数 key 必须命中 product_property.code; 命中 dp_id 时直接提示应改用的 code; 同步校验值域(bool 取值、数值 min/max 区间、enum 候选值); 目标设备不存在、无产品物模型等场景给出明确业务错误。 前端: 后台规则表单补齐执行动作编辑(JSON), 提交前本地解析校验, 原本表单不提交 actions 导致新建规则必然失败。 Android: 物模型不可用时的回退路径改为优先使用涂鸦 code(反射读取), 避免提交 dpId 被后端拒绝。 V24: scene_run 表不存在时容错跳过, 便于在未执行 V16 的旧结构库上整段执行。
This commit is contained in:
parent
91d50eda91
commit
4c2d92571e
@ -428,7 +428,10 @@ public class SceneDetailActivity extends AppCompatActivity {
|
|||||||
/** 将涂鸦 SchemaBean 转换为 ControlItem(仅用于回退路径,字段尽量从 schemaType 推断) */
|
/** 将涂鸦 SchemaBean 转换为 ControlItem(仅用于回退路径,字段尽量从 schemaType 推断) */
|
||||||
private ControlSchema.ControlItem toControlItem(String dpId, SchemaBean bean) {
|
private ControlSchema.ControlItem toControlItem(String dpId, SchemaBean bean) {
|
||||||
ControlSchema.ControlItem item = new ControlSchema.ControlItem();
|
ControlSchema.ControlItem item = new ControlSchema.ControlItem();
|
||||||
item.code = dpId;
|
// 规则/场景动作参数最终由后端转涂鸦云命令下发,必须使用平台无关的功能点编码(code),
|
||||||
|
// 后端会拒绝 dpId(并提示改用对应 code)。回退路径同样优先取 code,取不到才退回 dpId。
|
||||||
|
String schemaCode = resolveSchemaCode(bean);
|
||||||
|
item.code = schemaCode != null ? schemaCode : dpId;
|
||||||
item.dpId = dpId;
|
item.dpId = dpId;
|
||||||
item.name = bean.getName() != null ? String.valueOf(bean.getName()) : dpId;
|
item.name = bean.getName() != null ? String.valueOf(bean.getName()) : dpId;
|
||||||
item.writable = isWritable(bean);
|
item.writable = isWritable(bean);
|
||||||
@ -440,6 +443,31 @@ public class SceneDetailActivity extends AppCompatActivity {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取涂鸦 SchemaBean 的物模型 code(如 switch_1)。
|
||||||
|
* 不同涂鸦 SDK 版本的字段名可能不同,用反射兜底读取,避免编译期强耦合。
|
||||||
|
*
|
||||||
|
* @return code,取不到返回 null
|
||||||
|
*/
|
||||||
|
private String resolveSchemaCode(SchemaBean bean) {
|
||||||
|
if (bean == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.lang.reflect.Method m = bean.getClass().getMethod("getCode");
|
||||||
|
Object v = m.invoke(bean);
|
||||||
|
if (v != null) {
|
||||||
|
String s = String.valueOf(v).trim();
|
||||||
|
if (!s.isEmpty()) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 读不到 code 时退回 dpId
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断 schema 是否可写(mode 包含 w),回退路径用 */
|
/** 判断 schema 是否可写(mode 包含 w),回退路径用 */
|
||||||
private boolean isWritable(SchemaBean s) {
|
private boolean isWritable(SchemaBean s) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -25,4 +25,18 @@ public interface ProductSpecMapper {
|
|||||||
@Select("SELECT code, name, type, semantic, grp, access, dp_id FROM product_property "
|
@Select("SELECT code, name, type, semantic, grp, access, dp_id FROM product_property "
|
||||||
+ "WHERE product_id = #{productId} AND deleted = 0 ORDER BY sort ASC")
|
+ "WHERE product_id = #{productId} AND deleted = 0 ORDER BY sort ASC")
|
||||||
List<Map<String, Object>> selectProperties(@Param("productId") Long productId);
|
List<Map<String, Object>> selectProperties(@Param("productId") Long productId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询产品的功能点定义(含值域信息),用于校验规则动作参数。
|
||||||
|
*
|
||||||
|
* <p>下发侧把动作参数的 key 当作涂鸦 code 直接下发(见 DeviceCommandListener),
|
||||||
|
* 因此这里以 code 为权威,同时带出 dp_id 以便识别"误用 dpId"的提交。</p>
|
||||||
|
*
|
||||||
|
* @param productId 产品ID
|
||||||
|
* @return 每行含 code / name / type / access / dp_id / min_value / max_value / enum_list
|
||||||
|
*/
|
||||||
|
@Select("SELECT code, name, type, access, dp_id, "
|
||||||
|
+ "`min` AS min_value, `max` AS max_value, enum_list "
|
||||||
|
+ "FROM product_property WHERE product_id = #{productId} AND deleted = 0 ORDER BY sort ASC")
|
||||||
|
List<Map<String, Object>> selectPropertySpecs(@Param("productId") Long productId);
|
||||||
}
|
}
|
||||||
@ -16,6 +16,7 @@ import com.iot.rule.entity.SceneEntity;
|
|||||||
import com.iot.rule.mapper.RuleMapper;
|
import com.iot.rule.mapper.RuleMapper;
|
||||||
import com.iot.rule.mapper.SceneMapper;
|
import com.iot.rule.mapper.SceneMapper;
|
||||||
import com.iot.rule.service.RuleEngineService;
|
import com.iot.rule.service.RuleEngineService;
|
||||||
|
import com.iot.rule.validator.RuleActionValidator;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -44,6 +45,7 @@ public class RuleEngineServiceImpl implements RuleEngineService {
|
|||||||
private final RuleMapper ruleMapper;
|
private final RuleMapper ruleMapper;
|
||||||
private final SceneMapper sceneMapper;
|
private final SceneMapper sceneMapper;
|
||||||
private final DeviceCommandPublisher deviceCommandPublisher;
|
private final DeviceCommandPublisher deviceCommandPublisher;
|
||||||
|
private final RuleActionValidator ruleActionValidator;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
|
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
|
||||||
|
|
||||||
@ -52,6 +54,9 @@ public class RuleEngineServiceImpl implements RuleEngineService {
|
|||||||
public Long createRule(RuleEntity rule) {
|
public Long createRule(RuleEntity rule) {
|
||||||
log.info("创建规则: ruleName={}", rule.getRuleName());
|
log.info("创建规则: ruleName={}", rule.getRuleName());
|
||||||
|
|
||||||
|
// 校验动作参数:指令码必须是物模型 code(不是 dpId),取值须落在物模型值域内
|
||||||
|
ruleActionValidator.validate(rule, true);
|
||||||
|
|
||||||
// 检查规则编码是否已存在
|
// 检查规则编码是否已存在
|
||||||
RuleEntity existingRule = ruleMapper.selectOne(
|
RuleEntity existingRule = ruleMapper.selectOne(
|
||||||
new LambdaQueryWrapper<RuleEntity>()
|
new LambdaQueryWrapper<RuleEntity>()
|
||||||
@ -81,6 +86,9 @@ public class RuleEngineServiceImpl implements RuleEngineService {
|
|||||||
throw new BusinessException(ResultCode.RULE_NOT_FOUND);
|
throw new BusinessException(ResultCode.RULE_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新时动作可能未提交(部分更新),此时不校验;提交了才校验
|
||||||
|
ruleActionValidator.validate(rule, false);
|
||||||
|
|
||||||
ruleMapper.updateById(rule);
|
ruleMapper.updateById(rule);
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
|
|||||||
@ -0,0 +1,333 @@
|
|||||||
|
package com.iot.rule.validator;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.iot.common.exception.BusinessException;
|
||||||
|
import com.iot.rule.entity.AiDeviceView;
|
||||||
|
import com.iot.rule.entity.RuleEntity;
|
||||||
|
import com.iot.rule.mapper.AiDeviceViewMapper;
|
||||||
|
import com.iot.rule.mapper.ProductSpecMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则动作参数校验器。
|
||||||
|
*
|
||||||
|
* <p><b>为什么需要它:</b>规则动作的 params key 会被原样当作涂鸦功能点 code 下发
|
||||||
|
* (见 iot-device 的 DeviceCommandListener:params 的每个 entry 直接转成 {"code":key,"value":value}),
|
||||||
|
* 而 dp_id 从不参与下发。历史上测试阶段出现过同一条规则里
|
||||||
|
* {@code switch_1} 与 {@code 1}(dpId)混用的情况,导致下发失败或指令相互矛盾。
|
||||||
|
* 本校验器在<b>保存时</b>把这类问题挡在门外,并给出可直接照改的提示。</p>
|
||||||
|
*
|
||||||
|
* <p><b>校验内容:</b></p>
|
||||||
|
* <ol>
|
||||||
|
* <li>动作不能为空、格式合法、目标设备存在</li>
|
||||||
|
* <li>动作参数的 key 必须是目标设备物模型中的 <b>code</b>;若命中的是 dp_id,
|
||||||
|
* 报错并直接给出应改用的 code</li>
|
||||||
|
* <li>动作参数的值符合物模型值域:bool → true/false/0/1;数值 → min/max 区间;
|
||||||
|
* enum → 枚举候选值</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>无标准产品物模型的设备(如本地虚拟设备)跳过指令码校验,不阻断保存。</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RuleActionValidator {
|
||||||
|
|
||||||
|
private final AiDeviceViewMapper deviceViewMapper;
|
||||||
|
private final ProductSpecMapper productSpecMapper;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验规则动作。
|
||||||
|
*
|
||||||
|
* @param rule 规则
|
||||||
|
* @param requireActions 是否要求动作必填(新建时必填;更新时为空表示未提交动作,不阻断)
|
||||||
|
*/
|
||||||
|
public void validate(RuleEntity rule, boolean requireActions) {
|
||||||
|
if (rule == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonNode actions = rule.getActions();
|
||||||
|
if (actions == null || actions.isNull()) {
|
||||||
|
if (requireActions) {
|
||||||
|
throw new BusinessException("规则动作不能为空,示例:"
|
||||||
|
+ "[{\"deviceId\":\"设备ID\",\"params\":{\"switch_1\":true}}]");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JsonNode> actionNodes = new ArrayList<>();
|
||||||
|
if (actions.isArray()) {
|
||||||
|
if (actions.isEmpty()) {
|
||||||
|
throw new BusinessException("规则动作不能为空");
|
||||||
|
}
|
||||||
|
actions.forEach(actionNodes::add);
|
||||||
|
} else {
|
||||||
|
actionNodes.add(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode action : actionNodes) {
|
||||||
|
validateAction(rule, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验单个动作
|
||||||
|
*/
|
||||||
|
private void validateAction(RuleEntity rule, JsonNode action) {
|
||||||
|
if (action == null || !action.isObject()) {
|
||||||
|
throw new BusinessException("规则动作格式非法:应为对象或对象数组");
|
||||||
|
}
|
||||||
|
|
||||||
|
String commandType = text(action, "commandType", "set");
|
||||||
|
String actionType = text(action, "type", null);
|
||||||
|
if ("tuya_scene".equals(actionType)) {
|
||||||
|
commandType = "scene";
|
||||||
|
}
|
||||||
|
// 场景类动作不携带功能点参数,无需物模型校验
|
||||||
|
if ("scene".equals(commandType) || "execute_scene".equals(actionType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode params = action.get("params");
|
||||||
|
if (params == null || !params.isObject() || params.isEmpty()) {
|
||||||
|
throw new BusinessException("规则动作缺少指令参数(params),示例:"
|
||||||
|
+ "{\"deviceId\":\"设备ID\",\"params\":{\"switch_1\":true}}");
|
||||||
|
}
|
||||||
|
|
||||||
|
String deviceId = text(action, "deviceId", null);
|
||||||
|
if (deviceId == null || deviceId.isEmpty()) {
|
||||||
|
deviceId = rule.getTriggerDeviceId();
|
||||||
|
}
|
||||||
|
if (deviceId == null || deviceId.isEmpty()) {
|
||||||
|
throw new BusinessException("规则动作缺少目标设备(deviceId)");
|
||||||
|
}
|
||||||
|
|
||||||
|
AiDeviceView device = deviceViewMapper.selectOne(new LambdaQueryWrapper<AiDeviceView>()
|
||||||
|
.eq(AiDeviceView::getDeviceId, deviceId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (device == null) {
|
||||||
|
throw new BusinessException("规则动作的目标设备不存在:" + deviceId);
|
||||||
|
}
|
||||||
|
if (device.getProductId() == null) {
|
||||||
|
// 无标准产品物模型(本地虚拟设备等),跳过指令码校验
|
||||||
|
log.debug("设备无产品物模型,跳过动作指令码校验: deviceId={}", deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> specs;
|
||||||
|
try {
|
||||||
|
specs = productSpecMapper.selectPropertySpecs(device.getProductId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("读取产品物模型失败(跳过动作指令码校验): productId={}, {}", device.getProductId(), e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (specs == null || specs.isEmpty()) {
|
||||||
|
log.warn("产品物模型为空,跳过动作指令码校验: productId={}", device.getProductId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, Object>> byCode = new LinkedHashMap<>();
|
||||||
|
Map<String, String> dpIdToCode = new HashMap<>();
|
||||||
|
for (Map<String, Object> spec : specs) {
|
||||||
|
String code = str(spec.get("code"));
|
||||||
|
if (code.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byCode.put(code, spec);
|
||||||
|
String dpId = str(spec.get("dp_id"));
|
||||||
|
// dp_id 为 0 表示未同步真实 DP 号,不作为 dpId 提示依据
|
||||||
|
if (!dpId.isEmpty() && !"0".equals(dpId)) {
|
||||||
|
dpIdToCode.putIfAbsent(dpId, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> fields = params.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> entry = fields.next();
|
||||||
|
String key = entry.getKey();
|
||||||
|
Map<String, Object> spec = byCode.get(key);
|
||||||
|
if (spec == null) {
|
||||||
|
String mappedCode = dpIdToCode.get(key);
|
||||||
|
if (mappedCode != null) {
|
||||||
|
throw new BusinessException(String.format(
|
||||||
|
"动作参数 \"%s\" 使用了涂鸦 dpId;规则指令必须使用物模型功能点编码,请改为 \"%s\"",
|
||||||
|
key, mappedCode));
|
||||||
|
}
|
||||||
|
throw new BusinessException(String.format(
|
||||||
|
"动作参数 \"%s\" 不在设备 %s 的物模型中;可用指令:%s",
|
||||||
|
key, deviceId, String.join("、", byCode.keySet())));
|
||||||
|
}
|
||||||
|
validateValue(spec, key, entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验动作参数取值是否落在物模型值域内
|
||||||
|
*/
|
||||||
|
private void validateValue(Map<String, Object> spec, String key, JsonNode value) {
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 的值不能为空");
|
||||||
|
}
|
||||||
|
String type = str(spec.get("type")).toLowerCase();
|
||||||
|
switch (type) {
|
||||||
|
case "bool": {
|
||||||
|
if (value.isBoolean()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.isIntegralNumber()) {
|
||||||
|
int v = value.asInt();
|
||||||
|
if (v == 0 || v == 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 为开关量(bool),取值只能是 true/false/0/1,当前值:"
|
||||||
|
+ value.asText());
|
||||||
|
}
|
||||||
|
if (value.isTextual()) {
|
||||||
|
String v = value.asText().trim();
|
||||||
|
if ("true".equalsIgnoreCase(v) || "false".equalsIgnoreCase(v)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 为开关量(bool),取值只能是 true/false/0/1,当前值:"
|
||||||
|
+ value.asText());
|
||||||
|
}
|
||||||
|
case "int":
|
||||||
|
case "integer":
|
||||||
|
case "float":
|
||||||
|
case "double":
|
||||||
|
case "number": {
|
||||||
|
double d = numericValue(key, value);
|
||||||
|
Double min = numeric(spec.get("min_value"));
|
||||||
|
Double max = numeric(spec.get("max_value"));
|
||||||
|
if (min != null && d < min) {
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 的值 " + trimNumber(d)
|
||||||
|
+ " 小于允许的最小值 " + trimNumber(min));
|
||||||
|
}
|
||||||
|
if (max != null && d > max) {
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 的值 " + trimNumber(d)
|
||||||
|
+ " 大于允许的最大值 " + trimNumber(max));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "enum": {
|
||||||
|
String raw = str(spec.get("enum_list"));
|
||||||
|
if (raw.isEmpty() || "null".equalsIgnoreCase(raw)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> allowed = parseEnumValues(raw);
|
||||||
|
if (allowed.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String v = value.isTextual() ? value.asText().trim() : value.asText();
|
||||||
|
for (String candidate : allowed) {
|
||||||
|
if (candidate.equals(v)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 的值 \"" + v
|
||||||
|
+ "\" 不在枚举范围 [" + String.join(",", allowed) + "]");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// 未识别的类型不做值域限制,避免误伤
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析枚举候选值,兼容 ["a","b"] 与 [{"code":"a"}] 两种写法
|
||||||
|
*/
|
||||||
|
private List<String> parseEnumValues(String raw) {
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(raw);
|
||||||
|
if (!node.isArray()) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
for (JsonNode item : node) {
|
||||||
|
if (item.isTextual()) {
|
||||||
|
values.add(item.asText());
|
||||||
|
} else if (item.isObject()) {
|
||||||
|
JsonNode code = item.get("code");
|
||||||
|
if (code != null && code.isTextual()) {
|
||||||
|
values.add(code.asText());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JsonNode val = item.get("value");
|
||||||
|
if (val != null) {
|
||||||
|
values.add(val.asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("枚举值解析失败,忽略值域校验: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取数值型参数值,文本数字也允许
|
||||||
|
*/
|
||||||
|
private double numericValue(String key, JsonNode value) {
|
||||||
|
if (value.isNumber()) {
|
||||||
|
return value.asDouble();
|
||||||
|
}
|
||||||
|
if (value.isTextual()) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(value.asText().trim());
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// 落到下方统一报错
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BusinessException("动作参数 \"" + key + "\" 需要数值,当前值:" + value.asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double numeric(Object raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String s = str(raw);
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(s);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimNumber(double d) {
|
||||||
|
if (d == Math.floor(d) && !Double.isInfinite(d)) {
|
||||||
|
return String.valueOf((long) d);
|
||||||
|
}
|
||||||
|
return String.valueOf(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String text(JsonNode node, String field, String defaultValue) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
String text = value.asText();
|
||||||
|
return text == null || text.isEmpty() ? defaultValue : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String str(Object raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = String.valueOf(raw);
|
||||||
|
return s == null ? "" : s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
package com.iot.rule.validator;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.iot.common.exception.BusinessException;
|
||||||
|
import com.iot.rule.entity.AiDeviceView;
|
||||||
|
import com.iot.rule.entity.RuleEntity;
|
||||||
|
import com.iot.rule.mapper.AiDeviceViewMapper;
|
||||||
|
import com.iot.rule.mapper.ProductSpecMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则动作参数校验器单测
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("规则动作参数校验")
|
||||||
|
class RuleActionValidatorTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AiDeviceViewMapper deviceViewMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ProductSpecMapper productSpecMapper;
|
||||||
|
|
||||||
|
private RuleActionValidator validator;
|
||||||
|
|
||||||
|
private static final String DEVICE_ID = "6c938236ce5d1c5e86abar";
|
||||||
|
private static final Long PRODUCT_ID = 2101267306509938690L;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
validator = new RuleActionValidator(deviceViewMapper, productSpecMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mockDevice() {
|
||||||
|
AiDeviceView device = new AiDeviceView();
|
||||||
|
device.setDeviceId(DEVICE_ID);
|
||||||
|
device.setProductId(PRODUCT_ID);
|
||||||
|
when(deviceViewMapper.selectOne(any())).thenReturn(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mockSpecs() {
|
||||||
|
List<Map<String, Object>> specs = new ArrayList<>();
|
||||||
|
specs.add(spec("switch_1", "bool", "1", null, null, null));
|
||||||
|
specs.add(spec("mode", "enum", "2", null, null, "[\"warm\",\"auto\",\"cold\"]"));
|
||||||
|
specs.add(spec("brightness", "int", "3", "0", "100", null));
|
||||||
|
when(productSpecMapper.selectPropertySpecs(eq(PRODUCT_ID))).thenReturn(specs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> spec(String code, String type, String dpId,
|
||||||
|
Object min, Object max, String enumList) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("code", code);
|
||||||
|
map.put("type", type);
|
||||||
|
map.put("dp_id", dpId);
|
||||||
|
map.put("min_value", min);
|
||||||
|
map.put("max_value", max);
|
||||||
|
map.put("enum_list", enumList);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuleEntity rule(String actionsJson) throws Exception {
|
||||||
|
RuleEntity rule = new RuleEntity();
|
||||||
|
rule.setRuleName("测试规则");
|
||||||
|
rule.setRuleCode("rule_test_001");
|
||||||
|
rule.setTriggerDeviceId(DEVICE_ID);
|
||||||
|
JsonNode actions = MAPPER.readTree(actionsJson);
|
||||||
|
rule.setActions(actions);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("动作参数使用 dpId 时被拒绝并提示应改用的 code")
|
||||||
|
void shouldRejectDpIdParam() throws Exception {
|
||||||
|
mockDevice();
|
||||||
|
mockSpecs();
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"1\":true}}]");
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("dpId"), ex.getMessage());
|
||||||
|
assertTrue(ex.getMessage().contains("switch_1"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("动作参数不在物模型中被拒绝并列出可用指令")
|
||||||
|
void shouldRejectUnknownParam() throws Exception {
|
||||||
|
mockDevice();
|
||||||
|
mockSpecs();
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"not_exist\":true}}]");
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("不在设备"), ex.getMessage());
|
||||||
|
assertTrue(ex.getMessage().contains("switch_1"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("使用物模型 code 且取值合法时通过校验")
|
||||||
|
void shouldPassWithValidCodeAndValue() throws Exception {
|
||||||
|
mockDevice();
|
||||||
|
mockSpecs();
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"switch_1\":true}}]");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> validator.validate(rule, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("bool 取值非法时被拒绝")
|
||||||
|
void shouldRejectInvalidBoolValue() throws Exception {
|
||||||
|
mockDevice();
|
||||||
|
mockSpecs();
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"switch_1\":\"yes\"}}]");
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("bool"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("枚举取值超出候选值时被拒绝")
|
||||||
|
void shouldRejectInvalidEnumValue() throws Exception {
|
||||||
|
mockDevice();
|
||||||
|
mockSpecs();
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"mode\":\"hot\"}}]");
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("枚举范围"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("数值超出物模型上下限时被拒绝")
|
||||||
|
void shouldRejectOutOfRangeValue() throws Exception {
|
||||||
|
mockDevice();
|
||||||
|
mockSpecs();
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"brightness\":200}}]");
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("最大值"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("目标设备不存在时被拒绝")
|
||||||
|
void shouldRejectUnknownDevice() throws Exception {
|
||||||
|
when(deviceViewMapper.selectOne(any())).thenReturn(null);
|
||||||
|
RuleEntity rule = rule("[{\"deviceId\":\"" + DEVICE_ID + "\",\"params\":{\"switch_1\":true}}]");
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("目标设备不存在"), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("场景类动作不校验功能点参数")
|
||||||
|
void shouldSkipSceneAction() throws Exception {
|
||||||
|
RuleEntity rule = rule("[{\"commandType\":\"scene\",\"sceneId\":\"2091721760835940353\"}]");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> validator.validate(rule, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("更新时未提交动作不阻断(部分更新)")
|
||||||
|
void shouldAllowEmptyActionsOnUpdate() {
|
||||||
|
RuleEntity rule = new RuleEntity();
|
||||||
|
rule.setRuleName("测试规则");
|
||||||
|
rule.setTriggerDeviceId(DEVICE_ID);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> validator.validate(rule, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("新建时动作为空被拒绝")
|
||||||
|
void shouldRejectEmptyActionsOnCreate() {
|
||||||
|
RuleEntity rule = new RuleEntity();
|
||||||
|
rule.setRuleName("测试规则");
|
||||||
|
rule.setTriggerDeviceId(DEVICE_ID);
|
||||||
|
|
||||||
|
BusinessException ex = assertThrows(BusinessException.class, () -> validator.validate(rule, true));
|
||||||
|
assertTrue(ex.getMessage().contains("动作不能为空"), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,5 +47,14 @@ DELETE FROM linkage_rule WHERE deleted = 0;
|
|||||||
DELETE FROM scene WHERE deleted = 0;
|
DELETE FROM scene WHERE deleted = 0;
|
||||||
|
|
||||||
-- ---------- 5) 清理场景执行记录 ----------
|
-- ---------- 5) 清理场景执行记录 ----------
|
||||||
DELETE FROM scene_run;
|
-- scene_run 表在部分环境(未执行 V16 的库)不存在,用动态 SQL 做存在性判断后再清理,
|
||||||
|
-- 保证迁移在"旧结构库"上也能整段跑完而不中断。
|
||||||
|
SET @has_scene_run = (SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'scene_run');
|
||||||
|
SET @s = IF(@has_scene_run > 0,
|
||||||
|
'DELETE FROM scene_run',
|
||||||
|
'SELECT ''scene_run table not found, skipped'' AS info');
|
||||||
|
PREPARE stmt FROM @s;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
|||||||
@ -85,6 +85,18 @@
|
|||||||
placeholder="例如: value > 30"
|
placeholder="例如: value > 30"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="执行动作" prop="actionsText">
|
||||||
|
<el-input
|
||||||
|
v-model="form.actionsText"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder='JSON 数组,params 的 key 必须是物模型功能点编码(code),不能用涂鸦数字 dpId。例如: [{"deviceId":"设备ID","params":{"switch_1":true}}]'
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
动作参数 key 只能是物模型 code(如 switch_1 / mode / brightness),
|
||||||
|
写成 dpId(如 1 / 2)保存时会被拒绝;取值还需符合物模型值域。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="优先级" prop="priority">
|
<el-form-item label="优先级" prop="priority">
|
||||||
<el-input-number v-model="form.priority" :min="1" :max="100" />
|
<el-input-number v-model="form.priority" :min="1" :max="100" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -133,6 +145,7 @@ const form = reactive({
|
|||||||
ruleCode: '',
|
ruleCode: '',
|
||||||
triggerDeviceId: '',
|
triggerDeviceId: '',
|
||||||
conditionExpression: '',
|
conditionExpression: '',
|
||||||
|
actionsText: '',
|
||||||
priority: 10,
|
priority: 10,
|
||||||
enabled: true
|
enabled: true
|
||||||
})
|
})
|
||||||
@ -149,9 +162,25 @@ const formRules: FormRules = {
|
|||||||
],
|
],
|
||||||
conditionExpression: [
|
conditionExpression: [
|
||||||
{ required: true, message: '请输入条件表达式', trigger: 'blur' }
|
{ required: true, message: '请输入条件表达式', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
actionsText: [
|
||||||
|
{ required: true, message: '请输入执行动作', trigger: 'blur' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把后端返回的 actions(对象或字符串)格式化为可编辑的 JSON 文本 */
|
||||||
|
const formatActions = (actions: any): string => {
|
||||||
|
if (!actions) return ''
|
||||||
|
if (typeof actions === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(actions), null, 2)
|
||||||
|
} catch {
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(actions, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -188,6 +217,7 @@ const handleAdd = () => {
|
|||||||
ruleCode: '',
|
ruleCode: '',
|
||||||
triggerDeviceId: '',
|
triggerDeviceId: '',
|
||||||
conditionExpression: '',
|
conditionExpression: '',
|
||||||
|
actionsText: '',
|
||||||
priority: 10,
|
priority: 10,
|
||||||
enabled: true
|
enabled: true
|
||||||
})
|
})
|
||||||
@ -202,6 +232,7 @@ const handleEdit = (row: any) => {
|
|||||||
ruleCode: row.ruleCode,
|
ruleCode: row.ruleCode,
|
||||||
triggerDeviceId: row.triggerDeviceId,
|
triggerDeviceId: row.triggerDeviceId,
|
||||||
conditionExpression: row.conditionExpression,
|
conditionExpression: row.conditionExpression,
|
||||||
|
actionsText: formatActions(row.actions),
|
||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
enabled: row.enabled
|
enabled: row.enabled
|
||||||
})
|
})
|
||||||
@ -249,17 +280,46 @@ const handleExecute = async (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
// 动作 JSON 先行校验,避免把格式错误提交到后端
|
||||||
|
let actions: any = undefined
|
||||||
|
const actionsText = form.actionsText.trim()
|
||||||
|
if (actionsText) {
|
||||||
|
try {
|
||||||
|
actions = JSON.parse(actionsText)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('执行动作不是合法的 JSON 格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (form.id === undefined && actions === undefined) {
|
||||||
|
ElMessage.error('请填写执行动作')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
ruleName: form.ruleName,
|
||||||
|
ruleCode: form.ruleCode,
|
||||||
|
triggerDeviceId: form.triggerDeviceId,
|
||||||
|
conditionExpression: form.conditionExpression,
|
||||||
|
priority: form.priority,
|
||||||
|
enabled: form.enabled
|
||||||
|
}
|
||||||
|
if (actions !== undefined) {
|
||||||
|
payload.actions = actions
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
await updateRule(form.id, form)
|
await updateRule(form.id, payload)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createRule(form)
|
await createRule(payload)
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('创建成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
loadData()
|
loadData()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 失败提示已由 axios 拦截器统一输出(后端会给出具体的指令码/值域错误)
|
||||||
console.error('提交失败:', error)
|
console.error('提交失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -274,5 +334,12 @@ loadData()
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user