feat(mcp): add toilet.airquality list/detail/metrics/statelog query tools (REQ-20261012-0004)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package me.iiv.agent.entity.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/** 空感(空气质量检测器)设备主档(m0.toilet_airquality),极简字段子集。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("toilet_airquality")
|
||||
public class ToiletAirquality implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Integer softDelete;
|
||||
/** 设备 SN/mac */
|
||||
private String airmac;
|
||||
/** 设备状态:0=异常 1=正常 */
|
||||
private Integer state;
|
||||
/** 运行状态:0=离线 1=在线 */
|
||||
private Integer runState;
|
||||
/** 上线状态:0=初始化 1=上线 2=下线 */
|
||||
private Integer onlineState;
|
||||
private Date onlineTime;
|
||||
private Long agentId;
|
||||
private Long propertyId;
|
||||
private Long districtId;
|
||||
private Long buildingId;
|
||||
private String floor;
|
||||
private String installPosition;
|
||||
private Long toiletId;
|
||||
private Long positionId;
|
||||
private Double temp;
|
||||
private Double hum;
|
||||
private Double pm2d5;
|
||||
private Double pm10;
|
||||
private Double pm1d0;
|
||||
private Double co2;
|
||||
private Double tvoc;
|
||||
private Double ammonia;
|
||||
private Double hydrogensulfide;
|
||||
private Double formaldehyde;
|
||||
private String hardware;
|
||||
private String firmware;
|
||||
private String protocol;
|
||||
private Integer upInterval;
|
||||
private String imsi;
|
||||
private String signalValue;
|
||||
/** 厂商类型:0=未知 1=合宙 2=合宙 3=SIMBOOS(列表输出时被 networkcard 覆盖,对齐老系统) */
|
||||
private Integer factoryType;
|
||||
private Integer voiceFlag;
|
||||
private Integer shockFlag;
|
||||
private Integer muteFlag;
|
||||
private Integer shockTimeFlag;
|
||||
private Integer pilotLightFlag;
|
||||
private Date createTime;
|
||||
private Date modifyTime;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package me.iiv.agent.entity.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/** 空感状态变更日志(m0.toilet_airquality_log)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("toilet_airquality_log")
|
||||
public class ToiletAirqualityLog implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Integer softDelete;
|
||||
/** 设备 SN/mac */
|
||||
private String airmac;
|
||||
/** 状态变更时间 */
|
||||
private Date updateTime;
|
||||
/** 状态:0=异常 1=正常 2=离线 3=在线 */
|
||||
private Integer state;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* toilet_airquality_detail_*(空感指标上报明细)ES 查询网关。
|
||||
* 索引建在阿里云 ES 云集群(老 KongGanLogESServiceImpl 注入的 restClient = EsCluster 云集群),
|
||||
* 索引名固定为通配 toilet_airquality_detail_* 由调用方传入,本接口只做单次查询,便于单测 mock。
|
||||
*/
|
||||
public interface ToiletAirqualityMetricsEsGateway {
|
||||
|
||||
/**
|
||||
* 分页查询单台空感设备的指标上报明细。
|
||||
*
|
||||
* @param index 索引名/通配,如 toilet_airquality_detail_*
|
||||
* @param airmac 设备 SN/mac,match_phrase 精确短语匹配
|
||||
* (老系统 matchQuery 经 standard analyzer 分词误配,不照搬)
|
||||
* @param gteMillis updateTime 下界(含),epoch 毫秒(date 字段发字符串会被 400 拒绝)
|
||||
* @param lteMillis updateTime 上界(含),epoch 毫秒
|
||||
* @param from from+size 分页起始偏移
|
||||
* @param size 每页条数
|
||||
* @return 命中总数(track_total_hits)与当页记录,按 updateTime DESC
|
||||
*/
|
||||
Result search(String index, String airmac, long gteMillis, long lteMillis, int from, int size);
|
||||
|
||||
/** 单次查询结果。 */
|
||||
class Result {
|
||||
private final long total;
|
||||
private final List<Record> records;
|
||||
|
||||
public Result(long total, List<Record> records) {
|
||||
this.total = total;
|
||||
this.records = records;
|
||||
}
|
||||
|
||||
public long getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public List<Record> getRecords() {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条指标明细:updateTime 已归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本;
|
||||
* 10 项指标原值(_source 数值/文本双形态归一为 Double,非数值为 null)。
|
||||
*/
|
||||
class Record {
|
||||
private final String updateTime;
|
||||
private final Double temp;
|
||||
private final Double hum;
|
||||
private final Double pm2d5;
|
||||
private final Double pm10;
|
||||
private final Double pm1d0;
|
||||
private final Double co2;
|
||||
private final Double tvoc;
|
||||
private final Double ammonia;
|
||||
private final Double hydrogensulfide;
|
||||
private final Double formaldehyde;
|
||||
|
||||
public Record(String updateTime, Double temp, Double hum, Double pm2d5, Double pm10,
|
||||
Double pm1d0, Double co2, Double tvoc, Double ammonia,
|
||||
Double hydrogensulfide, Double formaldehyde) {
|
||||
this.updateTime = updateTime;
|
||||
this.temp = temp;
|
||||
this.hum = hum;
|
||||
this.pm2d5 = pm2d5;
|
||||
this.pm10 = pm10;
|
||||
this.pm1d0 = pm1d0;
|
||||
this.co2 = co2;
|
||||
this.tvoc = tvoc;
|
||||
this.ammonia = ammonia;
|
||||
this.hydrogensulfide = hydrogensulfide;
|
||||
this.formaldehyde = formaldehyde;
|
||||
}
|
||||
|
||||
public String getUpdateTime() {
|
||||
return updateTime;
|
||||
}
|
||||
|
||||
public Double getTemp() {
|
||||
return temp;
|
||||
}
|
||||
|
||||
public Double getHum() {
|
||||
return hum;
|
||||
}
|
||||
|
||||
public Double getPm2d5() {
|
||||
return pm2d5;
|
||||
}
|
||||
|
||||
public Double getPm10() {
|
||||
return pm10;
|
||||
}
|
||||
|
||||
public Double getPm1d0() {
|
||||
return pm1d0;
|
||||
}
|
||||
|
||||
public Double getCo2() {
|
||||
return co2;
|
||||
}
|
||||
|
||||
public Double getTvoc() {
|
||||
return tvoc;
|
||||
}
|
||||
|
||||
public Double getAmmonia() {
|
||||
return ammonia;
|
||||
}
|
||||
|
||||
public Double getHydrogensulfide() {
|
||||
return hydrogensulfide;
|
||||
}
|
||||
|
||||
public Double getFormaldehyde() {
|
||||
return formaldehyde;
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.elasticsearch.client.Request;
|
||||
import org.elasticsearch.client.Response;
|
||||
import org.elasticsearch.client.RestClient;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* toilet_airquality_detail_* ES 查询实现,骨架照 ElectricityMessageEsGatewayImpl。
|
||||
* 只注入 esCloudRestClient(低阶 RestClient:生产 ES 7.7.1 与高阶客户端不兼容,见 AGENTS.md);
|
||||
* 空感指标索引在云集群(老 KongGanLogESServiceImpl 的 restClient bean = EsCluster 云集群)。
|
||||
* 修复老系统坑:airmac 用 match_phrase(老 BaseSearchSupportImpl type=0 走 matchQuery,
|
||||
* standard analyzer 对 SN 分词误配);updateTime range 发 epoch 毫秒;ES 异常不静默,
|
||||
* 抛 IllegalStateException。
|
||||
*/
|
||||
@Component
|
||||
public class ToiletAirqualityMetricsEsGatewayImpl implements ToiletAirqualityMetricsEsGateway {
|
||||
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE);
|
||||
|
||||
private final RestClient cloudRestClient;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public ToiletAirqualityMetricsEsGatewayImpl(@Qualifier("esCloudRestClient") RestClient cloudRestClient) {
|
||||
this.cloudRestClient = cloudRestClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result search(String index, String airmac, long gteMillis, long lteMillis, int from, int size) {
|
||||
ArrayNode filters = mapper.createArrayNode();
|
||||
filters.addObject().putObject("match_phrase").putObject("airmac").put("query", airmac);
|
||||
ObjectNode range = filters.addObject().putObject("range").putObject("updateTime");
|
||||
range.put("gte", gteMillis).put("lte", lteMillis);
|
||||
|
||||
ObjectNode body = mapper.createObjectNode();
|
||||
body.put("from", from).put("size", size);
|
||||
body.put("track_total_hits", true);
|
||||
ObjectNode bool = body.putObject("query").putObject("bool");
|
||||
bool.set("filter", filters);
|
||||
body.putArray("sort").addObject().putObject("updateTime").put("order", "desc");
|
||||
try {
|
||||
Request req = new Request("POST", "/" + index + "/_search");
|
||||
// setJsonEntity 自动带 Content-Type: application/json(无 vendor 头)
|
||||
req.setJsonEntity(mapper.writeValueAsString(body));
|
||||
Response resp = cloudRestClient.performRequest(req);
|
||||
JsonNode root = mapper.readTree(resp.getEntity().getContent());
|
||||
List<Record> records = new ArrayList<>();
|
||||
for (JsonNode hit : root.at("/hits/hits")) {
|
||||
JsonNode source = hit.get("_source");
|
||||
if (source == null) {
|
||||
continue;
|
||||
}
|
||||
records.add(new Record(updateTimeText(source.get("updateTime")),
|
||||
doubleValue(source.get("temp")),
|
||||
doubleValue(source.get("hum")),
|
||||
doubleValue(source.get("pm2d5")),
|
||||
doubleValue(source.get("pm10")),
|
||||
doubleValue(source.get("pm1d0")),
|
||||
doubleValue(source.get("co2")),
|
||||
doubleValue(source.get("tvoc")),
|
||||
doubleValue(source.get("ammonia")),
|
||||
doubleValue(source.get("hydrogensulfide")),
|
||||
doubleValue(source.get("formaldehyde"))));
|
||||
}
|
||||
return new Result(root.at("/hits/total/value").asLong(), records);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("ES 查询空感指标明细失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* updateTime 在 _source 里可能是 epoch 毫秒数或 ISO 文本(mapping 类型冒烟阶段实测确认),
|
||||
* 两种形态都归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本输出。
|
||||
*/
|
||||
private static String updateTimeText(JsonNode value) {
|
||||
if (value == null || value.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
return TIME_FORMATTER.format(Instant.ofEpochMilli(value.longValue()));
|
||||
}
|
||||
if (value.isTextual()) {
|
||||
String text = value.asText();
|
||||
if (text.indexOf('T') >= 0) {
|
||||
try {
|
||||
return TIME_FORMATTER.format(OffsetDateTime.parse(text));
|
||||
} catch (DateTimeParseException e) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 指标值在 _source 里可能是数字也可能是字符串,统一解析为 Double;非数值文本返回 null。 */
|
||||
private static Double doubleValue(JsonNode value) {
|
||||
if (value == null || value.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
return value.doubleValue();
|
||||
}
|
||||
if (value.isTextual()) {
|
||||
try {
|
||||
return Double.valueOf(value.asText().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m0.ToiletAirqualityLog;
|
||||
|
||||
public interface ToiletAirqualityLogMapper extends BaseMapper<ToiletAirqualityLog> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m0.ToiletAirquality;
|
||||
|
||||
public interface ToiletAirqualityMapper extends BaseMapper<ToiletAirquality> {
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.Networkcard;
|
||||
import me.iiv.agent.entity.m0.ToiletAirquality;
|
||||
import me.iiv.agent.entity.m0.ToiletAirqualityLog;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.es.ToiletAirqualityMetricsEsGateway;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.NetworkcardMapper;
|
||||
import me.iiv.agent.mapper.m0.ToiletAirqualityLogMapper;
|
||||
import me.iiv.agent.mapper.m0.ToiletAirqualityMapper;
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 空感(空气质量检测器)查询工具,仿老系统空感管理域:
|
||||
* /toiletAirQuality/listByPage(列表)、/toiletAirQualityLog/listByPage(ES 指标明细)、
|
||||
* /toiletAirQualityLog/logListByPage(状态变更日志)。故障日志本期不做(王恒 2026-10-12 拍板)。
|
||||
* 富化修正老接口 N+1:名称走 Redis multiGet,物联网卡按页 IN 批量查;
|
||||
* metrics/statelog 补齐老接口缺失的行级权限(先经主档 airmac 校验可见性)。
|
||||
* 设计文档:doc/toilet-airquality-mcp-tool-design.md
|
||||
*/
|
||||
@Component
|
||||
public class ToiletAirqualityTools implements ToolObject {
|
||||
|
||||
private static final int DEFAULT_ROWS = 20;
|
||||
private static final int MAX_ROWS = 100;
|
||||
/** ES from+size 深分页上限 max_result_window */
|
||||
private static final int MAX_RESULT_WINDOW = 10000;
|
||||
/** 指标明细 ES 索引通配(云集群) */
|
||||
private static final String METRICS_INDEX = "toilet_airquality_detail_*";
|
||||
/** 日期区间硬限 31 天(一个月) */
|
||||
private static final int MAX_QUERY_DAYS = 31;
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(SHANGHAI);
|
||||
|
||||
/** 老接口 Redis 字典 key(ToiletAirQualityController /listByPage :137-142) */
|
||||
private static final String NAME_AGENT_PREFIX = "name:Agent:";
|
||||
private static final String NAME_PROPERTY_PREFIX = "name:Properyt:";
|
||||
private static final String NAME_DISTRICT_PREFIX = "name:District:";
|
||||
|
||||
final ToiletAirqualityMapper toiletAirqualityMapper;
|
||||
final ToiletAirqualityLogMapper toiletAirqualityLogMapper;
|
||||
final NetworkcardMapper networkcardMapper;
|
||||
final DistrictDistrictStrategyMapper districtMapper;
|
||||
final PermissionService permissionService;
|
||||
final StringRedisTemplate redisTemplate;
|
||||
final ToiletAirqualityMetricsEsGateway metricsEsGateway;
|
||||
|
||||
public ToiletAirqualityTools(ToiletAirqualityMapper toiletAirqualityMapper,
|
||||
ToiletAirqualityLogMapper toiletAirqualityLogMapper,
|
||||
NetworkcardMapper networkcardMapper,
|
||||
DistrictDistrictStrategyMapper districtMapper,
|
||||
PermissionService permissionService,
|
||||
StringRedisTemplate redisTemplate,
|
||||
ToiletAirqualityMetricsEsGateway metricsEsGateway) {
|
||||
this.toiletAirqualityMapper = toiletAirqualityMapper;
|
||||
this.toiletAirqualityLogMapper = toiletAirqualityLogMapper;
|
||||
this.networkcardMapper = networkcardMapper;
|
||||
this.districtMapper = districtMapper;
|
||||
this.permissionService = permissionService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.metricsEsGateway = metricsEsGateway;
|
||||
}
|
||||
|
||||
@XqTool(category = "toilet", kind = "read")
|
||||
@Tool(name = "toilet.airquality.list.query", description = "分页查询当前用户可见的空感(空气质量检测器)设备列表,"
|
||||
+ "对应老系统 /toiletAirQuality/listByPage。支持 airmac(设备 SN/mac)模糊匹配,"
|
||||
+ "agentId、propertyId、districtId、buildingId、floor、state、runState、onlineState、"
|
||||
+ "hardware、firmware、protocol、imsi、muteFlag、shockTimeFlag、pilotLightFlag 精确匹配。"
|
||||
+ "page 默认 1,rows 默认 20、上限 100,固定按 id 倒序。"
|
||||
+ "agentName/propertyName/districtName 使用 Redis 批量富化,物联网卡 factoryType 按页批量查询并覆盖主档值。"
|
||||
+ "返回主档固定字段集(不做老系统 webId 字段级裁剪),pm10 即老页面「烟感」、tvoc 即「异味」。")
|
||||
public Map<String, Object> toiletAirqualityListQuery(
|
||||
@ToolParam(required = false, description = "设备 SN/mac,模糊匹配") String airmac,
|
||||
@ToolParam(required = false, description = "代理商 id,正整数,精确匹配") Long agentId,
|
||||
@ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId,
|
||||
@ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long districtId,
|
||||
@ToolParam(required = false, description = "楼 id,正整数,精确匹配") Long buildingId,
|
||||
@ToolParam(required = false, description = "楼层,精确匹配") String floor,
|
||||
@ToolParam(required = false, description = "设备状态:0=异常 1=正常") Integer state,
|
||||
@ToolParam(required = false, description = "运行状态:0=离线 1=在线") Integer runState,
|
||||
@ToolParam(required = false, description = "上线状态:0=初始化 1=上线 2=下线") Integer onlineState,
|
||||
@ToolParam(required = false, description = "硬件版本,精确匹配") String hardware,
|
||||
@ToolParam(required = false, description = "嵌入式版本,精确匹配") String firmware,
|
||||
@ToolParam(required = false, description = "语音版本,精确匹配") String protocol,
|
||||
@ToolParam(required = false, description = "物联网卡 imsi,精确匹配") String imsi,
|
||||
@ToolParam(required = false, description = "静音模式:0=关闭 1=开启") Integer muteFlag,
|
||||
@ToolParam(required = false, description = "定时震动功能:0=关闭 1=开启") Integer shockTimeFlag,
|
||||
@ToolParam(required = false, description = "指示灯状态:0=关闭 1=开启") Integer pilotLightFlag,
|
||||
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) {
|
||||
validatePositive(agentId, "agentId");
|
||||
validatePositive(propertyId, "propertyId");
|
||||
validatePositive(districtId, "districtId");
|
||||
validatePositive(buildingId, "buildingId");
|
||||
validateEnum(state, "state", 0, 1);
|
||||
validateEnum(runState, "runState", 0, 1);
|
||||
validateEnum(onlineState, "onlineState", 0, 1, 2);
|
||||
validateEnum(muteFlag, "muteFlag", 0, 1);
|
||||
validateEnum(shockTimeFlag, "shockTimeFlag", 0, 1);
|
||||
validateEnum(pilotLightFlag, "pilotLightFlag", 0, 1);
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
int pageNo = page == null || page < 1 ? 1 : page;
|
||||
boolean truncated = rows != null && rows > MAX_ROWS;
|
||||
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
|
||||
|
||||
LambdaQueryWrapper<ToiletAirquality> wrapper = new LambdaQueryWrapper<ToiletAirquality>()
|
||||
.eq(ToiletAirquality::getSoftDelete, 0);
|
||||
if (StringUtils.hasText(airmac)) {
|
||||
wrapper.like(ToiletAirquality::getAirmac, airmac.trim());
|
||||
}
|
||||
if (agentId != null) {
|
||||
wrapper.eq(ToiletAirquality::getAgentId, agentId);
|
||||
}
|
||||
if (propertyId != null) {
|
||||
wrapper.eq(ToiletAirquality::getPropertyId, propertyId);
|
||||
}
|
||||
if (districtId != null) {
|
||||
wrapper.eq(ToiletAirquality::getDistrictId, districtId);
|
||||
}
|
||||
if (buildingId != null) {
|
||||
wrapper.eq(ToiletAirquality::getBuildingId, buildingId);
|
||||
}
|
||||
if (StringUtils.hasText(floor)) {
|
||||
wrapper.eq(ToiletAirquality::getFloor, floor.trim());
|
||||
}
|
||||
if (state != null) {
|
||||
wrapper.eq(ToiletAirquality::getState, state);
|
||||
}
|
||||
if (runState != null) {
|
||||
wrapper.eq(ToiletAirquality::getRunState, runState);
|
||||
}
|
||||
if (onlineState != null) {
|
||||
wrapper.eq(ToiletAirquality::getOnlineState, onlineState);
|
||||
}
|
||||
if (StringUtils.hasText(hardware)) {
|
||||
wrapper.eq(ToiletAirquality::getHardware, hardware.trim());
|
||||
}
|
||||
if (StringUtils.hasText(firmware)) {
|
||||
wrapper.eq(ToiletAirquality::getFirmware, firmware.trim());
|
||||
}
|
||||
if (StringUtils.hasText(protocol)) {
|
||||
wrapper.eq(ToiletAirquality::getProtocol, protocol.trim());
|
||||
}
|
||||
if (StringUtils.hasText(imsi)) {
|
||||
wrapper.eq(ToiletAirquality::getImsi, imsi.trim());
|
||||
}
|
||||
if (muteFlag != null) {
|
||||
wrapper.eq(ToiletAirquality::getMuteFlag, muteFlag);
|
||||
}
|
||||
if (shockTimeFlag != null) {
|
||||
wrapper.eq(ToiletAirquality::getShockTimeFlag, shockTimeFlag);
|
||||
}
|
||||
if (pilotLightFlag != null) {
|
||||
wrapper.eq(ToiletAirquality::getPilotLightFlag, pilotLightFlag);
|
||||
}
|
||||
permissionService.applyToiletAirqualityRowFilter(wrapper, user);
|
||||
|
||||
Long total = toiletAirqualityMapper.selectCount(wrapper);
|
||||
wrapper.orderByDesc(ToiletAirquality::getId)
|
||||
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
|
||||
List<ToiletAirquality> list = toiletAirqualityMapper.selectList(wrapper);
|
||||
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
Enrichment enrichment = enrich(list);
|
||||
for (ToiletAirquality device : list) {
|
||||
records.add(toRecord(device, enrichment));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", total);
|
||||
result.put("page", pageNo);
|
||||
result.put("rows", pageSize);
|
||||
if (truncated) {
|
||||
result.put("notice", "rows 超过上限 100,已截断为 100");
|
||||
}
|
||||
result.put("records", records);
|
||||
return result;
|
||||
}
|
||||
|
||||
@XqTool(category = "toilet", kind = "read")
|
||||
@Tool(name = "toilet.airquality.detail.query", description = "按 id 或 airmac 查询当前用户可见的空感设备详情,二选一必填"
|
||||
+ "(airmac 精确匹配)。返回主档固定字段集 + agentName/propertyName/districtName、物联网卡 factoryType "
|
||||
+ "+ 关联站点信息 site(districtName、省市区县、详细地址、联系人、楼座/楼层)。"
|
||||
+ "设备不存在或无权访问时返回 record=null 并在 notice 说明(不区分,防探测)。")
|
||||
public Map<String, Object> toiletAirqualityDetailQuery(
|
||||
@ToolParam(required = false, description = "设备 id,正整数,精确匹配;与 airmac 二选一必填") Long id,
|
||||
@ToolParam(required = false, description = "设备 SN/mac,精确匹配;与 id 二选一必填") String airmac) {
|
||||
if (id == null && !StringUtils.hasText(airmac)) {
|
||||
throw new IllegalArgumentException("id 与 airmac 至少填一个");
|
||||
}
|
||||
validatePositive(id, "id");
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
LambdaQueryWrapper<ToiletAirquality> wrapper = new LambdaQueryWrapper<ToiletAirquality>()
|
||||
.eq(ToiletAirquality::getSoftDelete, 0);
|
||||
if (id != null) {
|
||||
wrapper.eq(ToiletAirquality::getId, id);
|
||||
} else {
|
||||
wrapper.eq(ToiletAirquality::getAirmac, airmac.trim());
|
||||
}
|
||||
permissionService.applyToiletAirqualityRowFilter(wrapper, user);
|
||||
ToiletAirquality device = toiletAirqualityMapper.selectOne(wrapper);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (device == null) {
|
||||
result.put("record", null);
|
||||
result.put("notice", "设备不存在或无权访问");
|
||||
return result;
|
||||
}
|
||||
Map<String, Object> record = toRecord(device, enrich(List.of(device)));
|
||||
record.put("site", siteInfo(device));
|
||||
result.put("record", record);
|
||||
return result;
|
||||
}
|
||||
|
||||
@XqTool(category = "toilet", kind = "read")
|
||||
@Tool(name = "toilet.airquality.metrics.query", description = "分页查询单台空感设备的指标上报明细"
|
||||
+ "(ES 云集群 toilet_airquality_detail_* 索引,对应老系统 /toiletAirQualityLog/listByPage)。"
|
||||
+ "airmac 必填(match_phrase 精确短语匹配);startDate/endDate 非必填 yyyy-MM-dd 闭区间,"
|
||||
+ "缺省查当天(与老系统一致),只填一个时另一个取同一天;endDate 不得晚于今天,区间最长 31 天。"
|
||||
+ "按 updateTime 倒序,page 默认 1、rows 默认 20、上限 100。"
|
||||
+ "record 含 updateTime 与 temp/hum/pm2d5/pm10/pm1d0/co2/tvoc/ammonia/hydrogensulfide/formaldehyde 十项指标原值"
|
||||
+ "(pm10 即老页面「烟感」、tvoc 即「异味」)。"
|
||||
+ "先经主档行权限校验 airmac 可见性,设备不存在或无权访问返回 NOT_FOUND_OR_FORBIDDEN。")
|
||||
public Map<String, Object> toiletAirqualityMetricsQuery(
|
||||
@ToolParam(description = "设备 SN/mac,必填,非空,精确匹配") String airmac,
|
||||
@ToolParam(required = false, description = "开始日期,yyyy-MM-dd,含当天;缺省=当天") String startDate,
|
||||
@ToolParam(required = false, description = "结束日期,yyyy-MM-dd,含当天,不早于 startDate、不得晚于今天;缺省=当天") String endDate,
|
||||
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认 20,上限 100,超出截断为 100") Integer rows) {
|
||||
if (!StringUtils.hasText(airmac)) {
|
||||
throw new IllegalArgumentException("airmac 必填,非空");
|
||||
}
|
||||
LocalDate start = parseOptionalDay(startDate, "startDate");
|
||||
LocalDate end = parseOptionalDay(endDate, "endDate");
|
||||
LocalDate today = LocalDate.now(SHANGHAI);
|
||||
// 缺省=当天(老系统口径);只填一个时另一个取同一天
|
||||
if (start == null && end == null) {
|
||||
start = today;
|
||||
end = today;
|
||||
} else if (start == null) {
|
||||
start = end;
|
||||
} else if (end == null) {
|
||||
end = start;
|
||||
}
|
||||
if (start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("startDate 不能晚于 endDate");
|
||||
}
|
||||
if (end.isAfter(today)) {
|
||||
throw new IllegalArgumentException("endDate 不得晚于今天");
|
||||
}
|
||||
if (ChronoUnit.DAYS.between(start, end) + 1 > MAX_QUERY_DAYS) {
|
||||
throw new IllegalArgumentException(
|
||||
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
|
||||
}
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
String trimmedAirmac = airmac.trim();
|
||||
requireVisible(trimmedAirmac, user);
|
||||
|
||||
int pageNo = page == null || page < 1 ? 1 : page;
|
||||
boolean truncatedRows = rows != null && rows > MAX_ROWS;
|
||||
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
|
||||
long fromLong = (long) (pageNo - 1) * pageSize;
|
||||
String windowNotice = null;
|
||||
if (fromLong >= MAX_RESULT_WINDOW) {
|
||||
// from+size 超出 ES max_result_window 会被 ES 拒绝,截断为窗口内最后可用的完整页
|
||||
fromLong = MAX_RESULT_WINDOW - pageSize;
|
||||
windowNotice = "查询起始位置超过 ES max_result_window(10000),已截断为第 "
|
||||
+ (fromLong / pageSize + 1) + " 页起的最后可用窗口";
|
||||
} else if (fromLong + pageSize > MAX_RESULT_WINDOW) {
|
||||
windowNotice = "本页超出 ES max_result_window(10000),已截断为 " + (MAX_RESULT_WINDOW - fromLong) + " 条";
|
||||
pageSize = MAX_RESULT_WINDOW - (int) fromLong;
|
||||
}
|
||||
|
||||
long gteMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
|
||||
long lteMillis = end.plusDays(1).atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
|
||||
ToiletAirqualityMetricsEsGateway.Result search = metricsEsGateway.search(
|
||||
METRICS_INDEX, trimmedAirmac, gteMillis, lteMillis, (int) fromLong, pageSize);
|
||||
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
for (ToiletAirqualityMetricsEsGateway.Record record : search.getRecords()) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("updateTime", record.getUpdateTime());
|
||||
row.put("temp", record.getTemp());
|
||||
row.put("hum", record.getHum());
|
||||
row.put("pm2d5", record.getPm2d5());
|
||||
row.put("pm10", record.getPm10());
|
||||
row.put("pm1d0", record.getPm1d0());
|
||||
row.put("co2", record.getCo2());
|
||||
row.put("tvoc", record.getTvoc());
|
||||
row.put("ammonia", record.getAmmonia());
|
||||
row.put("hydrogensulfide", record.getHydrogensulfide());
|
||||
row.put("formaldehyde", record.getFormaldehyde());
|
||||
records.add(row);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("airmac", trimmedAirmac);
|
||||
result.put("index", METRICS_INDEX);
|
||||
result.put("total", search.getTotal());
|
||||
result.put("page", pageNo);
|
||||
result.put("rows", pageSize);
|
||||
String notice = notice(truncatedRows, windowNotice);
|
||||
if (notice != null) {
|
||||
result.put("notice", notice);
|
||||
}
|
||||
result.put("records", records);
|
||||
return result;
|
||||
}
|
||||
|
||||
@XqTool(category = "toilet", kind = "read")
|
||||
@Tool(name = "toilet.airquality.statelog.list.query", description = "分页查询单台空感设备的状态变更日志"
|
||||
+ "(m0 toilet_airquality_log,对应老系统 /toiletAirQualityLog/logListByPage)。"
|
||||
+ "airmac 必填(精确匹配);startUpdateTime/endUpdateTime 可选 yyyy-MM-dd 闭区间,缺省不过滤;"
|
||||
+ "state 可选(0=异常 1=正常 2=离线 3=在线)。按 updateTime 倒序、id 倒序,"
|
||||
+ "page 默认 1、rows 默认 20、上限 100。"
|
||||
+ "先经主档行权限校验 airmac 可见性,设备不存在或无权访问返回 NOT_FOUND_OR_FORBIDDEN。")
|
||||
public Map<String, Object> toiletAirqualityStatelogListQuery(
|
||||
@ToolParam(description = "设备 SN/mac,必填,非空,精确匹配") String airmac,
|
||||
@ToolParam(required = false, description = "变更时间起,yyyy-MM-dd,含当天;缺省不过滤") String startUpdateTime,
|
||||
@ToolParam(required = false, description = "变更时间止,yyyy-MM-dd,含当天,不早于 startUpdateTime;缺省不过滤") String endUpdateTime,
|
||||
@ToolParam(required = false, description = "状态:0=异常 1=正常 2=离线 3=在线") Integer state,
|
||||
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) {
|
||||
if (!StringUtils.hasText(airmac)) {
|
||||
throw new IllegalArgumentException("airmac 必填,非空");
|
||||
}
|
||||
LocalDate start = parseOptionalDay(startUpdateTime, "startUpdateTime");
|
||||
LocalDate end = parseOptionalDay(endUpdateTime, "endUpdateTime");
|
||||
if (start != null && end != null && start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("startUpdateTime 不能晚于 endUpdateTime");
|
||||
}
|
||||
validateEnum(state, "state", 0, 1, 2, 3);
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
String trimmedAirmac = airmac.trim();
|
||||
requireVisible(trimmedAirmac, user);
|
||||
|
||||
int pageNo = page == null || page < 1 ? 1 : page;
|
||||
boolean truncated = rows != null && rows > MAX_ROWS;
|
||||
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
|
||||
|
||||
LambdaQueryWrapper<ToiletAirqualityLog> wrapper = new LambdaQueryWrapper<ToiletAirqualityLog>()
|
||||
.eq(ToiletAirqualityLog::getSoftDelete, 0)
|
||||
.eq(ToiletAirqualityLog::getAirmac, trimmedAirmac);
|
||||
if (state != null) {
|
||||
wrapper.eq(ToiletAirqualityLog::getState, state);
|
||||
}
|
||||
if (start != null) {
|
||||
wrapper.ge(ToiletAirqualityLog::getUpdateTime,
|
||||
Date.from(start.atStartOfDay(SHANGHAI).toInstant()));
|
||||
}
|
||||
if (end != null) {
|
||||
wrapper.lt(ToiletAirqualityLog::getUpdateTime,
|
||||
Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant()));
|
||||
}
|
||||
|
||||
Long total = toiletAirqualityLogMapper.selectCount(wrapper);
|
||||
wrapper.orderByDesc(ToiletAirqualityLog::getUpdateTime)
|
||||
.orderByDesc(ToiletAirqualityLog::getId)
|
||||
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
|
||||
List<Map<String, Object>> records = toiletAirqualityLogMapper.selectList(wrapper).stream()
|
||||
.map(this::stateLogRecord).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("airmac", trimmedAirmac);
|
||||
result.put("total", total);
|
||||
result.put("page", pageNo);
|
||||
result.put("rows", pageSize);
|
||||
if (truncated) {
|
||||
result.put("notice", "rows 超过上限 100,已截断为 100");
|
||||
}
|
||||
result.put("records", records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** metrics/statelog 无权限列:先经主档行过滤校验 airmac 可见性,不可见直接拒绝(补老接口权限漏洞)。 */
|
||||
private ToiletAirquality requireVisible(String airmac, User user) {
|
||||
LambdaQueryWrapper<ToiletAirquality> wrapper = new LambdaQueryWrapper<ToiletAirquality>()
|
||||
.eq(ToiletAirquality::getSoftDelete, 0)
|
||||
.eq(ToiletAirquality::getAirmac, airmac);
|
||||
permissionService.applyToiletAirqualityRowFilter(wrapper, user);
|
||||
ToiletAirquality device = toiletAirqualityMapper.selectOne(wrapper);
|
||||
if (device == null) {
|
||||
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "设备不存在或无权访问");
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
/** 站点信息:district 表现有字段;楼座/楼层取主档。 */
|
||||
private Map<String, Object> siteInfo(ToiletAirquality device) {
|
||||
Map<String, Object> site = new LinkedHashMap<>();
|
||||
DistrictDistrictStrategy district = device.getDistrictId() == null
|
||||
? null : districtMapper.selectById(device.getDistrictId());
|
||||
site.put("districtId", device.getDistrictId());
|
||||
site.put("districtName", district == null ? null : district.getDistrictName());
|
||||
site.put("province", district == null ? null : district.getProvince());
|
||||
site.put("city", district == null ? null : district.getCity());
|
||||
site.put("countyseat", district == null ? null : district.getCountyseat());
|
||||
site.put("address", district == null ? null : district.getAddress());
|
||||
site.put("contact", district == null ? null : district.getContact());
|
||||
site.put("phone", district == null ? null : district.getPhone());
|
||||
site.put("buildingId", device.getBuildingId());
|
||||
site.put("floor", device.getFloor());
|
||||
return site;
|
||||
}
|
||||
|
||||
/** 批量富化上下文:Redis 名称 multiGet + 本页物联网卡 IN 查询,一次查询替代老接口 N+1。 */
|
||||
private Enrichment enrich(List<ToiletAirquality> list) {
|
||||
Enrichment enrichment = new Enrichment();
|
||||
if (list.isEmpty()) {
|
||||
return enrichment;
|
||||
}
|
||||
enrichment.agentNames = redisNames(list, ToiletAirquality::getAgentId, NAME_AGENT_PREFIX);
|
||||
enrichment.propertyNames = redisNames(list, ToiletAirquality::getPropertyId, NAME_PROPERTY_PREFIX);
|
||||
enrichment.districtNames = redisNames(list, ToiletAirquality::getDistrictId, NAME_DISTRICT_PREFIX);
|
||||
enrichment.networkcards = networkcards(list);
|
||||
return enrichment;
|
||||
}
|
||||
|
||||
private Map<Long, String> redisNames(List<ToiletAirquality> list, Function<ToiletAirquality, Long> idGetter,
|
||||
String prefix) {
|
||||
List<Long> ids = list.stream().map(idGetter)
|
||||
.filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<String> keys = ids.stream().map(id -> prefix + id).collect(Collectors.toList());
|
||||
List<String> values = redisTemplate.opsForValue().multiGet(keys);
|
||||
Map<Long, String> names = new LinkedHashMap<>();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
names.put(ids.get(i), values == null || i >= values.size() ? null : values.get(i));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 本页非空 imsi 一次性 IN 查 networkcard(老接口按条 limit 1,取首条语义保留)。 */
|
||||
private Map<String, Networkcard> networkcards(List<ToiletAirquality> list) {
|
||||
List<String> imsis = list.stream().map(ToiletAirquality::getImsi)
|
||||
.filter(StringUtils::hasText).distinct().collect(Collectors.toList());
|
||||
if (imsis.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return networkcardMapper.selectList(new LambdaQueryWrapper<Networkcard>()
|
||||
.eq(Networkcard::getSoftDelete, 0)
|
||||
.in(Networkcard::getImsi, imsis))
|
||||
.stream().collect(Collectors.toMap(Networkcard::getImsi, card -> card, (a, b) -> a));
|
||||
}
|
||||
|
||||
private Map<String, Object> toRecord(ToiletAirquality d, Enrichment enrichment) {
|
||||
Map<String, Object> record = new LinkedHashMap<>();
|
||||
record.put("id", d.getId());
|
||||
record.put("airmac", d.getAirmac());
|
||||
record.put("state", d.getState());
|
||||
record.put("stateText", text(d.getState(), "异常", "正常"));
|
||||
record.put("runState", d.getRunState());
|
||||
record.put("runStateText", text(d.getRunState(), "离线", "在线"));
|
||||
record.put("onlineState", d.getOnlineState());
|
||||
record.put("onlineStateText", text(d.getOnlineState(), "初始化", "上线", "下线"));
|
||||
record.put("onlineTime", format(d.getOnlineTime()));
|
||||
record.put("agentId", d.getAgentId());
|
||||
record.put("agentName", mapGet(enrichment.agentNames, d.getAgentId()));
|
||||
record.put("propertyId", d.getPropertyId());
|
||||
record.put("propertyName", mapGet(enrichment.propertyNames, d.getPropertyId()));
|
||||
record.put("districtId", d.getDistrictId());
|
||||
record.put("districtName", mapGet(enrichment.districtNames, d.getDistrictId()));
|
||||
record.put("buildingId", d.getBuildingId());
|
||||
record.put("floor", d.getFloor());
|
||||
record.put("installPosition", d.getInstallPosition());
|
||||
record.put("toiletId", d.getToiletId());
|
||||
record.put("positionId", d.getPositionId());
|
||||
record.put("temp", d.getTemp());
|
||||
record.put("hum", d.getHum());
|
||||
record.put("pm2d5", d.getPm2d5());
|
||||
record.put("pm10", d.getPm10());
|
||||
record.put("pm1d0", d.getPm1d0());
|
||||
record.put("co2", d.getCo2());
|
||||
record.put("tvoc", d.getTvoc());
|
||||
record.put("ammonia", d.getAmmonia());
|
||||
record.put("hydrogensulfide", d.getHydrogensulfide());
|
||||
record.put("formaldehyde", d.getFormaldehyde());
|
||||
record.put("hardware", d.getHardware());
|
||||
record.put("firmware", d.getFirmware());
|
||||
record.put("protocol", d.getProtocol());
|
||||
record.put("upInterval", d.getUpInterval());
|
||||
record.put("imsi", d.getImsi());
|
||||
record.put("signalValue", d.getSignalValue());
|
||||
// 物联网卡覆盖主档 factoryType(对齐老列表 :153 语义)
|
||||
Networkcard card = mapGet(enrichment.networkcards, d.getImsi());
|
||||
Integer factoryType = card == null ? d.getFactoryType() : card.getFactoryType();
|
||||
record.put("factoryType", factoryType);
|
||||
record.put("factoryTypeText", text(factoryType, "未知", "合宙", "合宙", "SIMBOOS"));
|
||||
record.put("voiceFlag", d.getVoiceFlag());
|
||||
record.put("voiceFlagText", text(d.getVoiceFlag(), "不支持", "支持"));
|
||||
record.put("shockFlag", d.getShockFlag());
|
||||
record.put("shockFlagText", text(d.getShockFlag(), "不支持", "支持"));
|
||||
record.put("muteFlag", d.getMuteFlag());
|
||||
record.put("muteFlagText", text(d.getMuteFlag(), "关闭", "开启"));
|
||||
record.put("shockTimeFlag", d.getShockTimeFlag());
|
||||
record.put("shockTimeFlagText", text(d.getShockTimeFlag(), "关闭", "开启"));
|
||||
record.put("pilotLightFlag", d.getPilotLightFlag());
|
||||
record.put("pilotLightFlagText", text(d.getPilotLightFlag(), "关闭", "开启"));
|
||||
record.put("createTime", format(d.getCreateTime()));
|
||||
record.put("modifyTime", format(d.getModifyTime()));
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<String, Object> stateLogRecord(ToiletAirqualityLog log) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("updateTime", format(log.getUpdateTime()));
|
||||
row.put("state", log.getState());
|
||||
row.put("stateText", text(log.getState(), "异常", "正常", "离线", "在线"));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static final class Enrichment {
|
||||
Map<Long, String> agentNames = Map.of();
|
||||
Map<Long, String> propertyNames = Map.of();
|
||||
Map<Long, String> districtNames = Map.of();
|
||||
Map<String, Networkcard> networkcards = Map.of();
|
||||
}
|
||||
|
||||
private static <K, V> V mapGet(Map<K, V> map, K key) {
|
||||
return key == null ? null : map.get(key);
|
||||
}
|
||||
|
||||
private static String text(Integer value, String... labels) {
|
||||
return value != null && value >= 0 && value < labels.length ? labels[value] : null;
|
||||
}
|
||||
|
||||
private static String format(Date value) {
|
||||
return value == null ? null : TIME_FORMATTER.format(value.toInstant());
|
||||
}
|
||||
|
||||
private static LocalDate parseOptionalDay(String value, String name) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String notice(boolean truncatedRows, String windowNotice) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (truncatedRows) {
|
||||
builder.append("rows 超过上限 100,已截断为 100");
|
||||
}
|
||||
if (windowNotice != null) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(";");
|
||||
}
|
||||
builder.append(windowNotice);
|
||||
}
|
||||
return builder.length() == 0 ? null : builder.toString();
|
||||
}
|
||||
|
||||
private static void validatePositive(Long value, String name) {
|
||||
if (value != null && value < 1) {
|
||||
throw new IllegalArgumentException(name + " 必须为正整数");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateEnum(Integer value, String name, int... allowed) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
for (int candidate : allowed) {
|
||||
if (value == candidate) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
String options = Arrays.stream(allowed).mapToObj(String::valueOf)
|
||||
.collect(Collectors.joining("、"));
|
||||
throw new IllegalArgumentException(name + " 仅支持 " + options);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import me.iiv.agent.entity.m0.BatteryFaultLog;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.HardwareView;
|
||||
import me.iiv.agent.entity.m0.Properyt;
|
||||
import me.iiv.agent.entity.m0.ToiletAirquality;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m1.MallGoodsOrder;
|
||||
import me.iiv.agent.entity.m2.ContractNew;
|
||||
@@ -858,6 +859,29 @@ public class PermissionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空感(toilet_airquality)行权限:逐行镜像 applyHardwareRowFilter——
|
||||
* 老列表 /toiletAirQuality/listByPage 用的就是同一 getByIsAgent 20250401 变体
|
||||
* (propertyFlag=true, className="property_id",ToiletAirQualityController.java:122),
|
||||
* 落到 toilet_airquality 的 agent_id / property_id 字段上,语义 1:1 对齐。
|
||||
*/
|
||||
public void applyToiletAirqualityRowFilter(LambdaQueryWrapper<ToiletAirquality> wrapper, User user) {
|
||||
RowScope scope = resolveRowScope(user);
|
||||
if (scope.headquarters) {
|
||||
return;
|
||||
}
|
||||
if (scope.salesPropertyIds != null) {
|
||||
wrapper.in(ToiletAirquality::getPropertyId, scope.salesPropertyIds);
|
||||
return;
|
||||
}
|
||||
if (!scope.managedPropertyIds.isEmpty()) {
|
||||
wrapper.and(w -> w.in(ToiletAirquality::getAgentId, scope.ids)
|
||||
.or().in(ToiletAirquality::getPropertyId, scope.managedPropertyIds));
|
||||
} else {
|
||||
wrapper.in(ToiletAirquality::getAgentId, scope.ids);
|
||||
}
|
||||
}
|
||||
|
||||
/** 电池故障日志行权限:与出纸机列表复用同一代理/物业可见范围。 */
|
||||
public void applyBatteryFaultLogRowFilter(LambdaQueryWrapper<BatteryFaultLog> wrapper, User user) {
|
||||
RowScope scope = resolveRowScope(user);
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.elasticsearch.client.Request;
|
||||
import org.elasticsearch.client.Response;
|
||||
import org.elasticsearch.client.RestClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
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.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ToiletAirqualityMetricsEsGatewayImplTest {
|
||||
|
||||
private static final String RESPONSE_JSON = "{"
|
||||
+ "\"took\":1,\"timed_out\":false,"
|
||||
+ "\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},"
|
||||
+ "\"hits\":{\"total\":{\"value\":2,\"relation\":\"eq\"},"
|
||||
+ "\"hits\":["
|
||||
+ "{\"_index\":\"toilet_airquality_detail_2026\","
|
||||
+ "\"_source\":{\"airmac\":\"AIR-001\",\"updateTime\":\"2026-10-12T10:00:00.000+08:00\","
|
||||
+ "\"temp\":\"26.5\",\"hum\":60,\"pm2d5\":12.0,\"pm10\":20.0,\"pm1d0\":8.0,"
|
||||
+ "\"co2\":\"600\",\"tvoc\":0.3,\"ammonia\":0.1,\"hydrogensulfide\":0.01,\"formaldehyde\":\"0.05\"}},"
|
||||
+ "{\"_index\":\"toilet_airquality_detail_2026\","
|
||||
+ "\"_source\":{\"airmac\":\"AIR-001\",\"updateTime\":1790000000000,\"temp\":null,\"hum\":\"abc\"}}"
|
||||
+ "]}}";
|
||||
|
||||
@Mock
|
||||
RestClient cloudRestClient;
|
||||
|
||||
ToiletAirqualityMetricsEsGatewayImpl gateway;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
gateway = new ToiletAirqualityMetricsEsGatewayImpl(cloudRestClient);
|
||||
}
|
||||
|
||||
private static Response esResponse(String jsonBody) throws IOException {
|
||||
Response response = org.mockito.Mockito.mock(Response.class);
|
||||
when(response.getEntity()).thenReturn(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));
|
||||
return response;
|
||||
}
|
||||
|
||||
private JsonNode captureRequestBody(String airmac) throws Exception {
|
||||
Response esResponse = esResponse(RESPONSE_JSON);
|
||||
when(cloudRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
gateway.search("toilet_airquality_detail_*", airmac, 1789920000000L, 1790006399000L, 0, 20);
|
||||
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
|
||||
verify(cloudRestClient).performRequest(captor.capture());
|
||||
Request captured = captor.getValue();
|
||||
assertEquals("POST", captured.getMethod());
|
||||
assertEquals("/toilet_airquality_detail_*/_search", captured.getEndpoint());
|
||||
return mapper.readTree(EntityUtils.toString(captured.getEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsMatchPhraseRangeSortDescAndPagination() throws Exception {
|
||||
JsonNode body = captureRequestBody("AIR-001");
|
||||
|
||||
assertEquals(0, body.get("from").asInt());
|
||||
assertEquals(20, body.get("size").asInt());
|
||||
assertTrue(body.get("track_total_hits").asBoolean());
|
||||
assertEquals("desc", body.get("sort").get(0).get("updateTime").get("order").asText());
|
||||
|
||||
JsonNode filters = body.at("/query/bool/filter");
|
||||
// airmac 必须 match_phrase(老系统 matchQuery 分词误配的修复)
|
||||
assertTrue(anyFilter(filters, f -> f.has("match_phrase")
|
||||
&& f.get("match_phrase").get("airmac").get("query").asText().equals("AIR-001")));
|
||||
// updateTime 闭区间 range 发 epoch 毫秒
|
||||
assertTrue(anyFilter(filters, f -> f.has("range")
|
||||
&& f.get("range").get("updateTime").get("gte").asLong() == 1789920000000L
|
||||
&& f.get("range").get("updateTime").get("lte").asLong() == 1790006399000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsHitsWithEpochAndIsoUpdateTimeAndTextMetrics() throws Exception {
|
||||
Response esResponse = esResponse(RESPONSE_JSON);
|
||||
when(cloudRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
|
||||
ToiletAirqualityMetricsEsGateway.Result result = gateway.search(
|
||||
"toilet_airquality_detail_*", "AIR-001", 1789920000000L, 1790006399000L, 0, 20);
|
||||
|
||||
assertEquals(2, result.getTotal());
|
||||
assertEquals(2, result.getRecords().size());
|
||||
|
||||
ToiletAirqualityMetricsEsGateway.Record first = result.getRecords().get(0);
|
||||
// ISO 文本归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss
|
||||
assertEquals("2026-10-12 10:00:00", first.getUpdateTime());
|
||||
// 文本/数值双形态归一为 Double
|
||||
assertEquals(26.5, first.getTemp());
|
||||
assertEquals(60.0, first.getHum());
|
||||
assertEquals(600.0, first.getCo2());
|
||||
assertEquals(0.05, first.getFormaldehyde());
|
||||
|
||||
ToiletAirqualityMetricsEsGateway.Record second = result.getRecords().get(1);
|
||||
// epoch 毫秒归一为文本(1790000000000 = 2026-09-21 22:13:20 Asia/Shanghai)
|
||||
assertEquals("2026-09-21 22:13:20", second.getUpdateTime());
|
||||
// null 与非数值文本归一为 null
|
||||
assertNull(second.getTemp());
|
||||
assertNull(second.getHum());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapsEsFailureInIllegalStateException() throws Exception {
|
||||
when(cloudRestClient.performRequest(any(Request.class)))
|
||||
.thenThrow(new IOException("connection refused"));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> gateway.search(
|
||||
"toilet_airquality_detail_*", "AIR-001", 1789920000000L, 1790006399000L, 0, 20));
|
||||
}
|
||||
|
||||
private static boolean anyFilter(JsonNode filters, java.util.function.Predicate<JsonNode> predicate) {
|
||||
for (JsonNode filter : filters) {
|
||||
if (predicate.test(filter)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.Networkcard;
|
||||
import me.iiv.agent.entity.m0.ToiletAirquality;
|
||||
import me.iiv.agent.entity.m0.ToiletAirqualityLog;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.es.ToiletAirqualityMetricsEsGateway;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.NetworkcardMapper;
|
||||
import me.iiv.agent.mapper.m0.ToiletAirqualityLogMapper;
|
||||
import me.iiv.agent.mapper.m0.ToiletAirqualityMapper;
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
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.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ToiletAirqualityToolsTest {
|
||||
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Mock
|
||||
ToiletAirqualityMapper toiletAirqualityMapper;
|
||||
@Mock
|
||||
ToiletAirqualityLogMapper toiletAirqualityLogMapper;
|
||||
@Mock
|
||||
NetworkcardMapper networkcardMapper;
|
||||
@Mock
|
||||
DistrictDistrictStrategyMapper districtMapper;
|
||||
@Mock
|
||||
PermissionService permissionService;
|
||||
@Mock
|
||||
StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
ToiletAirqualityMetricsEsGateway metricsEsGateway;
|
||||
|
||||
ToiletAirqualityTools tools;
|
||||
User user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
ToiletAirquality.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
ToiletAirqualityLog.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
Networkcard.class);
|
||||
tools = new ToiletAirqualityTools(toiletAirqualityMapper, toiletAirqualityLogMapper,
|
||||
networkcardMapper, districtMapper, permissionService, redisTemplate, metricsEsGateway);
|
||||
user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void listBuildsFiltersStableSortAndEnrichment() {
|
||||
ToiletAirquality device = new ToiletAirquality();
|
||||
device.setId(9L);
|
||||
device.setAirmac("AIR-001");
|
||||
device.setState(1);
|
||||
device.setRunState(1);
|
||||
device.setOnlineState(1);
|
||||
device.setAgentId(12L);
|
||||
device.setPropertyId(11L);
|
||||
device.setDistrictId(5L);
|
||||
device.setImsi("460001234567890");
|
||||
device.setFactoryType(0);
|
||||
device.setMuteFlag(1);
|
||||
when(toiletAirqualityMapper.selectCount(any())).thenReturn(1L);
|
||||
when(toiletAirqualityMapper.selectList(any())).thenReturn(List.of(device));
|
||||
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
// name:Agent:12 / name:Properyt:11 / name:District:5
|
||||
when(valueOperations.multiGet(anyList())).thenAnswer(invocation -> {
|
||||
List<String> keys = invocation.getArgument(0);
|
||||
return keys.stream().map(key -> switch (key) {
|
||||
case "name:Agent:12" -> "测试代理";
|
||||
case "name:Properyt:11" -> "测试物业";
|
||||
case "name:District:5" -> "测试站点";
|
||||
default -> null;
|
||||
}).collect(Collectors.toList());
|
||||
});
|
||||
Networkcard card = new Networkcard();
|
||||
card.setImsi("460001234567890");
|
||||
card.setFactoryType(3);
|
||||
when(networkcardMapper.selectList(any())).thenReturn(List.of(card));
|
||||
|
||||
Map<String, Object> result = tools.toiletAirqualityListQuery(
|
||||
"AIR", 12L, 11L, 5L, 3L, "3F", 1, 1, 1, "hw1", "fw1", "pt1",
|
||||
"460001234567890", 1, 0, 1, 1, 500);
|
||||
|
||||
assertEquals(1, result.get("page"));
|
||||
assertEquals(100, result.get("rows"));
|
||||
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<ToiletAirquality>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(toiletAirqualityMapper).selectList(captor.capture());
|
||||
String sql = captor.getValue().getSqlSegment();
|
||||
assertTrue(sql.contains("airmac LIKE"));
|
||||
assertTrue(sql.contains("agent_id ="));
|
||||
assertTrue(sql.contains("property_id ="));
|
||||
assertTrue(sql.contains("district_id ="));
|
||||
assertTrue(sql.contains("building_id ="));
|
||||
assertTrue(sql.contains("floor ="));
|
||||
assertTrue(sql.contains("state ="));
|
||||
assertTrue(sql.contains("run_state ="));
|
||||
assertTrue(sql.contains("online_state ="));
|
||||
assertTrue(sql.contains("hardware ="));
|
||||
assertTrue(sql.contains("firmware ="));
|
||||
assertTrue(sql.contains("protocol ="));
|
||||
assertTrue(sql.contains("imsi ="));
|
||||
assertTrue(sql.contains("mute_flag ="));
|
||||
assertTrue(sql.contains("shock_time_flag ="));
|
||||
assertTrue(sql.contains("pilot_light_flag ="));
|
||||
assertTrue(sql.contains("soft_delete ="));
|
||||
assertTrue(sql.contains("ORDER BY id DESC"));
|
||||
verify(permissionService).applyToiletAirqualityRowFilter(any(), any());
|
||||
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals("测试代理", record.get("agentName"));
|
||||
assertEquals("测试物业", record.get("propertyName"));
|
||||
assertEquals("测试站点", record.get("districtName"));
|
||||
assertEquals("正常", record.get("stateText"));
|
||||
assertEquals("在线", record.get("runStateText"));
|
||||
assertEquals("上线", record.get("onlineStateText"));
|
||||
assertEquals("开启", record.get("muteFlagText"));
|
||||
// factoryType 被 networkcard 覆盖(回归老 :153 语义)
|
||||
assertEquals(3, record.get("factoryType"));
|
||||
assertEquals("SIMBOOS", record.get("factoryTypeText"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void listKeepsMainFactoryTypeAndNullNamesWhenEnrichmentMissing() {
|
||||
ToiletAirquality device = new ToiletAirquality();
|
||||
device.setId(9L);
|
||||
device.setAirmac("AIR-002");
|
||||
device.setFactoryType(1);
|
||||
when(toiletAirqualityMapper.selectCount(any())).thenReturn(1L);
|
||||
when(toiletAirqualityMapper.selectList(any())).thenReturn(List.of(device));
|
||||
|
||||
// 设备无 agentId/propertyId/districtId,不触发 Redis 名称查询
|
||||
|
||||
Map<String, Object> result = tools.toiletAirqualityListQuery(
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, 1, 20);
|
||||
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals(1, record.get("factoryType"));
|
||||
assertEquals("合宙", record.get("factoryTypeText"));
|
||||
assertNull(record.get("agentName"));
|
||||
// 无 imsi 不查物联网卡
|
||||
verify(networkcardMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRejectsInvalidEnumsAndIdsBeforeQuerying() {
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery(
|
||||
null, 0L, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery(
|
||||
null, null, null, null, null, null, 2, null, null, null, null, null,
|
||||
null, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery(
|
||||
null, null, null, null, null, null, null, null, 3, null, null, null,
|
||||
null, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery(
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, 2, null, null, 1, 20));
|
||||
verifyNoInteractions(toiletAirqualityMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailRequiresIdOrAirmac() {
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityDetailQuery(null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityDetailQuery(0L, " "));
|
||||
verifyNoInteractions(toiletAirqualityMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReturnsNullRecordWithNoticeWhenMissingOrUnauthorized() {
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
Map<String, Object> result = tools.toiletAirqualityDetailQuery(null, "AIR-404");
|
||||
|
||||
assertNull(result.get("record"));
|
||||
assertEquals("设备不存在或无权访问", result.get("notice"));
|
||||
verify(permissionService).applyToiletAirqualityRowFilter(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void detailByIdReturnsRecordWithSite() {
|
||||
ToiletAirquality device = new ToiletAirquality();
|
||||
device.setId(9L);
|
||||
device.setAirmac("AIR-001");
|
||||
device.setDistrictId(5L);
|
||||
device.setBuildingId(3L);
|
||||
device.setFloor("3F");
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(device);
|
||||
|
||||
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.multiGet(anyList())).thenReturn(List.of());
|
||||
|
||||
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
|
||||
district.setId(5L);
|
||||
district.setDistrictName("测试站点");
|
||||
district.setProvince("广东省");
|
||||
district.setCity("深圳市");
|
||||
district.setCountyseat("南山区");
|
||||
district.setAddress("科技园");
|
||||
district.setContact("张三");
|
||||
district.setPhone("13800000000");
|
||||
when(districtMapper.selectById(5L)).thenReturn(district);
|
||||
|
||||
Map<String, Object> result = tools.toiletAirqualityDetailQuery(9L, null);
|
||||
|
||||
Map<String, Object> record = (Map<String, Object>) result.get("record");
|
||||
assertEquals("AIR-001", record.get("airmac"));
|
||||
Map<String, Object> site = (Map<String, Object>) record.get("site");
|
||||
assertEquals(5L, site.get("districtId"));
|
||||
assertEquals("测试站点", site.get("districtName"));
|
||||
assertEquals("广东省", site.get("province"));
|
||||
assertEquals("深圳市", site.get("city"));
|
||||
assertEquals("南山区", site.get("countyseat"));
|
||||
assertEquals("科技园", site.get("address"));
|
||||
assertEquals("张三", site.get("contact"));
|
||||
assertEquals("13800000000", site.get("phone"));
|
||||
assertEquals(3L, site.get("buildingId"));
|
||||
assertEquals("3F", site.get("floor"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsRequiresAirmac() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityMetricsQuery(" ", null, null, 1, 20));
|
||||
verifyNoInteractions(toiletAirqualityMapper, metricsEsGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsRejectsInvalidDateRange() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityMetricsQuery("AIR-001", "2026-10-11", "2026-10-10", 1, 20));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityMetricsQuery("AIR-001", null, "2999-01-01", 1, 20));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityMetricsQuery("AIR-001", "2026-08-01", "2026-09-15", 1, 20));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityMetricsQuery("AIR-001", "2026/10/01", null, 1, 20));
|
||||
verifyNoInteractions(toiletAirqualityMapper, metricsEsGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsRejectsInvisibleAirmac() {
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
ToolCallException exception = assertThrows(ToolCallException.class,
|
||||
() -> tools.toiletAirqualityMetricsQuery("AIR-404", null, null, 1, 20));
|
||||
|
||||
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, exception.getCode());
|
||||
verify(permissionService).applyToiletAirqualityRowFilter(any(), any());
|
||||
verifyNoInteractions(metricsEsGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void metricsDefaultsToTodayAndMapsRecords() {
|
||||
ToiletAirquality device = new ToiletAirquality();
|
||||
device.setId(9L);
|
||||
device.setAirmac("AIR-001");
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(device);
|
||||
when(metricsEsGateway.search(anyString(), anyString(), anyLong(), anyLong(),
|
||||
eq(0), eq(20))).thenReturn(new ToiletAirqualityMetricsEsGateway.Result(1,
|
||||
List.of(new ToiletAirqualityMetricsEsGateway.Record("2026-10-12 10:00:00",
|
||||
26.5, 60.0, 12.0, 20.0, 8.0, 600.0, 0.3, 0.1, 0.01, 0.05))));
|
||||
|
||||
Map<String, Object> result = tools.toiletAirqualityMetricsQuery("AIR-001", null, null, 1, 20);
|
||||
|
||||
// 缺省=当天闭区间(epoch 毫秒)
|
||||
LocalDate today = LocalDate.now(SHANGHAI);
|
||||
long expectedGte = today.atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
|
||||
long expectedLte = today.plusDays(1).atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
|
||||
verify(metricsEsGateway).search(eq("toilet_airquality_detail_*"), eq("AIR-001"),
|
||||
eq(expectedGte), eq(expectedLte), eq(0), eq(20));
|
||||
|
||||
assertEquals("AIR-001", result.get("airmac"));
|
||||
assertEquals("toilet_airquality_detail_*", result.get("index"));
|
||||
assertEquals(1L, result.get("total"));
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals("2026-10-12 10:00:00", record.get("updateTime"));
|
||||
assertEquals(26.5, record.get("temp"));
|
||||
assertEquals(0.05, record.get("formaldehyde"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsSingleDateFillsTheOtherSide() {
|
||||
ToiletAirquality device = new ToiletAirquality();
|
||||
device.setId(9L);
|
||||
device.setAirmac("AIR-001");
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(device);
|
||||
when(metricsEsGateway.search(anyString(), anyString(), anyLong(), anyLong(),
|
||||
eq(0), eq(20))).thenReturn(new ToiletAirqualityMetricsEsGateway.Result(0, List.of()));
|
||||
|
||||
tools.toiletAirqualityMetricsQuery("AIR-001", "2026-10-01", null, 1, 20);
|
||||
|
||||
long expectedGte = LocalDate.parse("2026-10-01").atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
|
||||
long expectedLte = LocalDate.parse("2026-10-02").atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
|
||||
verify(metricsEsGateway).search(anyString(), anyString(), eq(expectedGte), eq(expectedLte), eq(0), eq(20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void statelogRequiresAirmacAndValidatesParams() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityStatelogListQuery(null, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityStatelogListQuery("AIR-001", "2026-10-11", "2026-10-10", null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.toiletAirqualityStatelogListQuery("AIR-001", null, null, 4, 1, 20));
|
||||
verifyNoInteractions(toiletAirqualityMapper, toiletAirqualityLogMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statelogRejectsInvisibleAirmac() {
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
ToolCallException exception = assertThrows(ToolCallException.class,
|
||||
() -> tools.toiletAirqualityStatelogListQuery("AIR-404", null, null, null, 1, 20));
|
||||
|
||||
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, exception.getCode());
|
||||
verifyNoInteractions(toiletAirqualityLogMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void statelogBuildsFiltersAndMapsStateText() {
|
||||
ToiletAirquality device = new ToiletAirquality();
|
||||
device.setId(9L);
|
||||
device.setAirmac("AIR-001");
|
||||
when(toiletAirqualityMapper.selectOne(any())).thenReturn(device);
|
||||
|
||||
ToiletAirqualityLog log = new ToiletAirqualityLog();
|
||||
log.setId(3L);
|
||||
log.setAirmac("AIR-001");
|
||||
log.setState(3);
|
||||
log.setUpdateTime(new Date(1789920000000L));
|
||||
when(toiletAirqualityLogMapper.selectCount(any())).thenReturn(1L);
|
||||
when(toiletAirqualityLogMapper.selectList(any())).thenReturn(List.of(log));
|
||||
|
||||
Map<String, Object> result = tools.toiletAirqualityStatelogListQuery(
|
||||
"AIR-001", "2026-10-01", "2026-10-12", 3, 1, 20);
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<ToiletAirqualityLog>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(toiletAirqualityLogMapper).selectList(captor.capture());
|
||||
String sql = captor.getValue().getSqlSegment();
|
||||
assertTrue(sql.contains("soft_delete ="));
|
||||
assertTrue(sql.contains("airmac ="));
|
||||
assertTrue(sql.contains("state ="));
|
||||
assertTrue(sql.contains("update_time >="));
|
||||
assertTrue(sql.contains("update_time <"));
|
||||
assertTrue(sql.contains("ORDER BY update_time DESC,id DESC"));
|
||||
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals(3, record.get("state"));
|
||||
assertEquals("在线", record.get("stateText"));
|
||||
String expectedTime = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(SHANGHAI).format(java.time.Instant.ofEpochMilli(1789920000000L));
|
||||
assertEquals(expectedTime, record.get("updateTime"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesFourToolSchemas() {
|
||||
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
|
||||
.toolObjects(tools).build().getToolCallbacks();
|
||||
Map<String, String> schemas = Arrays.stream(callbacks).collect(Collectors.toMap(
|
||||
callback -> callback.getToolDefinition().name(),
|
||||
callback -> callback.getToolDefinition().inputSchema()));
|
||||
|
||||
assertEquals(4, schemas.size());
|
||||
assertTrue(schemas.containsKey("toilet.airquality.list.query"));
|
||||
assertTrue(schemas.containsKey("toilet.airquality.detail.query"));
|
||||
assertTrue(schemas.containsKey("toilet.airquality.metrics.query"));
|
||||
assertTrue(schemas.containsKey("toilet.airquality.statelog.list.query"));
|
||||
assertTrue(schemas.get("toilet.airquality.metrics.query").contains("airmac"));
|
||||
assertTrue(schemas.get("toilet.airquality.statelog.list.query").contains("startUpdateTime"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user