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:
2995840376 2026-10-28 00:52:04 +08:00
parent 91d50eda91
commit 4c2d92571e
7 changed files with 659 additions and 4 deletions

View File

@ -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 {

View File

@ -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);
} }

View File

@ -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);
// 更新缓存 // 更新缓存

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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;

View File

@ -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>