package xiaozhi.modules.parent.service; import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.RenException; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.parent.catalog.IdiomLessonCatalog; import xiaozhi.modules.parent.catalog.LessonClipIndex; import xiaozhi.modules.parent.catalog.ParentCatalog; import xiaozhi.modules.parent.catalog.ParentSceneCatalog; import xiaozhi.modules.parent.dao.ParentTaskDao; import xiaozhi.modules.parent.dto.ParentDeviceStatusDTO; import xiaozhi.modules.parent.dto.ParentImmediateCloseDTO; import xiaozhi.modules.parent.dto.ParentTaskDTO; import xiaozhi.modules.parent.dto.ParentTaskListDTO; import xiaozhi.modules.parent.dto.ParentTaskSaveDTO; import xiaozhi.modules.parent.entity.ParentTaskEntity; import xiaozhi.modules.parent.mqtt.ParentDevicePresence; import xiaozhi.modules.parent.mqtt.ParentMqttPublisher; import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.task.dao.TaskInstanceDao; import xiaozhi.modules.task.dto.LessonSyncDTO; import xiaozhi.modules.task.entity.TaskInstanceEntity; @Slf4j @Service @RequiredArgsConstructor public class ParentTaskService { private static final Set RUN_MODES = Set.of("immediate", "scheduled"); private static final Set ON_COMPLETE = Set.of("chat", "power_off"); private static final Set SCHEDULE_TYPES = Set.of("daily", "hourly", "interval"); private static final Pattern TIME = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d)?$"); private static final long STALE_RUNNING_MS = TimeUnit.HOURS.toMillis(48); private static final long OFFLINE_GRACE_MS = TimeUnit.MINUTES.toMillis(5); private static final TypeReference> MAP = new TypeReference<>() { }; private final ParentTaskDao parentTaskDao; private final DeviceService deviceService; private final AgentService agentService; private final ParentMqttPublisher mqttPublisher; private final ParentDevicePresence devicePresence; private final ObjectMapper objectMapper; private final SysParamsService sysParamsService; private final TaskInstanceDao taskInstanceDao; public ParentDeviceStatusDTO deviceStatus(Long userId, String macAddress, String agentId) { DeviceEntity device = requireDevice(userId, macAddress, agentId); ParentDeviceStatusDTO dto = new ParentDeviceStatusDTO(); dto.setMacAddress(ParentMqttPublisher.normalizeMac(device.getMacAddress())); dto.setOnline(devicePresence.isOnline(device.getMacAddress())); return dto; } public ParentTaskListDTO list(Long userId, String macAddress, String agentId, String runMode) { DeviceEntity device = requireDevice(userId, macAddress, agentId); String mac = ParentMqttPublisher.normalizeMac(device.getMacAddress()); expireStaleImmediate(mac, devicePresence.isOnlineCached(mac)); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ParentTaskEntity::getUserId, userId) .eq(ParentTaskEntity::getMacAddress, mac) .eq(ParentTaskEntity::getAgentId, device.getAgentId()) .eq(StringUtils.isNotBlank(runMode), ParentTaskEntity::getRunMode, runMode) .and(w -> w.eq(ParentTaskEntity::getRunMode, "scheduled") .or(q -> q.in(ParentTaskEntity::getStatus, "queued", "running"))) .orderByDesc(ParentTaskEntity::getUpdatedAt); List rows = parentTaskDao.selectList(wrapper); ParentTaskListDTO result = new ParentTaskListDTO(); result.setTotal(rows.size()); for (ParentTaskEntity row : rows) { result.getList().add(toDto(row)); } return result; } public ParentTaskDTO get(Long userId, String id) { return toDto(requireOwned(userId, id)); } @Transactional(rollbackFor = Exception.class) public ParentTaskDTO create(Long userId, ParentTaskSaveDTO body) { DeviceEntity device = requireDevice(userId, body.getMacAddress(), body.getAgentId()); devicePresence.requireOnline(device.getMacAddress()); String runMode = normalizeRunMode(body.getRunMode()); Map scene = requireSceneRow(body.getScene()); Map mapped = sceneAppTask(scene); Map params = compactParams(scene, body.getParams()); Map schedule = null; if ("scheduled".equals(runMode)) { schedule = requireSchedule(body.getSchedule()); int scheduled = countScheduled(device.getMacAddress()); if (scheduled >= ParentCatalog.SCHEDULED_SOFT_LIMIT) { throw new RenException("每台设备最多 " + ParentCatalog.SCHEDULED_SOFT_LIMIT + " 条计划"); } } Date now = new Date(); ParentTaskEntity entity = new ParentTaskEntity(); entity.setId(UUID.randomUUID().toString()); entity.setUserId(userId); entity.setMacAddress(ParentMqttPublisher.normalizeMac(device.getMacAddress())); entity.setAgentId(device.getAgentId()); entity.setRunMode(runMode); entity.setScene(text(scene.get("id"))); entity.setAppId(mapped.get("appId")); entity.setTaskId(mapped.get("taskId")); entity.setParams(writeJson(params)); entity.setSchedule(schedule == null ? null : writeJson(schedule)); entity.setEnabled(Boolean.FALSE.equals(body.getEnabled()) ? 0 : 1); entity.setStatus("scheduled".equals(runMode) ? "active" : "queued"); entity.setCreatedAt(now); entity.setUpdatedAt(now); parentTaskDao.insert(entity); if ("immediate".equals(runMode)) { dispatchImmediate(entity, params); } else { syncSchedule(device.getMacAddress()); } return toDto(entity); } @Transactional(rollbackFor = Exception.class) public ParentTaskDTO update(Long userId, String id, ParentTaskSaveDTO body) { ParentTaskEntity entity = requireOwned(userId, id); DeviceEntity device = requireDevice(userId, entity.getMacAddress(), entity.getAgentId()); devicePresence.requireOnline(device.getMacAddress()); if (StringUtils.isNotBlank(body.getScene()) && !body.getScene().equals(entity.getScene())) { Map scene = requireSceneRow(body.getScene()); Map mapped = sceneAppTask(scene); entity.setScene(text(scene.get("id"))); entity.setAppId(mapped.get("appId")); entity.setTaskId(mapped.get("taskId")); } if (body.getParams() != null) { entity.setParams(writeJson(compactParams(requireSceneRow(entity.getScene()), body.getParams()))); } if ("scheduled".equals(entity.getRunMode())) { if (body.getSchedule() != null) { entity.setSchedule(writeJson(requireSchedule(body.getSchedule()))); } if (body.getEnabled() != null) { entity.setEnabled(body.getEnabled() ? 1 : 0); } } entity.setUpdatedAt(new Date()); parentTaskDao.updateById(entity); if ("scheduled".equals(entity.getRunMode())) { syncSchedule(device.getMacAddress()); } return toDto(entity); } @Transactional(rollbackFor = Exception.class) public void delete(Long userId, String id) { ParentTaskEntity entity = requireOwned(userId, id); devicePresence.requireOnline(entity.getMacAddress()); String mac = entity.getMacAddress(); boolean scheduled = "scheduled".equals(entity.getRunMode()); parentTaskDao.deleteById(id); if (scheduled) { syncSchedule(mac); } } @Transactional(rollbackFor = Exception.class) public ParentTaskDTO runNow(Long userId, String id) { ParentTaskEntity entity = requireOwned(userId, id); devicePresence.requireOnline(entity.getMacAddress()); Map params = readMap(entity.getParams()); dispatchImmediate(entity, params); return toDto(entity); } @Transactional(rollbackFor = Exception.class) public ParentTaskDTO stop(Long userId, String id) { ParentTaskEntity entity = requireOwned(userId, id); if (!"immediate".equals(entity.getRunMode()) || (!"queued".equals(entity.getStatus()) && !"running".equals(entity.getStatus()))) { throw new RenException("当前没有正在执行的任务"); } devicePresence.requireOnline(entity.getMacAddress()); dispatchStopToChat(entity.getMacAddress()); entity.setStatus("cancelled"); entity.setUpdatedAt(new Date()); parentTaskDao.updateById(entity); return toDto(entity); } public void syncSchedule(String macAddress) { List rows = parentTaskDao.selectList(new LambdaQueryWrapper() .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress)) .eq(ParentTaskEntity::getRunMode, "scheduled") .eq(ParentTaskEntity::getEnabled, 1) .orderByAsc(ParentTaskEntity::getCreatedAt)); List> tasks = new ArrayList<>(); for (ParentTaskEntity row : rows) { Map item = new LinkedHashMap<>(); item.put("id", row.getId()); item.put("app_id", row.getAppId()); item.put("task_id", row.getTaskId()); item.put("enabled", true); item.put("params", readMap(row.getParams())); Map schedule = readMap(row.getSchedule()); if (!schedule.isEmpty()) { item.put("schedule", schedule); } tasks.add(item); } mqttPublisher.publishCtl(macAddress, "SYNC_SCHEDULE", Map.of("tasks", tasks)); syncScheduledLessonAudio(macAddress, rows); } private void dispatchImmediate(ParentTaskEntity entity, Map params) { Map mqttParams = new LinkedHashMap<>(params == null ? Map.of() : params); Map payload = new LinkedHashMap<>(); payload.put("app_id", entity.getAppId()); payload.put("task_id", entity.getTaskId()); payload.put("source", "mqtt_immediate"); payload.put("params", mqttParams); Map pkg = buildLessonPackage(entity.getScene(), mqttParams); if (pkg != null) { payload.put("package", pkg); } mqttPublisher.publishCtl(entity.getMacAddress(), "START_APP_TASK", payload); supersedeImmediate(entity.getMacAddress(), entity.getId()); if ("immediate".equals(entity.getRunMode())) { entity.setStatus("running"); entity.setUpdatedAt(new Date()); parentTaskDao.updateById(entity); } } private void dispatchStopToChat(String macAddress) { Map payload = new LinkedHashMap<>(); payload.put("app_id", "chat"); payload.put("task_id", "conversation"); payload.put("source", "mqtt_immediate"); payload.put("params", Map.of("on_complete", "chat")); mqttPublisher.publishCtl(macAddress, "START_APP_TASK", payload); } public void syncLessonFromServer(LessonSyncDTO body) { if (body == null || StringUtils.isBlank(body.getMacAddress())) { throw new RenException(ErrorCode.PARAM_VALUE_NULL); } List files = ParentCatalog.uniqueLessonPaths(body.getFiles()); if (files.isEmpty()) { return; } String org = ParentCatalog.resolveOrgId(body.getOrgId()); String base = StringUtils.trimToEmpty(body.getBaseUrl()).replaceAll("/+$", ""); if (StringUtils.isBlank(base) || "null".equalsIgnoreCase(base) || base.contains("你")) { base = resolveLessonAudioBase(org); } if (StringUtils.isBlank(base)) { throw new RenException("未配置 server.lesson_audio_public_url,无法同步课文"); } mqttPublisher.publishLessonManifest(body.getMacAddress(), body.getCourseId(), base, files); } @SuppressWarnings("unchecked") private Map buildLessonPackage(String sceneId, Map params) { Map scene = ParentSceneCatalog.find(sceneId); if (!ParentSceneCatalog.flag(scene, "audioPackage")) { return null; } Map src = params == null ? Map.of() : params; String lessonId = text(src.get("lesson_id") == null ? src.get("lessonId") : src.get("lesson_id")); String language = text(src.get("language") == null ? src.get("lang") : src.get("language")); String org = ParentCatalog.resolveOrgId(text(src.get("org_id") == null ? src.get("orgId") : src.get("org_id"))); String base = resolveLessonAudioBase(org); List files = lessonFilesForPackage(base, sceneId, lessonId, language); if (StringUtils.isBlank(base)) { log.warn("skip lesson package: server.lesson_audio_public_url is empty"); return null; } if (files.isEmpty()) { return null; } Map pkg = new LinkedHashMap<>(); pkg.put("course_id", ParentCatalog.courseId(sceneId, lessonId)); pkg.put("base_url", base); pkg.put("files", files); return pkg; } private void syncScheduledLessonAudio(String macAddress, List rows) { List files = new ArrayList<>(); String lessonId = "l1.001"; String scene = "extensive"; for (ParentTaskEntity row : rows) { if (!ParentSceneCatalog.flag(row.getScene(), "audioPackage")) { continue; } Map params = readMap(row.getParams()); lessonId = text(params.get("lesson_id") == null ? params.get("lessonId") : params.get("lesson_id")); scene = row.getScene(); String language = text(params.get("language") == null ? params.get("lang") : params.get("language")); String org = ParentCatalog.DEFAULT_ORG_ID; String base = resolveLessonAudioBase(org); files.addAll(lessonFilesForPackage(base, row.getScene(), lessonId, language)); } files = ParentCatalog.uniqueLessonPaths(files); if (files.isEmpty()) { return; } String base = resolveLessonAudioBase(ParentCatalog.DEFAULT_ORG_ID); if (StringUtils.isBlank(base)) { log.warn("skip scheduled lesson sync: server.lesson_audio_public_url is empty"); return; } mqttPublisher.publishLessonManifest(macAddress, ParentCatalog.courseId(scene, lessonId), base, files); } private List lessonFilesForPackage(String base, String sceneId, String lessonId, String language) { List listed = LessonClipIndex.fetch(base, sceneId, lessonId, language); if (listed != null) { return ParentCatalog.uniqueLessonPaths(listed); } return ParentCatalog.uniqueLessonPaths(ParentCatalog.lessonRelativePaths(sceneId, lessonId, language)); } private String resolveLessonAudioBase(String orgId) { String root = StringUtils.trimToEmpty(sysParamsService.getValue("server.lesson_audio_public_url", true)); if (StringUtils.isBlank(root) || "null".equalsIgnoreCase(root) || root.contains("你")) { return ""; } return root.replaceAll("/+$", "") + "/" + ParentCatalog.resolveOrgId(orgId); } private void supersedeImmediate(String macAddress, String exceptId) { LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress)) .eq(ParentTaskEntity::getRunMode, "immediate") .in(ParentTaskEntity::getStatus, "queued", "running") .set(ParentTaskEntity::getStatus, "cancelled") .set(ParentTaskEntity::getUpdatedAt, new Date()); if (StringUtils.isNotBlank(exceptId)) { wrapper.ne(ParentTaskEntity::getId, exceptId); } parentTaskDao.update(null, wrapper); } public int closeFromServer(ParentImmediateCloseDTO body) { if (body == null || StringUtils.isBlank(body.getDeviceId())) { return 0; } String reason = StringUtils.defaultIfBlank(body.getReason(), "server"); String status = closeStatus(body.getStatus(), reason); if (StringUtils.isNotBlank(body.getMatchAppId())) { return closeMatchingImmediate(body.getDeviceId(), body.getMatchAppId(), body.getMatchTaskId(), status); } return closeRunningImmediate(body.getDeviceId(), status, body.getExceptAppId(), body.getExceptTaskId()); } public void closeRunningForOffline(String macAddress) { closeRunningImmediate(macAddress, "cancelled", null, null); expireRunningInstances(macAddress, "disconnected"); } public void expireStaleForDevice(String macAddress) { String mac = ParentMqttPublisher.normalizeMac(macAddress); if (StringUtils.isBlank(mac)) { return; } expireStaleImmediate(mac, devicePresence.isOnlineCached(mac)); } public int closeRunningImmediate(String macAddress, String status, String exceptAppId, String exceptTaskId) { String mac = ParentMqttPublisher.normalizeMac(macAddress); if (StringUtils.isBlank(mac)) { return 0; } LambdaUpdateWrapper wrapper = runningImmediateWrapper(mac) .set(ParentTaskEntity::getStatus, closeStatus(status, status)) .set(ParentTaskEntity::getUpdatedAt, new Date()); if (StringUtils.isNotBlank(exceptAppId) && StringUtils.isNotBlank(exceptTaskId)) { wrapper.and(w -> w.ne(ParentTaskEntity::getAppId, exceptAppId) .or() .ne(ParentTaskEntity::getTaskId, exceptTaskId)); } return parentTaskDao.update(null, wrapper); } public int closeMatchingImmediate(String macAddress, String appId, String taskId, String status) { String mac = ParentMqttPublisher.normalizeMac(macAddress); if (StringUtils.isBlank(mac) || StringUtils.isBlank(appId)) { return 0; } LambdaUpdateWrapper wrapper = runningImmediateWrapper(mac) .eq(ParentTaskEntity::getAppId, appId) .eq(StringUtils.isNotBlank(taskId), ParentTaskEntity::getTaskId, taskId) .set(ParentTaskEntity::getStatus, closeStatus(status, status)) .set(ParentTaskEntity::getUpdatedAt, new Date()); return parentTaskDao.update(null, wrapper); } private void expireStaleImmediate(String mac, boolean online) { long ageMs = online ? STALE_RUNNING_MS : OFFLINE_GRACE_MS; Date cutoff = new Date(System.currentTimeMillis() - ageMs); parentTaskDao.update(null, runningImmediateWrapper(mac) .lt(ParentTaskEntity::getUpdatedAt, cutoff) .set(ParentTaskEntity::getStatus, "cancelled") .set(ParentTaskEntity::getUpdatedAt, new Date())); long instanceCutoff = System.currentTimeMillis() - STALE_RUNNING_MS; expireRunningInstancesBefore(mac, instanceCutoff, "stale"); } private void expireRunningInstances(String macAddress, String reason) { expireRunningInstancesBefore(macAddress, System.currentTimeMillis(), reason); } private void expireRunningInstancesBefore(String macAddress, long startedBeforeMs, String reason) { String colon = ParentMqttPublisher.normalizeMac(macAddress); if (StringUtils.isBlank(colon)) { return; } String hyphen = colon.replace(':', '-'); Date now = new Date(); taskInstanceDao.update(null, new LambdaUpdateWrapper() .in(TaskInstanceEntity::getMacAddress, colon, hyphen) .eq(TaskInstanceEntity::getStatus, "running") .lt(TaskInstanceEntity::getStartedAt, startedBeforeMs) .set(TaskInstanceEntity::getStatus, "cancelled") .set(TaskInstanceEntity::getEndReason, StringUtils.left(reason, 32)) .set(TaskInstanceEntity::getEndedAt, now.getTime()) .set(TaskInstanceEntity::getUpdatedAt, now)); } private LambdaUpdateWrapper runningImmediateWrapper(String mac) { return new LambdaUpdateWrapper() .eq(ParentTaskEntity::getMacAddress, mac) .eq(ParentTaskEntity::getRunMode, "immediate") .in(ParentTaskEntity::getStatus, "queued", "running"); } private static String closeStatus(String status, String reason) { if ("done".equals(status) || "completed".equals(status)) { return "done"; } if ("cancelled".equals(status) || "canceled".equals(status)) { return "cancelled"; } if ("play_completed".equals(reason) || "finished".equals(reason) || "done".equals(reason)) { return "done"; } return "cancelled"; } private DeviceEntity requireDevice(Long userId, String macAddress, String agentId) { if (userId == null || userId == 0L) { throw new RenException(ErrorCode.UNAUTHORIZED); } if (StringUtils.isBlank(macAddress) || StringUtils.isBlank(agentId)) { throw new RenException(ErrorCode.PARAM_VALUE_NULL); } DeviceEntity device = findDevice(macAddress); if (device == null || device.getUserId() == null || !device.getUserId().equals(userId)) { throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND); } if (!agentService.checkAgentPermission(agentId, userId)) { throw new RenException(ErrorCode.NO_PERMISSION); } if (StringUtils.isNotBlank(device.getAgentId()) && !agentId.equals(device.getAgentId())) { throw new RenException("设备与智能体不匹配"); } return device; } public DeviceEntity requireUserDevice(Long userId, String macAddress, String agentId) { return requireDevice(userId, macAddress, agentId); } private DeviceEntity findDevice(String macAddress) { String raw = StringUtils.trimToEmpty(macAddress); String lower = raw.toLowerCase(); String colon = lower.replace('-', ':'); String hyphen = lower.replace(':', '-'); for (String candidate : new String[] { raw, lower, colon, hyphen }) { if (StringUtils.isBlank(candidate)) { continue; } DeviceEntity device = deviceService.getDeviceByMacAddress(candidate); if (device != null) { return device; } } return null; } private ParentTaskEntity requireOwned(Long userId, String id) { if (StringUtils.isBlank(id)) { throw new RenException(ErrorCode.PARAM_VALUE_NULL); } ParentTaskEntity entity = parentTaskDao.selectById(id); if (entity == null || entity.getUserId() == null || !entity.getUserId().equals(userId)) { throw new RenException(ErrorCode.RESOURCE_NOT_FOUND); } return entity; } private int countScheduled(String macAddress) { Long count = parentTaskDao.selectCount(new LambdaQueryWrapper() .eq(ParentTaskEntity::getMacAddress, ParentMqttPublisher.normalizeMac(macAddress)) .eq(ParentTaskEntity::getRunMode, "scheduled")); return count == null ? 0 : count.intValue(); } private String normalizeRunMode(String runMode) { String value = StringUtils.trimToEmpty(runMode); if (!RUN_MODES.contains(value)) { throw new RenException("run_mode 只能是 immediate 或 scheduled"); } return value; } private Map requireSceneRow(String scene) { try { return ParentSceneCatalog.require(StringUtils.trimToEmpty(scene)); } catch (IllegalArgumentException ex) { throw new RenException("不支持的场景"); } } private Map sceneAppTask(Map scene) { Map mapped = ParentSceneCatalog.sceneToAppTask(text(scene.get("id"))); if (mapped == null) { throw new RenException("不支持的场景"); } return mapped; } private Map compactParams(Map scene, Map raw) { Map src = raw == null ? Map.of() : raw; Map out = new LinkedHashMap<>(); String lessonId = text(src.get("lesson_id") == null ? src.get("lessonId") : src.get("lesson_id")); if (ParentSceneCatalog.flag(scene, "needsLesson")) { if (StringUtils.isBlank(lessonId)) { lessonId = "l1.001"; } if (IdiomLessonCatalog.findStory(lessonId) == null) { throw new RenException("未找到该成语课文,请检查清单 idiom-lessons.json"); } String canonical = IdiomLessonCatalog.canonicalLessonId(lessonId); out.put("lesson_id", canonical); String lessonName = IdiomLessonCatalog.storyName(canonical); if (StringUtils.isNotBlank(lessonName)) { out.put("lesson_name", lessonName); } String lessonNameEn = IdiomLessonCatalog.storyNameEn(canonical); if (StringUtils.isNotBlank(lessonNameEn)) { out.put("lesson_name_en", lessonNameEn); } if (ParentSceneCatalog.flag(scene, "needsEnPasses")) { out.put("cn_repeat", intInRange(src.get("cn_repeat") == null ? src.get("cnRepeat") : src.get("cn_repeat"), 1, 10, 1)); out.put("cn_speed", doubleInRange(src.get("cn_speed") == null ? src.get("cnSpeed") : src.get("cn_speed"), 0.5, 2.0, 1.0)); out.put("en_passes", normalizeEnPasses(src.get("en_passes") == null ? src.get("enPasses") : src.get("en_passes"))); } if (ParentSceneCatalog.flag(scene, "needsShadowingSettings")) { String language = text(src.get("language") == null ? src.get("lang") : src.get("language")).toLowerCase(); if (!"cn".equals(language) && !"en".equals(language)) { language = "en"; } out.put("language", language); out.put("speed", doubleInRange(src.get("speed"), 0.5, 2.0, 0.8)); out.put("listen_ratio", 1.2); } } if (ParentSceneCatalog.flag(scene, "needsLanguagePair")) { String pair = text(src.get("language_pair") == null ? src.get("languagePair") : src.get("language_pair")); out.put("language_pair", StringUtils.defaultIfBlank(pair, "zh-en")); } Object duration = src.get("duration_minutes") == null ? src.get("durationMinutes") : src.get("duration_minutes"); if (duration != null) { int minutes = intInRange(duration, 0, 24 * 60, 0); if (minutes > 0) { out.put("duration_minutes", minutes); } } if (ParentSceneCatalog.flag(scene, "needsOnComplete")) { String complete = text(src.get("on_complete") == null ? src.get("onComplete") : src.get("on_complete")); if (StringUtils.isBlank(complete)) { complete = "chat"; } if (!ON_COMPLETE.contains(complete)) { throw new RenException("on_complete 只能是 chat 或 power_off"); } out.put("on_complete", complete); } Object announce = src.get("announce"); if (announce instanceof Boolean bool) { out.put("announce", bool); } return out; } private Map requireSchedule(Map raw) { if (raw == null || raw.isEmpty()) { throw new RenException("计划任务需要 schedule"); } String type = text(raw.get("type")); if (!SCHEDULE_TYPES.contains(type)) { throw new RenException("schedule.type 只能是 daily、hourly、interval"); } Map out = new LinkedHashMap<>(); out.put("type", type); if ("daily".equals(type)) { String time = text(raw.get("time")); if (!TIME.matcher(time).matches()) { throw new RenException("daily.time 格式应为 HH:MM"); } out.put("time", time.length() == 5 ? time : time.substring(0, 5)); Object weekdays = raw.get("weekdays"); if (weekdays instanceof List list && !list.isEmpty()) { List days = new ArrayList<>(); for (Object item : list) { int day = intInRange(item, 1, 7, 0); if (day >= 1 && day <= 7 && !days.contains(day)) { days.add(day); } } if (!days.isEmpty()) { out.put("weekdays", days); } } return out; } if ("hourly".equals(type)) { out.put("minute", intInRange(raw.get("minute"), 0, 59, 0)); return out; } out.put("every_minutes", intInRange(raw.get("every_minutes") == null ? raw.get("everyMinutes") : raw.get("every_minutes"), 1, 24 * 60, 60)); return out; } private ParentTaskDTO toDto(ParentTaskEntity entity) { ParentTaskDTO dto = new ParentTaskDTO(); dto.setId(entity.getId()); dto.setMacAddress(entity.getMacAddress()); dto.setAgentId(entity.getAgentId()); dto.setRunMode(entity.getRunMode()); dto.setScene(entity.getScene()); dto.setAppId(entity.getAppId()); dto.setTaskId(entity.getTaskId()); dto.setSceneName(ParentCatalog.sceneName(entity.getScene())); dto.setParams(readMap(entity.getParams())); dto.setSchedule(readMap(entity.getSchedule())); dto.setEnabled(entity.getEnabled() == null || entity.getEnabled() != 0); dto.setStatus(entity.getStatus()); dto.setCreatedAt(entity.getCreatedAt()); dto.setUpdatedAt(entity.getUpdatedAt()); return dto; } private Map readMap(String json) { if (StringUtils.isBlank(json)) { return new LinkedHashMap<>(); } try { Map value = objectMapper.readValue(json, MAP); return value == null ? new LinkedHashMap<>() : value; } catch (Exception ex) { return new LinkedHashMap<>(); } } private String writeJson(Object value) { try { return objectMapper.writeValueAsString(value == null ? Map.of() : value); } catch (Exception ex) { throw new RenException("JSON 写入失败"); } } private String text(Object value) { return value == null ? "" : StringUtils.trimToEmpty(String.valueOf(value)); } private List> normalizeEnPasses(Object raw) { List> out = new ArrayList<>(); if (raw instanceof List list) { for (Object item : list) { Map row = IdiomLessonCatalog.normalizePass(item); if (row != null) { out.add(row); } if (out.size() >= 8) { break; } } } if (out.isEmpty()) { return new ArrayList<>(IdiomLessonCatalog.defaultEnPasses()); } return out; } private double doubleInRange(Object value, double min, double max, double fallback) { if (value == null) { return fallback; } double parsed; try { parsed = Double.parseDouble(String.valueOf(value).trim()); } catch (NumberFormatException ex) { return fallback; } if (Double.isNaN(parsed) || Double.isInfinite(parsed)) { return fallback; } if (parsed < min) { return min; } if (parsed > max) { return max; } return parsed; } private int intInRange(Object value, int min, int max, int fallback) { if (value == null) { return fallback; } int parsed; try { parsed = Integer.parseInt(String.valueOf(value).trim()); } catch (NumberFormatException ex) { return fallback; } if (parsed < min) { return min; } if (parsed > max) { return max; } return parsed; } }