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 推断) */
|
||||
private ControlSchema.ControlItem toControlItem(String dpId, SchemaBean bean) {
|
||||
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.name = bean.getName() != null ? String.valueOf(bean.getName()) : dpId;
|
||||
item.writable = isWritable(bean);
|
||||
@ -440,6 +443,31 @@ public class SceneDetailActivity extends AppCompatActivity {
|
||||
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),回退路径用 */
|
||||
private boolean isWritable(SchemaBean s) {
|
||||
try {
|
||||
|
||||
@ -25,4 +25,18 @@ public interface ProductSpecMapper {
|
||||
@Select("SELECT code, name, type, semantic, grp, access, dp_id FROM product_property "
|
||||
+ "WHERE product_id = #{productId} AND deleted = 0 ORDER BY sort ASC")
|
||||
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.SceneMapper;
|
||||
import com.iot.rule.service.RuleEngineService;
|
||||
import com.iot.rule.validator.RuleActionValidator;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -44,6 +45,7 @@ public class RuleEngineServiceImpl implements RuleEngineService {
|
||||
private final RuleMapper ruleMapper;
|
||||
private final SceneMapper sceneMapper;
|
||||
private final DeviceCommandPublisher deviceCommandPublisher;
|
||||
private final RuleActionValidator ruleActionValidator;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
|
||||
|
||||
@ -52,6 +54,9 @@ public class RuleEngineServiceImpl implements RuleEngineService {
|
||||
public Long createRule(RuleEntity rule) {
|
||||
log.info("创建规则: ruleName={}", rule.getRuleName());
|
||||
|
||||
// 校验动作参数:指令码必须是物模型 code(不是 dpId),取值须落在物模型值域内
|
||||
ruleActionValidator.validate(rule, true);
|
||||
|
||||
// 检查规则编码是否已存在
|
||||
RuleEntity existingRule = ruleMapper.selectOne(
|
||||
new LambdaQueryWrapper<RuleEntity>()
|
||||
@ -81,6 +86,9 @@ public class RuleEngineServiceImpl implements RuleEngineService {
|
||||
throw new BusinessException(ResultCode.RULE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 更新时动作可能未提交(部分更新),此时不校验;提交了才校验
|
||||
ruleActionValidator.validate(rule, false);
|
||||
|
||||
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;
|
||||
|
||||
-- ---------- 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"
|
||||
/>
|
||||
</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-input-number v-model="form.priority" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
@ -133,6 +145,7 @@ const form = reactive({
|
||||
ruleCode: '',
|
||||
triggerDeviceId: '',
|
||||
conditionExpression: '',
|
||||
actionsText: '',
|
||||
priority: 10,
|
||||
enabled: true
|
||||
})
|
||||
@ -149,9 +162,25 @@ const formRules: FormRules = {
|
||||
],
|
||||
conditionExpression: [
|
||||
{ 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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -188,6 +217,7 @@ const handleAdd = () => {
|
||||
ruleCode: '',
|
||||
triggerDeviceId: '',
|
||||
conditionExpression: '',
|
||||
actionsText: '',
|
||||
priority: 10,
|
||||
enabled: true
|
||||
})
|
||||
@ -202,6 +232,7 @@ const handleEdit = (row: any) => {
|
||||
ruleCode: row.ruleCode,
|
||||
triggerDeviceId: row.triggerDeviceId,
|
||||
conditionExpression: row.conditionExpression,
|
||||
actionsText: formatActions(row.actions),
|
||||
priority: row.priority,
|
||||
enabled: row.enabled
|
||||
})
|
||||
@ -249,17 +280,46 @@ const handleExecute = async (row: any) => {
|
||||
}
|
||||
|
||||
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 {
|
||||
if (form.id) {
|
||||
await updateRule(form.id, form)
|
||||
await updateRule(form.id, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createRule(form)
|
||||
await createRule(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
// 失败提示已由 axios 拦截器统一输出(后端会给出具体的指令码/值域错误)
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
@ -274,5 +334,12 @@ loadData()
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user