新增 MCP 工具 district.paper.statistics.query(客户用纸统计)并引入 ES 7.17 客户端
- 复刻老 MonthlyStatisticsController.getMonthlyStatisticsMap:m0 monthly_statistics 按 gender/按日聚合(用纸长度米、人次、台日),district_strategy.rfid_use_flag 分口径统计 ES paperuselog 换纸卷数 - ES 双集群(云/本地,Dictionary.LocalEsDate 分界)+ 冷热索引(90 天),terms 聚合统计 rfid 去重数替代老 scroll 拉全量 - 引入 co.elastic.clients:elasticsearch-java 7.17.29(显式钉版本,覆盖 Boot BOM 8.x),EsClientConfig 双集群客户端 - 字典缺失/ES 异常 fail fast 抛 IllegalStateException(老系统静默为 0);district_strategy 缺失按未使用 rfid 口径(老系统 NPE) - 新增单测 16 个(工具 13 + planner 3),全套 303 个单测通过
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package me.iiv.agent.config;
|
||||
|
||||
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
|
||||
import co.elastic.clients.transport.rest_client.RestClientTransport;
|
||||
import org.apache.http.HttpHost;
|
||||
import org.apache.http.auth.AuthScope;
|
||||
import org.apache.http.auth.UsernamePasswordCredentials;
|
||||
import org.apache.http.impl.client.BasicCredentialsProvider;
|
||||
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
|
||||
import org.elasticsearch.client.RestClient;
|
||||
import org.elasticsearch.client.RestClientBuilder;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 双集群 ES 客户端:cloud=云集群、local=本地化集群(老系统 Dictionary.LocalEsDate 分界)。
|
||||
* 低阶 RestClient 也注册为 bean(destroyMethod=close 随容器关闭);
|
||||
* 高阶 ElasticsearchClient 包装同一 RestClient,关闭 client 即关闭底层连接。
|
||||
*/
|
||||
@Configuration
|
||||
public class EsClientConfig {
|
||||
|
||||
@Bean(name = "esCloudRestClient", destroyMethod = "close")
|
||||
public RestClient esCloudRestClient(@Value("${app.es.cloud.hosts}") String hosts,
|
||||
@Value("${app.es.cloud.port:9200}") int port,
|
||||
@Value("${app.es.cloud.username:}") String username,
|
||||
@Value("${app.es.cloud.password:}") String password) {
|
||||
return buildRestClient("cloud", hosts, port, username, password);
|
||||
}
|
||||
|
||||
@Bean(name = "esCloudClient")
|
||||
public ElasticsearchClient esCloudClient(@Qualifier("esCloudRestClient") RestClient restClient) {
|
||||
return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));
|
||||
}
|
||||
|
||||
@Bean(name = "esLocalRestClient", destroyMethod = "close")
|
||||
public RestClient esLocalRestClient(@Value("${app.es.local.hosts}") String hosts,
|
||||
@Value("${app.es.local.port:9200}") int port,
|
||||
@Value("${app.es.local.username:}") String username,
|
||||
@Value("${app.es.local.password:}") String password) {
|
||||
return buildRestClient("local", hosts, port, username, password);
|
||||
}
|
||||
|
||||
@Bean(name = "esLocalClient")
|
||||
public ElasticsearchClient esLocalClient(@Qualifier("esLocalRestClient") RestClient restClient) {
|
||||
return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));
|
||||
}
|
||||
|
||||
private static RestClient buildRestClient(String cluster, String hosts, int port,
|
||||
String username, String password) {
|
||||
HttpHost[] httpHosts = Arrays.stream(hosts.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::hasText)
|
||||
.map(host -> new HttpHost(host, port))
|
||||
.toArray(HttpHost[]::new);
|
||||
if (httpHosts.length == 0) {
|
||||
throw new IllegalStateException("app.es." + cluster + ".hosts 未配置有效节点");
|
||||
}
|
||||
RestClientBuilder builder = RestClient.builder(httpHosts)
|
||||
.setRequestConfigCallback(config ->
|
||||
config.setConnectTimeout(1000).setSocketTimeout(60000));
|
||||
if (StringUtils.hasText(username)) {
|
||||
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
|
||||
credentialsProvider.setCredentials(AuthScope.ANY,
|
||||
new UsernamePasswordCredentials(username, password));
|
||||
builder.setHttpClientConfigCallback((HttpAsyncClientBuilder httpClientBuilder) ->
|
||||
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* district_strategy 站点策略(m0 库 xiaoqu_comples_d;老系统 beans/b0 走 dataSource0,
|
||||
* 行 id=站点 id,与 district_district_strategy 一一对应)。
|
||||
* 本服务只取 rfid_use_flag 判断换纸卷数口径。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("district_strategy")
|
||||
public class DistrictStrategy implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 站点id */
|
||||
private Long districtId;
|
||||
|
||||
/** rfid使用标记(0:未使用;1:使用) */
|
||||
private Integer rfidUseFlag;
|
||||
|
||||
/** 数据状态 */
|
||||
private Integer softDelete;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* monthly_statistics 站点用纸日统计(m0 库,每站点每厕位/厕每天一行,XXL-Job 预聚合)。
|
||||
* 字段照搬老项目 xiaoqu-publicbeanandmap beans/b0/MonthlyStatistics.java。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("monthly_statistics")
|
||||
public class MonthlyStatistics implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 统计时间(当天 0 点) */
|
||||
private Date statisticsTime;
|
||||
|
||||
/** 数据状态 */
|
||||
private Integer softDelete;
|
||||
|
||||
/** 代理商 */
|
||||
private Long agentId;
|
||||
|
||||
/** 所属物业 */
|
||||
private Long propertyId;
|
||||
|
||||
/** 所属区域/站点 */
|
||||
private Long districtId;
|
||||
|
||||
/** 楼id */
|
||||
private Long buildingId;
|
||||
|
||||
/** 楼层 */
|
||||
private String floor;
|
||||
|
||||
/** 厕所id */
|
||||
private Long toiletId;
|
||||
|
||||
/** 位置id(>0=厕位,=0=厕墙) */
|
||||
private Long positionId;
|
||||
|
||||
/** 位置信息 */
|
||||
private String position;
|
||||
|
||||
/** 性别(0男1女2母婴3残卫4第三5其他,无厕所时99) */
|
||||
private Integer gender;
|
||||
|
||||
/** sn码 */
|
||||
private String snCode;
|
||||
|
||||
/** 用纸长度,单位厘米 */
|
||||
private Integer paperLength;
|
||||
|
||||
/** 有效使用人数(仅厕位模式有值,厕墙行恒 0) */
|
||||
private Integer useCount;
|
||||
|
||||
/** 总人次 */
|
||||
private Integer totalCount;
|
||||
|
||||
/** 出纸人次 */
|
||||
private Integer paperCount;
|
||||
|
||||
/** 运行模式(0厕位模式) */
|
||||
private Integer runlMode;
|
||||
|
||||
/** 设备类型(0出纸机1擦手纸机) */
|
||||
private Integer deviceType;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
/**
|
||||
* paperuselog(换纸记录)ES 查询网关。集群选择(云/本地)与索引选择(热/冷)
|
||||
* 由调用方通过参数传入,本接口只做单次查询,便于单测 mock。
|
||||
*/
|
||||
public interface PaperUseLogEsGateway {
|
||||
|
||||
/**
|
||||
* rfid 去重数:state ∈ {2上纸, 4撤纸} 合并后 rfid 去重(terms 聚合统计 bucket 数,
|
||||
* size 上限 10000,超出部分不计——见设计文档)。
|
||||
*
|
||||
* @param index paperuselog / paperuselog_all
|
||||
* @param localCluster true=本地集群,false=云集群
|
||||
*/
|
||||
long distinctRfidCount(String index, boolean localCluster, Long districtId, Integer gender,
|
||||
Long fromMillis, Long toMillis, Integer deviceType, String positionMode);
|
||||
|
||||
/**
|
||||
* state=2(上纸)记录数(精确 count,不含 state=4、不去重)。
|
||||
* 参数含义同 {@link #distinctRfidCount}。
|
||||
*/
|
||||
long countState2(String index, boolean localCluster, Long districtId, Integer gender,
|
||||
Long fromMillis, Long toMillis, Integer deviceType, String positionMode);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||
import co.elastic.clients.elasticsearch._types.FieldValue;
|
||||
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
|
||||
import co.elastic.clients.json.JsonData;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* paperuselog ES 查询实现(elasticsearch-java 7.17 强类型 API)。
|
||||
* 查询口径对齐老 MonthlyStatisticsController.getMonthlyStatisticsMap:
|
||||
* term districtId/gender、range createTime [gte,lte](含边界)、deviceType 用 match(老代码字符串参数走 match),
|
||||
* positionMode 厕位=positionsId>0、厕墙=positionsId=0。
|
||||
* ES 异常不静默,抛 IllegalStateException(与老系统静默为 0 不同,见设计文档)。
|
||||
*/
|
||||
@Component
|
||||
public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway {
|
||||
|
||||
/** rfid terms 聚合去重上限,超出部分不计入 */
|
||||
private static final int RFID_TERMS_SIZE = 10000;
|
||||
|
||||
private final ElasticsearchClient cloudClient;
|
||||
private final ElasticsearchClient localClient;
|
||||
|
||||
public PaperUseLogEsGatewayImpl(@Qualifier("esCloudClient") ElasticsearchClient cloudClient,
|
||||
@Qualifier("esLocalClient") ElasticsearchClient localClient) {
|
||||
this.cloudClient = cloudClient;
|
||||
this.localClient = localClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long distinctRfidCount(String index, boolean localCluster, Long districtId, Integer gender,
|
||||
Long fromMillis, Long toMillis, Integer deviceType, String positionMode) {
|
||||
List<Query> filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
|
||||
// 上纸(2)+撤纸(4) 合并后 rfid 去重(老 :302-342)
|
||||
filters.add(Query.of(q -> q.terms(t -> t.field("state")
|
||||
.terms(tt -> tt.value(List.of(FieldValue.of(2), FieldValue.of(4)))))));
|
||||
try {
|
||||
return client(localCluster).search(s -> s
|
||||
.index(index)
|
||||
.size(0)
|
||||
.trackTotalHits(t -> t.enabled(true))
|
||||
.query(q -> q.bool(b -> b.filter(filters)))
|
||||
.aggregations("rfids", a -> a.terms(t -> t.field("rfid").size(RFID_TERMS_SIZE))),
|
||||
Void.class)
|
||||
.aggregations().get("rfids").sterms().buckets().array().size();
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("ES 查询换纸 rfid 去重失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countState2(String index, boolean localCluster, Long districtId, Integer gender,
|
||||
Long fromMillis, Long toMillis, Integer deviceType, String positionMode) {
|
||||
List<Query> filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("state").value(FieldValue.of(2)))));
|
||||
try {
|
||||
return client(localCluster).count(c -> c.index(index)
|
||||
.query(q -> q.bool(b -> b.filter(filters)))).count();
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("ES 查询换纸上纸记录数失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Query> baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
|
||||
Integer deviceType, String positionMode) {
|
||||
List<Query> filters = new ArrayList<>();
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("districtId").value(FieldValue.of(districtId)))));
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("gender").value(FieldValue.of(gender)))));
|
||||
// createTime 为 epoch 毫秒;含边界(老 rangeQuery from/to 均 inclusive)
|
||||
filters.add(Query.of(q -> q.range(r -> r.field("createTime")
|
||||
.gte(JsonData.of(fromMillis)).lte(JsonData.of(toMillis)))));
|
||||
if (deviceType != null) {
|
||||
// 老代码 deviceType 为字符串参数走 matchQuery(BaseSearchSupportImpl2 type=0 非数值分支)
|
||||
filters.add(Query.of(q -> q.match(m -> m.field("deviceType")
|
||||
.query(FieldValue.of(String.valueOf(deviceType))))));
|
||||
}
|
||||
if (StringUtils.hasText(positionMode)) {
|
||||
if ("position".equals(positionMode)) {
|
||||
filters.add(Query.of(q -> q.range(r -> r.field("positionsId").gt(JsonData.of(0)))));
|
||||
} else {
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("positionsId").value(FieldValue.of(0)))));
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
private ElasticsearchClient client(boolean localCluster) {
|
||||
return localCluster ? localClient : cloudClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* paperuselog 查询的索引/集群选择纯函数(老 DateToConverter.isHotTime / MonthlyStatisticsController
|
||||
* 的 LocalEsDate 判断),抽成静态方法便于单测。常量与比较口径 1:1 对齐老系统。
|
||||
*/
|
||||
public final class PaperUseLogEsPlanner {
|
||||
|
||||
public static final String HOT_INDEX = "paperuselog";
|
||||
public static final String COLD_INDEX = "paperuselog_all";
|
||||
|
||||
/** 老 isHotTime 硬编码 90 天:startDate 距今 <90 天走热索引,否则冷索引 */
|
||||
private static final int HOT_DAYS = 90;
|
||||
|
||||
private PaperUseLogEsPlanner() {
|
||||
}
|
||||
|
||||
/** startDate >= 今天-90天 → 热索引 paperuselog,否则 paperuselog_all */
|
||||
public static String resolveIndex(LocalDate startDate, LocalDate today) {
|
||||
return startDate.isBefore(today.minusDays(HOT_DAYS)) ? COLD_INDEX : HOT_INDEX;
|
||||
}
|
||||
|
||||
/** localEsDate > endDate → 整个区间在本地集群 */
|
||||
public static boolean useLocalCluster(LocalDate localEsDate, LocalDate endDate) {
|
||||
return localEsDate.isAfter(endDate);
|
||||
}
|
||||
|
||||
/** startDate < localEsDate < endDate(与老代码同口径的严格小于)→ 跨界拒绝 */
|
||||
public static void checkNotCrossingBoundary(LocalDate startDate, LocalDate endDate, LocalDate localEsDate) {
|
||||
if (localEsDate.isBefore(endDate) && localEsDate.isAfter(startDate)) {
|
||||
throw new IllegalArgumentException("查询范围跨越本地化数据分界日 " + localEsDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m0.DistrictStrategy;
|
||||
|
||||
public interface DistrictStrategyMapper extends BaseMapper<DistrictStrategy> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m0.MonthlyStatistics;
|
||||
|
||||
public interface MonthlyStatisticsMapper extends BaseMapper<MonthlyStatistics> {
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
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.DistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.MonthlyStatistics;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.es.PaperUseLogEsGateway;
|
||||
import me.iiv.agent.es.PaperUseLogEsPlanner;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.DistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
|
||||
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.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* 客户用纸统计(老 MonthlyStatisticsController.getMonthlyStatisticsMap 复刻):
|
||||
* DB 部分聚合 m0 monthly_statistics(用纸长度/人次/台日),ES 部分(paperuselog)
|
||||
* 按站点策略 rfid_use_flag 分口径统计换纸卷数。
|
||||
*/
|
||||
@Component
|
||||
public class DistrictPaperStatisticsTools implements ToolObject {
|
||||
|
||||
private static final String DICTIONARY_KEY = "Dictionary";
|
||||
private static final String DICT_LOCAL_ES_DATE = "LocalEsDate";
|
||||
private static final String DICT_STATISTICS_DAYS = "StatisticsDays";
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
final DistrictDistrictStrategyMapper districtMapper;
|
||||
final MonthlyStatisticsMapper monthlyStatisticsMapper;
|
||||
final DistrictStrategyMapper districtStrategyMapper;
|
||||
final PermissionService permissionService;
|
||||
final StringRedisTemplate redisTemplate;
|
||||
final PaperUseLogEsGateway paperUseLogEsGateway;
|
||||
|
||||
public DistrictPaperStatisticsTools(DistrictDistrictStrategyMapper districtMapper,
|
||||
MonthlyStatisticsMapper monthlyStatisticsMapper,
|
||||
DistrictStrategyMapper districtStrategyMapper,
|
||||
PermissionService permissionService,
|
||||
StringRedisTemplate redisTemplate,
|
||||
PaperUseLogEsGateway paperUseLogEsGateway) {
|
||||
this.districtMapper = districtMapper;
|
||||
this.monthlyStatisticsMapper = monthlyStatisticsMapper;
|
||||
this.districtStrategyMapper = districtStrategyMapper;
|
||||
this.permissionService = permissionService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.paperUseLogEsGateway = paperUseLogEsGateway;
|
||||
}
|
||||
|
||||
@XqTool(category = "hardware", kind = "read")
|
||||
@Tool(name = "district.paper.statistics.query", description = "客户用纸统计:按站点和日期范围统计各性别分组的用纸长度(米)、"
|
||||
+ "使用人次、台日合计与换纸卷数,并给出按日明细。主体数据来自 m0 monthly_statistics 日汇总表;"
|
||||
+ "换纸卷数来自 ES paperuselog(热索引近 90 天,更早走冷索引 paperuselog_all;"
|
||||
+ "按 Redis LocalEsDate 分界选择云/本地集群,跨界查询会被拒绝)。"
|
||||
+ "dateRange 最大天数受 Redis StatisticsDays 限制。所有长度字段单位均为米。")
|
||||
public Map<String, Object> districtPaperStatisticsQuery(
|
||||
@ToolParam(description = "站点 id,正整数") Long districtId,
|
||||
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
|
||||
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
|
||||
@ToolParam(required = false, description = "厕位/厕墙过滤:position=仅厕位、wall=仅厕墙,缺省不过滤") String positionMode,
|
||||
@ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
|
||||
@ToolParam(required = false, description = "楼 id,正整数,精确匹配") Long buildingId,
|
||||
@ToolParam(required = false, description = "楼层,精确匹配") String floor,
|
||||
@ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) {
|
||||
|
||||
if (districtId == null) {
|
||||
throw new IllegalArgumentException("districtId 必填");
|
||||
}
|
||||
validatePositive(districtId, "districtId");
|
||||
validatePositive(buildingId, "buildingId");
|
||||
validatePositive(toiletId, "toiletId");
|
||||
LocalDate start = parseDay(startDate, "startDate");
|
||||
LocalDate end = parseDay(endDate, "endDate");
|
||||
LocalDate today = LocalDate.now();
|
||||
if (start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("startDate 不能晚于 endDate");
|
||||
}
|
||||
if (end.isAfter(today)) {
|
||||
throw new IllegalArgumentException("endDate 不得晚于今天");
|
||||
}
|
||||
if (StringUtils.hasText(positionMode) && !"position".equals(positionMode) && !"wall".equals(positionMode)) {
|
||||
throw new IllegalArgumentException("positionMode 仅支持 position、wall");
|
||||
}
|
||||
if (deviceType != null && deviceType != 0 && deviceType != 1) {
|
||||
throw new IllegalArgumentException("deviceType 仅支持 0、1");
|
||||
}
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
LambdaQueryWrapper<DistrictDistrictStrategy> districtWrapper = new LambdaQueryWrapper<DistrictDistrictStrategy>()
|
||||
.eq(DistrictDistrictStrategy::getSoftDelete, 0)
|
||||
.eq(DistrictDistrictStrategy::getId, districtId);
|
||||
permissionService.applyDistrictRowFilter(districtWrapper, user);
|
||||
DistrictDistrictStrategy district = districtMapper.selectOne(districtWrapper);
|
||||
if (district == null) {
|
||||
throw new IllegalStateException("站点不存在或无权访问");
|
||||
}
|
||||
|
||||
// 老 :121-143:LocalEsDate 集群分界 + StatisticsDays 天数上限;字典缺失 fail fast(老系统 NPE 被吞)
|
||||
LocalDate localEsDate = parseDay(dictionaryValue(DICT_LOCAL_ES_DATE), DICT_LOCAL_ES_DATE);
|
||||
int maxDays = Integer.parseInt(dictionaryValue(DICT_STATISTICS_DAYS).trim());
|
||||
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
|
||||
if (queryDays > maxDays) {
|
||||
throw new IllegalArgumentException("查询时间段最大" + maxDays + "天");
|
||||
}
|
||||
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, localEsDate);
|
||||
String index = PaperUseLogEsPlanner.resolveIndex(start, today);
|
||||
boolean localCluster = PaperUseLogEsPlanner.useLocalCluster(localEsDate, end);
|
||||
|
||||
List<MonthlyStatistics> rows = queryMonthlyStatistics(districtId, start, end,
|
||||
buildingId, floor, toiletId, deviceType);
|
||||
Map<Integer, GenderAgg> byGender = aggregate(rows);
|
||||
|
||||
DistrictStrategy strategy = districtStrategyMapper.selectOne(new LambdaQueryWrapper<DistrictStrategy>()
|
||||
.eq(DistrictStrategy::getSoftDelete, 0)
|
||||
.eq(DistrictStrategy::getId, districtId));
|
||||
// 老 :324-325 对不存在/空 rfidUseFlag 会 NPE;这里按"未使用 rfid"口径走 state=2 count
|
||||
boolean rfidUsed = strategy != null && strategy.getRfidUseFlag() != null
|
||||
&& strategy.getRfidUseFlag() == 1;
|
||||
|
||||
long fromMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
|
||||
// 老 :260-268:endDate=今天 → 上界取今天 00:00(当天数据未跑完),否则 23:59:59(含边界)
|
||||
LocalDate endExclusive = end.equals(today) ? end : end.plusDays(1);
|
||||
long toMillis = endExclusive.atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
|
||||
|
||||
List<Map<String, Object>> summaryByGender = new ArrayList<>();
|
||||
List<Map<String, Object>> daily = new ArrayList<>();
|
||||
List<Map<String, Object>> toiletGenders = new ArrayList<>();
|
||||
for (Map.Entry<Integer, GenderAgg> entry : new TreeMap<>(byGender).entrySet()) {
|
||||
Integer gender = entry.getKey();
|
||||
GenderAgg agg = entry.getValue();
|
||||
long rfidCount = rfidUsed
|
||||
? paperUseLogEsGateway.distinctRfidCount(index, localCluster, districtId, gender,
|
||||
fromMillis, toMillis, deviceType, positionMode)
|
||||
: paperUseLogEsGateway.countState2(index, localCluster, districtId, gender,
|
||||
fromMillis, toMillis, deviceType, positionMode);
|
||||
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("gender", gender);
|
||||
item.put("genderText", genderText(gender));
|
||||
item.put("paperLengthM", round3(agg.paperCm / 100.0));
|
||||
item.put("useCount", agg.useCount);
|
||||
item.put("perUseLengthM", agg.useCount > 0 ? round3(agg.paperCm / 100.0 / agg.useCount) : null);
|
||||
item.put("deviceDayCount", agg.deviceDayCount());
|
||||
item.put("rfidCount", rfidCount);
|
||||
summaryByGender.add(item);
|
||||
|
||||
for (Map.Entry<String, DailyAgg> dayEntry : agg.daily.entrySet()) {
|
||||
DailyAgg dayAgg = dayEntry.getValue();
|
||||
Map<String, Object> dayItem = new LinkedHashMap<>();
|
||||
dayItem.put("date", dayEntry.getKey());
|
||||
dayItem.put("gender", gender);
|
||||
dayItem.put("paperLengthM", round3(dayAgg.paperCm / 100.0));
|
||||
dayItem.put("useCount", dayAgg.useCount);
|
||||
dayItem.put("deviceDayCount", (long) dayAgg.positionKeys.size());
|
||||
daily.add(dayItem);
|
||||
}
|
||||
|
||||
Map<String, Object> genderItem = new LinkedHashMap<>();
|
||||
genderItem.put("gender", gender);
|
||||
genderItem.put("genderText", genderText(gender));
|
||||
toiletGenders.add(genderItem);
|
||||
}
|
||||
|
||||
Map<String, Object> dateRange = new LinkedHashMap<>();
|
||||
dateRange.put("start", start.toString());
|
||||
dateRange.put("end", end.toString());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("districtId", districtId);
|
||||
result.put("districtName", district.getDistrictName());
|
||||
result.put("dateRange", dateRange);
|
||||
result.put("toiletGenders", toiletGenders);
|
||||
result.put("summaryByGender", summaryByGender);
|
||||
result.put("daily", daily);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<MonthlyStatistics> queryMonthlyStatistics(Long districtId, LocalDate start, LocalDate end,
|
||||
Long buildingId, String floor, Long toiletId,
|
||||
Integer deviceType) {
|
||||
LambdaQueryWrapper<MonthlyStatistics> wrapper = new LambdaQueryWrapper<MonthlyStatistics>()
|
||||
.eq(MonthlyStatistics::getSoftDelete, 0)
|
||||
.eq(MonthlyStatistics::getDistrictId, districtId)
|
||||
.ge(MonthlyStatistics::getStatisticsTime, Date.from(start.atStartOfDay(SHANGHAI).toInstant()))
|
||||
// [start 00:00:00, end 23:59:59],用左闭右开避免秒精度丢行
|
||||
.lt(MonthlyStatistics::getStatisticsTime, Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant()));
|
||||
if (buildingId != null) {
|
||||
wrapper.eq(MonthlyStatistics::getBuildingId, buildingId);
|
||||
}
|
||||
if (StringUtils.hasText(floor)) {
|
||||
wrapper.eq(MonthlyStatistics::getFloor, floor);
|
||||
}
|
||||
if (toiletId != null) {
|
||||
wrapper.eq(MonthlyStatistics::getToiletId, toiletId);
|
||||
}
|
||||
if (deviceType != null) {
|
||||
wrapper.eq(MonthlyStatistics::getDeviceType, deviceType);
|
||||
}
|
||||
return monthlyStatisticsMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/** 老 :176-249 聚合:gender 分组 + 按日分组;台日=每天 distinct 厕位/厕数之和(positionSet) */
|
||||
private Map<Integer, GenderAgg> aggregate(List<MonthlyStatistics> rows) {
|
||||
Map<Integer, GenderAgg> byGender = new LinkedHashMap<>();
|
||||
for (MonthlyStatistics row : rows) {
|
||||
if (row.getGender() == null) {
|
||||
continue;
|
||||
}
|
||||
GenderAgg agg = byGender.computeIfAbsent(row.getGender(), gender -> new GenderAgg());
|
||||
int paperLength = row.getPaperLength() == null ? 0 : row.getPaperLength();
|
||||
int useCount = row.getUseCount() == null ? 0 : row.getUseCount();
|
||||
agg.paperCm += paperLength;
|
||||
agg.useCount += useCount;
|
||||
String day = DAY_FORMAT.format(row.getStatisticsTime().toInstant().atZone(SHANGHAI));
|
||||
DailyAgg dailyAgg = agg.daily.computeIfAbsent(day, key -> new DailyAgg());
|
||||
dailyAgg.paperCm += paperLength;
|
||||
dailyAgg.useCount += useCount;
|
||||
// 厕位行按 positionId、厕墙行按 toiletId 计台(老 "p_"/"t_" positionSet)
|
||||
if (row.getPositionId() != null && row.getPositionId() > 0) {
|
||||
dailyAgg.positionKeys.add("p_" + row.getPositionId());
|
||||
} else {
|
||||
dailyAgg.positionKeys.add("t_" + row.getToiletId());
|
||||
}
|
||||
}
|
||||
return byGender;
|
||||
}
|
||||
|
||||
private String dictionaryValue(String field) {
|
||||
Object value = redisTemplate.opsForHash().get(DICTIONARY_KEY, field);
|
||||
if (value == null) {
|
||||
throw new IllegalStateException("Redis Dictionary 缺少 " + field + " 字段,拒绝查询");
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private static LocalDate parseDay(String value, String name) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd");
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (java.time.format.DateTimeParseException e) {
|
||||
throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validatePositive(Long value, String name) {
|
||||
if (value != null && value < 1) {
|
||||
throw new IllegalArgumentException(name + " 必须为正整数");
|
||||
}
|
||||
}
|
||||
|
||||
private static double round3(double value) {
|
||||
return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue();
|
||||
}
|
||||
|
||||
private static String genderText(Integer gender) {
|
||||
if (gender == null) {
|
||||
return "未知";
|
||||
}
|
||||
switch (gender) {
|
||||
case 0: return "男";
|
||||
case 1: return "女";
|
||||
case 2: return "母婴";
|
||||
case 3: return "残卫";
|
||||
case 4: return "第三";
|
||||
case 5: return "其他";
|
||||
case 99: return "未知";
|
||||
default: return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
private static final class GenderAgg {
|
||||
long paperCm;
|
||||
long useCount;
|
||||
final Map<String, DailyAgg> daily = new TreeMap<>();
|
||||
|
||||
long deviceDayCount() {
|
||||
return daily.values().stream().mapToLong(day -> day.positionKeys.size()).sum();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DailyAgg {
|
||||
long paperCm;
|
||||
long useCount;
|
||||
final Set<String> positionKeys = new LinkedHashSet<>();
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,14 @@ app:
|
||||
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_agent_svc_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: kaixinjiuhao
|
||||
# ES 双集群(老 dev 值):cloud=云集群、local=本地化集群;无账号
|
||||
es:
|
||||
cloud:
|
||||
hosts: 192.168.1.152
|
||||
port: 9200
|
||||
local:
|
||||
hosts: 127.0.0.1
|
||||
port: 9200
|
||||
|
||||
spring:
|
||||
data:
|
||||
|
||||
@@ -27,6 +27,18 @@ app:
|
||||
jdbc-url: ${SVC_DB_SVC_URL}
|
||||
username: ${SVC_DB_SVC_USER}
|
||||
password: ${SVC_DB_SVC_PASSWORD}
|
||||
# ES 双集群:cloud=云集群、local=本地化集群(hosts 支持逗号多节点)
|
||||
es:
|
||||
cloud:
|
||||
hosts: ${SVC_ES_CLOUD_HOSTS}
|
||||
port: ${SVC_ES_CLOUD_PORT:9200}
|
||||
username: ${SVC_ES_CLOUD_USERNAME:}
|
||||
password: ${SVC_ES_CLOUD_PASSWORD:}
|
||||
local:
|
||||
hosts: ${SVC_ES_LOCAL_HOSTS}
|
||||
port: ${SVC_ES_LOCAL_PORT:9200}
|
||||
username: ${SVC_ES_LOCAL_USERNAME:}
|
||||
password: ${SVC_ES_LOCAL_PASSWORD:}
|
||||
|
||||
spring:
|
||||
data:
|
||||
|
||||
@@ -32,6 +32,19 @@ app:
|
||||
rate-limit:
|
||||
query-per-minute: ${QUERY_RATE_LIMIT_PER_MINUTE:600}
|
||||
sync-per-minute: ${SYNC_RATE_LIMIT_PER_MINUTE:120}
|
||||
# 双集群 ES(paperuselog 换纸记录):cloud=云集群、local=本地化集群;
|
||||
# hosts 支持逗号多节点,username 为空 = 不设认证;各环境在 application-{env}.yml 覆盖
|
||||
es:
|
||||
cloud:
|
||||
hosts: ${SVC_ES_CLOUD_HOSTS:}
|
||||
port: ${SVC_ES_CLOUD_PORT:9200}
|
||||
username: ${SVC_ES_CLOUD_USERNAME:}
|
||||
password: ${SVC_ES_CLOUD_PASSWORD:}
|
||||
local:
|
||||
hosts: ${SVC_ES_LOCAL_HOSTS:}
|
||||
port: ${SVC_ES_LOCAL_PORT:9200}
|
||||
username: ${SVC_ES_LOCAL_USERNAME:}
|
||||
password: ${SVC_ES_LOCAL_PASSWORD:}
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PaperUseLogEsPlannerTest {
|
||||
|
||||
private static final LocalDate TODAY = LocalDate.of(2026, 9, 20);
|
||||
|
||||
@Test
|
||||
void hotIndexWhenStartWithin90Days() {
|
||||
assertEquals("paperuselog", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(90), TODAY));
|
||||
assertEquals("paperuselog", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(6), TODAY));
|
||||
assertEquals("paperuselog_all", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(91), TODAY));
|
||||
assertEquals("paperuselog_all", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(365), TODAY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localClusterOnlyWhenLocalEsDateAfterEndDate() {
|
||||
assertTrue(PaperUseLogEsPlanner.useLocalCluster(LocalDate.of(2026, 9, 21), TODAY));
|
||||
// 与老代码一致:localEsDate == endDate 走云集群
|
||||
assertFalse(PaperUseLogEsPlanner.useLocalCluster(TODAY, TODAY));
|
||||
assertFalse(PaperUseLogEsPlanner.useLocalCluster(TODAY.minusDays(30), TODAY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossingBoundaryRejectedOnlyWhenStrictlyInside() {
|
||||
LocalDate start = TODAY.minusDays(6);
|
||||
LocalDate boundary = TODAY.minusDays(3);
|
||||
LocalDate end = TODAY;
|
||||
|
||||
// start < localEsDate < end → 拒绝
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, boundary));
|
||||
assertEquals("查询范围跨越本地化数据分界日 " + boundary, exception.getMessage());
|
||||
|
||||
// localEsDate == startDate:老口径不拒绝(localEsDate > startDay 为 false)
|
||||
PaperUseLogEsPlanner.checkNotCrossingBoundary(boundary, end, boundary);
|
||||
// localEsDate == endDate:不拒绝且走云集群
|
||||
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, boundary, boundary);
|
||||
// 整段在分界日之前/之后
|
||||
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, TODAY.plusDays(1));
|
||||
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, start.minusDays(1));
|
||||
}
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.DistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.MonthlyStatistics;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.es.PaperUseLogEsGateway;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.DistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
|
||||
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.HashOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
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 DistrictPaperStatisticsToolsTest {
|
||||
|
||||
private static final ZoneId SH = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Mock
|
||||
DistrictDistrictStrategyMapper districtMapper;
|
||||
@Mock
|
||||
MonthlyStatisticsMapper monthlyStatisticsMapper;
|
||||
@Mock
|
||||
DistrictStrategyMapper districtStrategyMapper;
|
||||
@Mock
|
||||
PermissionService permissionService;
|
||||
@Mock
|
||||
StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
HashOperations<String, Object, Object> hashOperations;
|
||||
@Mock
|
||||
PaperUseLogEsGateway esGateway;
|
||||
|
||||
DistrictPaperStatisticsTools tools;
|
||||
LocalDate today;
|
||||
String start;
|
||||
String end;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DistrictDistrictStrategy.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
MonthlyStatistics.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DistrictStrategy.class);
|
||||
tools = new DistrictPaperStatisticsTools(districtMapper, monthlyStatisticsMapper,
|
||||
districtStrategyMapper, permissionService, redisTemplate, esGateway);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
today = LocalDate.now();
|
||||
start = today.minusDays(6).toString();
|
||||
end = today.toString();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
private void mockDistrictAndDictionary() {
|
||||
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
|
||||
district.setId(9L);
|
||||
district.setDistrictName("测试站点");
|
||||
when(districtMapper.selectOne(any())).thenReturn(district);
|
||||
lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations);
|
||||
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn("2020-01-01");
|
||||
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("31");
|
||||
}
|
||||
|
||||
private static MonthlyStatistics row(LocalDate day, Long positionId, Long toiletId,
|
||||
Integer gender, int paperLengthCm, int useCount) {
|
||||
MonthlyStatistics row = new MonthlyStatistics();
|
||||
row.setId(1L);
|
||||
row.setSoftDelete(0);
|
||||
row.setDistrictId(9L);
|
||||
row.setStatisticsTime(Date.from(day.atStartOfDay(SH).toInstant()));
|
||||
row.setGender(gender);
|
||||
row.setPositionId(positionId);
|
||||
row.setToiletId(toiletId);
|
||||
row.setPaperLength(paperLengthCm);
|
||||
row.setUseCount(useCount);
|
||||
return row;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidArgumentsBeforeAnyQuery() {
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
null, start, end, null, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
0L, start, end, null, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
9L, "2026/09/01", end, null, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
9L, start, end, "bad", null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
9L, start, end, null, 2, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
9L, end, start, null, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
|
||||
9L, start, today.plusDays(1).toString(), null, null, null, null, null));
|
||||
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, redisTemplate, esGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvisibleDistrict() {
|
||||
when(districtMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
IllegalStateException exception = assertThrows(IllegalStateException.class,
|
||||
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
|
||||
assertEquals("站点不存在或无权访问", exception.getMessage());
|
||||
verify(permissionService).applyDistrictRowFilter(any(), any());
|
||||
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsFastWhenDictionaryMissing() {
|
||||
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
|
||||
district.setId(9L);
|
||||
when(districtMapper.selectOne(any())).thenReturn(district);
|
||||
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
|
||||
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
|
||||
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRangeExceedingStatisticsDays() {
|
||||
mockDistrictAndDictionary();
|
||||
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("5");
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
|
||||
assertEquals("查询时间段最大5天", exception.getMessage());
|
||||
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRangeCrossingLocalEsDateBoundary() {
|
||||
mockDistrictAndDictionary();
|
||||
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(today.minusDays(3).toString());
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
|
||||
assertTrue(exception.getMessage().startsWith("查询范围跨越本地化数据分界日"));
|
||||
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesByGenderAndDayWithMeterConversion() {
|
||||
mockDistrictAndDictionary();
|
||||
LocalDate day1 = today.minusDays(2);
|
||||
LocalDate day2 = today.minusDays(1);
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(day1, 11L, 3L, 0, 100, 2),
|
||||
row(day1, 12L, 3L, 0, 50, 1),
|
||||
row(day2, 0L, 7L, 0, 50, 0),
|
||||
row(day1, 21L, 5L, 1, 200, 4)));
|
||||
DistrictStrategy strategy = new DistrictStrategy();
|
||||
strategy.setId(9L);
|
||||
strategy.setRfidUseFlag(1);
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(strategy);
|
||||
when(esGateway.distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(8L);
|
||||
|
||||
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
|
||||
|
||||
assertEquals(9L, result.get("districtId"));
|
||||
assertEquals("测试站点", result.get("districtName"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
|
||||
assertEquals(2, summary.size());
|
||||
|
||||
Map<String, Object> male = summary.get(0);
|
||||
assertEquals(0, male.get("gender"));
|
||||
assertEquals("男", male.get("genderText"));
|
||||
assertEquals(2.0, male.get("paperLengthM"));
|
||||
assertEquals(3L, male.get("useCount"));
|
||||
assertEquals(0.667, male.get("perUseLengthM"));
|
||||
// 台日=每天 distinct 厕位/厕数之和:day1 两个厕位 + day2 一个厕墙 = 3
|
||||
assertEquals(3L, male.get("deviceDayCount"));
|
||||
assertEquals(8L, male.get("rfidCount"));
|
||||
|
||||
Map<String, Object> female = summary.get(1);
|
||||
assertEquals(1, female.get("gender"));
|
||||
assertEquals(0.5, female.get("perUseLengthM"));
|
||||
assertEquals(1L, female.get("deviceDayCount"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> daily = (List<Map<String, Object>>) result.get("daily");
|
||||
assertEquals(3, daily.size());
|
||||
Map<String, Object> day1Item = daily.get(0);
|
||||
assertEquals(day1.toString(), day1Item.get("date"));
|
||||
assertEquals(1.5, day1Item.get("paperLengthM"));
|
||||
assertEquals(3L, day1Item.get("useCount"));
|
||||
assertEquals(2L, day1Item.get("deviceDayCount"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> genders = (List<Map<String, Object>>) result.get("toiletGenders");
|
||||
assertEquals(List.of(0, 1), genders.stream().map(g -> g.get("gender")).toList());
|
||||
|
||||
verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(0),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(1),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
verify(esGateway, never()).countState2(anyString(), anyBoolean(), anyLong(), anyInt(),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonRfidStrategyUsesState2Count() {
|
||||
mockDistrictAndDictionary();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
|
||||
DistrictStrategy strategy = new DistrictStrategy();
|
||||
strategy.setId(9L);
|
||||
strategy.setRfidUseFlag(0);
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(strategy);
|
||||
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(5L);
|
||||
|
||||
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
|
||||
assertEquals(5L, summary.get(0).get("rfidCount"));
|
||||
verify(esGateway).countState2(eq("paperuselog"), eq(false), eq(9L), eq(0),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
verify(esGateway, never()).distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStrategyFallsBackToState2Count() {
|
||||
mockDistrictAndDictionary();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
|
||||
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(0L);
|
||||
|
||||
tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
|
||||
|
||||
verify(esGateway).countState2(anyString(), anyBoolean(), anyLong(), anyInt(),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
verify(esGateway, never()).distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCountZeroYieldsNullPerUseLength() {
|
||||
mockDistrictAndDictionary();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(1), 0L, 7L, 99, 80, 0)));
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
|
||||
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(0L);
|
||||
|
||||
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
|
||||
assertEquals(0.8, summary.get(0).get("paperLengthM"));
|
||||
assertNull(summary.get(0).get("perUseLengthM"));
|
||||
assertEquals("未知", summary.get(0).get("genderText"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void coldIndexUsedWhenStartOlderThan90Days() {
|
||||
mockDistrictAndDictionary();
|
||||
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("365");
|
||||
String coldStart = today.minusDays(120).toString();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(100), 11L, 3L, 0, 100, 2)));
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
|
||||
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(0L);
|
||||
|
||||
tools.districtPaperStatisticsQuery(9L, coldStart, end, null, null, null, null, null);
|
||||
|
||||
verify(esGateway).countState2(eq("paperuselog_all"), eq(false), anyLong(), anyInt(),
|
||||
anyLong(), anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void endBoundaryExcludesTodayButKeepsFullPastDays() {
|
||||
mockDistrictAndDictionary();
|
||||
LocalDate pastEnd = today.minusDays(1);
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(pastEnd, 11L, 3L, 0, 100, 2)));
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
|
||||
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(0L);
|
||||
|
||||
// endDate=今天 → 上界取今天 00:00(毫秒-1)
|
||||
tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
|
||||
// endDate=过去某天 → 上界取当天 23:59:59.999
|
||||
tools.districtPaperStatisticsQuery(9L, start, pastEnd.toString(), null, null, null, null, null);
|
||||
|
||||
ArgumentCaptor<Long> toCaptor = ArgumentCaptor.forClass(Long.class);
|
||||
verify(esGateway, org.mockito.Mockito.times(2)).countState2(anyString(), anyBoolean(), anyLong(), anyInt(),
|
||||
anyLong(), toCaptor.capture(), any(), any());
|
||||
assertEquals(List.of(
|
||||
today.atStartOfDay(SH).toInstant().toEpochMilli() - 1,
|
||||
pastEnd.plusDays(1).atStartOfDay(SH).toInstant().toEpochMilli() - 1), toCaptor.getAllValues());
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesToolSchema() throws Exception {
|
||||
org.springframework.ai.tool.ToolCallback[] callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider
|
||||
.builder().toolObjects(tools).build().getToolCallbacks();
|
||||
assertEquals(1, callbacks.length);
|
||||
assertEquals("district.paper.statistics.query",
|
||||
callbacks[0].getToolDefinition().name());
|
||||
String schema = callbacks[0].getToolDefinition().inputSchema();
|
||||
assertTrue(schema.contains("districtId"));
|
||||
assertTrue(schema.contains("startDate"));
|
||||
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().readTree(schema)
|
||||
.path("required").toString().contains("\"districtId\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesPositionModeAndDeviceTypeThroughToEs() {
|
||||
mockDistrictAndDictionary();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
|
||||
DistrictStrategy strategy = new DistrictStrategy();
|
||||
strategy.setRfidUseFlag(1);
|
||||
when(districtStrategyMapper.selectOne(any())).thenReturn(strategy);
|
||||
when(esGateway.distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
|
||||
any(), any())).thenReturn(1L);
|
||||
|
||||
tools.districtPaperStatisticsQuery(9L, start, end, "position", 1, null, null, null);
|
||||
|
||||
verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(0),
|
||||
anyLong(), anyLong(), eq(1), eq("position"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user