diff --git a/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java b/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java index 357331d..5dde0d1 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java @@ -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 { diff --git a/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java b/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java index c5d82f0..c56c519 100644 --- a/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java +++ b/backend/iot-rule/src/main/java/com/iot/rule/mapper/ProductSpecMapper.java @@ -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> selectProperties(@Param("productId") Long productId); + + /** + * 查询产品的功能点定义(含值域信息),用于校验规则动作参数。 + * + *

下发侧把动作参数的 key 当作涂鸦 code 直接下发(见 DeviceCommandListener), + * 因此这里以 code 为权威,同时带出 dp_id 以便识别"误用 dpId"的提交。

+ * + * @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> selectPropertySpecs(@Param("productId") Long productId); } \ No newline at end of file diff --git a/backend/iot-rule/src/main/java/com/iot/rule/service/impl/RuleEngineServiceImpl.java b/backend/iot-rule/src/main/java/com/iot/rule/service/impl/RuleEngineServiceImpl.java index 1a84e0a..f52547d 100644 --- a/backend/iot-rule/src/main/java/com/iot/rule/service/impl/RuleEngineServiceImpl.java +++ b/backend/iot-rule/src/main/java/com/iot/rule/service/impl/RuleEngineServiceImpl.java @@ -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() @@ -81,6 +86,9 @@ public class RuleEngineServiceImpl implements RuleEngineService { throw new BusinessException(ResultCode.RULE_NOT_FOUND); } + // 更新时动作可能未提交(部分更新),此时不校验;提交了才校验 + ruleActionValidator.validate(rule, false); + ruleMapper.updateById(rule); // 更新缓存 diff --git a/backend/iot-rule/src/main/java/com/iot/rule/validator/RuleActionValidator.java b/backend/iot-rule/src/main/java/com/iot/rule/validator/RuleActionValidator.java new file mode 100644 index 0000000..3b16c81 --- /dev/null +++ b/backend/iot-rule/src/main/java/com/iot/rule/validator/RuleActionValidator.java @@ -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; + +/** + * 规则动作参数校验器。 + * + *

为什么需要它:规则动作的 params key 会被原样当作涂鸦功能点 code 下发 + * (见 iot-device 的 DeviceCommandListener:params 的每个 entry 直接转成 {"code":key,"value":value}), + * 而 dp_id 从不参与下发。历史上测试阶段出现过同一条规则里 + * {@code switch_1} 与 {@code 1}(dpId)混用的情况,导致下发失败或指令相互矛盾。 + * 本校验器在保存时把这类问题挡在门外,并给出可直接照改的提示。

+ * + *

校验内容:

+ *
    + *
  1. 动作不能为空、格式合法、目标设备存在
  2. + *
  3. 动作参数的 key 必须是目标设备物模型中的 code;若命中的是 dp_id, + * 报错并直接给出应改用的 code
  4. + *
  5. 动作参数的值符合物模型值域:bool → true/false/0/1;数值 → min/max 区间; + * enum → 枚举候选值
  6. + *
+ * + *

无标准产品物模型的设备(如本地虚拟设备)跳过指令码校验,不阻断保存。

+ */ +@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 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() + .eq(AiDeviceView::getDeviceId, deviceId) + .last("LIMIT 1")); + if (device == null) { + throw new BusinessException("规则动作的目标设备不存在:" + deviceId); + } + if (device.getProductId() == null) { + // 无标准产品物模型(本地虚拟设备等),跳过指令码校验 + log.debug("设备无产品物模型,跳过动作指令码校验: deviceId={}", deviceId); + return; + } + + List> 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> byCode = new LinkedHashMap<>(); + Map dpIdToCode = new HashMap<>(); + for (Map 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> fields = params.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String key = entry.getKey(); + Map 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 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 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 parseEnumValues(String raw) { + List 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(); + } +} diff --git a/backend/iot-rule/src/test/java/com/iot/rule/validator/RuleActionValidatorTest.java b/backend/iot-rule/src/test/java/com/iot/rule/validator/RuleActionValidatorTest.java new file mode 100644 index 0000000..3cf5c1f --- /dev/null +++ b/backend/iot-rule/src/test/java/com/iot/rule/validator/RuleActionValidatorTest.java @@ -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> 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 spec(String code, String type, String dpId, + Object min, Object max, String enumList) { + Map 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()); + } +} diff --git a/backend/sql/V24__cleanup_orphan_tuya_devices.sql b/backend/sql/V24__cleanup_orphan_tuya_devices.sql index 9ce48f4..1771bde 100644 --- a/backend/sql/V24__cleanup_orphan_tuya_devices.sql +++ b/backend/sql/V24__cleanup_orphan_tuya_devices.sql @@ -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; diff --git a/frontend/admin/src/views/Rule/RuleList.vue b/frontend/admin/src/views/Rule/RuleList.vue index 6fa1fc2..cadaf3f 100644 --- a/frontend/admin/src/views/Rule/RuleList.vue +++ b/frontend/admin/src/views/Rule/RuleList.vue @@ -85,6 +85,18 @@ placeholder="例如: value > 30" /> + + +
+ 动作参数 key 只能是物模型 code(如 switch_1 / mode / brightness), + 写成 dpId(如 1 / 2)保存时会被拒绝;取值还需符合物模型值域。 +
+
@@ -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 = { + 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; + } }