package xiaozhi.modules.parent.service; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.RenException; import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.parent.catalog.ParentCatalog; import xiaozhi.modules.parent.dto.ParentHistoryDetailDTO; import xiaozhi.modules.parent.dto.ParentHistoryItemDTO; import xiaozhi.modules.parent.dto.ParentHistoryListDTO; import xiaozhi.modules.parent.dto.ParentHistoryTurnDTO; import xiaozhi.modules.parent.mqtt.ParentMqttPublisher; import xiaozhi.modules.task.dao.TaskInstanceDao; import xiaozhi.modules.task.dao.TaskStepDao; import xiaozhi.modules.task.entity.TaskInstanceEntity; import xiaozhi.modules.task.entity.TaskStepEntity; @Service @RequiredArgsConstructor public class ParentHistoryService { private static final int LIST_MAX = 50; private static final Pattern PLAYABLE_AUDIO = Pattern.compile("^[0-9A-Fa-f-]{32,36}$"); private final ParentTaskService parentTaskService; private final TaskInstanceDao instanceDao; private final TaskStepDao stepDao; private final AgentChatHistoryService agentChatHistoryService; public ParentHistoryListDTO list(Long userId, String macAddress, String agentId, Integer limit) { DeviceEntity device = parentTaskService.requireUserDevice(userId, macAddress, agentId); parentTaskService.expireStaleForDevice(device.getMacAddress()); Set macs = macCandidates(device.getMacAddress(), macAddress); if (macs.isEmpty()) { return new ParentHistoryListDTO(); } int size = limit == null ? 20 : Math.min(Math.max(limit, 1), LIST_MAX); String aid = StringUtils.defaultIfBlank(device.getAgentId(), agentId); Long total = instanceDao.selectCount(ownedInstances(macs, aid)); List rows = instanceDao.selectList(ownedInstances(macs, aid) .orderByDesc(TaskInstanceEntity::getStartedAt) .last("LIMIT " + size)); Map turnCounts = countTurns(rows); ParentHistoryListDTO result = new ParentHistoryListDTO(); result.setTotal(total == null ? 0 : total); for (TaskInstanceEntity row : rows) { result.getList().add(toItem(row, turnCounts.getOrDefault(row.getId(), 0))); } return result; } public ParentHistoryDetailDTO get(Long userId, String macAddress, String agentId, String id) { DeviceEntity device = parentTaskService.requireUserDevice(userId, macAddress, agentId); if (StringUtils.isBlank(id)) { throw new RenException(ErrorCode.PARAM_VALUE_NULL); } TaskInstanceEntity entity = instanceDao.selectById(id); if (entity == null || !owns(device, entity)) { throw new RenException(ErrorCode.RESOURCE_NOT_FOUND); } List steps = stepDao.selectList(new LambdaQueryWrapper() .eq(TaskStepEntity::getInstanceId, id) .orderByAsc(TaskStepEntity::getStepIndex)); ParentHistoryDetailDTO detail = new ParentHistoryDetailDTO(); copyItem(toItem(entity, 0), detail); String kind = detail.getKind(); if ("shadowing".equals(kind)) { for (TaskStepEntity step : steps) { if (step.getEchoRejected() != null && step.getEchoRejected() == 1) { continue; } ParentHistoryTurnDTO turn = new ParentHistoryTurnDTO(); turn.setIndex(step.getStepIndex()); turn.setExpectedText(displayText(step.getExpectedText())); turn.setAsrText(displayText(step.getAsrText())); turn.setAudioId(playableAudioId(step.getAudioId())); detail.getTurns().add(turn); } detail.setTurnCount(detail.getTurns().size()); } if ("chat".equals(kind) && StringUtils.isNotBlank(entity.getWsSessionId())) { String aid = StringUtils.defaultIfBlank(device.getAgentId(), agentId); List chats = agentChatHistoryService .getChatHistoryBySessionId(aid, entity.getWsSessionId()); if (chats != null) { for (AgentChatHistoryDTO chat : chats) { chat.setAudioId(playableAudioId(chat.getAudioId())); } detail.setChats(chats); detail.setTurnCount(chats.size()); } } return detail; } private LambdaQueryWrapper ownedInstances(Set macs, String agentId) { return new LambdaQueryWrapper() .in(TaskInstanceEntity::getMacAddress, macs) .and(w -> w.eq(TaskInstanceEntity::getAgentId, agentId) .or() .isNull(TaskInstanceEntity::getAgentId) .or() .eq(TaskInstanceEntity::getAgentId, "")); } private boolean owns(DeviceEntity device, TaskInstanceEntity entity) { Set macs = macCandidates(device.getMacAddress(), null); String instanceMac = StringUtils.trimToEmpty(entity.getMacAddress()); boolean macOk = false; for (String candidate : macs) { if (candidate.equalsIgnoreCase(instanceMac) || ParentMqttPublisher.normalizeMac(candidate) .equals(ParentMqttPublisher.normalizeMac(instanceMac))) { macOk = true; break; } } if (!macOk) { return false; } if (StringUtils.isBlank(entity.getAgentId()) || StringUtils.isBlank(device.getAgentId())) { return true; } return entity.getAgentId().equals(device.getAgentId()); } private Map countTurns(List rows) { Map counts = new HashMap<>(); if (rows == null || rows.isEmpty()) { return counts; } List ids = new ArrayList<>(); for (TaskInstanceEntity row : rows) { if ("shadowing".equals(ParentCatalog.kindFromAppTask(row.getAppId(), row.getTaskId()))) { ids.add(row.getId()); } } if (ids.isEmpty()) { return counts; } List steps = stepDao.selectList(new LambdaQueryWrapper() .in(TaskStepEntity::getInstanceId, ids)); for (TaskStepEntity step : steps) { if (step.getEchoRejected() != null && step.getEchoRejected() == 1) { continue; } counts.merge(step.getInstanceId(), 1, Integer::sum); } return counts; } private ParentHistoryItemDTO toItem(TaskInstanceEntity row, int turnCount) { ParentHistoryItemDTO item = new ParentHistoryItemDTO(); item.setId(row.getId()); String scene = ParentCatalog.sceneIdFromAppTask(row.getAppId(), row.getTaskId()); item.setScene(scene); item.setSceneName(StringUtils.defaultIfBlank(ParentCatalog.sceneName(scene), StringUtils.defaultIfBlank(row.getTaskId(), row.getAppId()))); item.setKind(ParentCatalog.kindFromAppTask(row.getAppId(), row.getTaskId())); item.setAppId(row.getAppId()); item.setTaskId(row.getTaskId()); item.setSource(row.getSource()); item.setStatus(row.getStatus()); item.setEndReason(row.getEndReason()); item.setStartedAt(row.getStartedAt()); item.setEndedAt(row.getEndedAt()); if (row.getStartedAt() != null && row.getEndedAt() != null && row.getEndedAt() > row.getStartedAt()) { item.setDurationMs(row.getEndedAt() - row.getStartedAt()); } item.setTurnCount(turnCount); return item; } private void copyItem(ParentHistoryItemDTO source, ParentHistoryDetailDTO target) { target.setId(source.getId()); target.setScene(source.getScene()); target.setSceneName(source.getSceneName()); target.setKind(source.getKind()); target.setAppId(source.getAppId()); target.setTaskId(source.getTaskId()); target.setSource(source.getSource()); target.setStatus(source.getStatus()); target.setEndReason(source.getEndReason()); target.setStartedAt(source.getStartedAt()); target.setEndedAt(source.getEndedAt()); target.setDurationMs(source.getDurationMs()); target.setTurnCount(source.getTurnCount()); } private String playableAudioId(String audioId) { if (StringUtils.isBlank(audioId)) { return null; } String value = audioId.trim(); if (value.contains("/") || value.contains("\\") || value.contains(".")) { return null; } return PLAYABLE_AUDIO.matcher(value).matches() ? value : null; } private String displayText(String text) { if (StringUtils.isBlank(text)) { return ""; } String value = text.trim(); if (value.contains("/") || value.contains("\\") || value.endsWith(".mp3") || value.endsWith(".wav")) { return ""; } return value; } private Set macCandidates(String stored, String requested) { Set values = new HashSet<>(); addMac(values, stored); addMac(values, requested); return values; } private void addMac(Set values, String raw) { String text = StringUtils.trimToEmpty(raw); if (text.isEmpty()) { return; } values.add(text); values.add(text.toLowerCase()); values.add(text.toLowerCase().replace('-', ':')); values.add(text.toLowerCase().replace(':', '-')); values.add(ParentMqttPublisher.normalizeMac(text)); } }