package com.xiaopeng.map.utils;
|
|
import com.alibaba.fastjson2.JSONArray;
|
import com.alibaba.fastjson2.JSONObject;
|
|
import java.util.Collections;
|
import java.util.LinkedHashSet;
|
import java.util.List;
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.stream.Collectors;
|
|
/**
|
* 围栏运营策略时间范围索引工具类
|
*/
|
public final class OperationStrategyIndex {
|
private final List<List<TimeInterval>> strategyIntervals;
|
|
/**
|
* 构造索引(内部使用)。
|
*
|
* @param strategyIntervals 策略组时间区间列表(每个策略组已 merge)
|
*/
|
private OperationStrategyIndex(List<List<TimeInterval>> strategyIntervals) {
|
this.strategyIntervals = strategyIntervals == null ? Collections.emptyList() : strategyIntervals;
|
}
|
|
/**
|
* 解析围栏 {@code operation_strategy} JSON 字符串为运营时间范围索引。
|
* <p>
|
* 说明:会按“策略组”聚合并合并时间段;点位只要被任意一个策略组的时间段完整覆盖,则为正常。
|
* </p>
|
*
|
* @param operationStrategyJson 围栏运营策略 JSON(数组字符串)
|
* @return 索引对象;解析失败/空入参返回空索引
|
*/
|
public static OperationStrategyIndex parse(String operationStrategyJson) {
|
if (operationStrategyJson == null || operationStrategyJson.isEmpty()) {
|
return new OperationStrategyIndex(Collections.emptyList());
|
}
|
try {
|
JSONArray jsonArray = JSONArray.parseArray(operationStrategyJson);
|
if (jsonArray == null || jsonArray.isEmpty()) {
|
return new OperationStrategyIndex(Collections.emptyList());
|
}
|
List<List<TimeInterval>> groups = new java.util.ArrayList<>();
|
for (int i = 0; i < jsonArray.size(); i++) {
|
JSONObject obj = jsonArray.getJSONObject(i);
|
if (obj == null) {
|
continue;
|
}
|
JSONArray timeArray = obj.getJSONArray("time");
|
if (timeArray == null || timeArray.isEmpty()) {
|
continue;
|
}
|
List<TimeInterval> intervals = TimeInterval.parseRangesDedup(timeArray);
|
List<TimeInterval> merged = TimeInterval.merge(intervals);
|
if (!merged.isEmpty()) {
|
groups.add(merged);
|
}
|
}
|
return new OperationStrategyIndex(groups);
|
} catch (Exception e) {
|
return new OperationStrategyIndex(Collections.emptyList());
|
}
|
}
|
|
/**
|
* 解析点位 {@code effective_time_period} JSON 数组字符串为时间区间列表(并合并)。
|
*
|
* @param jsonArrayStr 点位生效时间段 JSON(数组字符串)
|
* @return 时间区间列表;解析失败返回空列表
|
*/
|
public static List<TimeInterval> parsePointEffectiveTimePeriod(String jsonArrayStr) {
|
return TimeInterval.parseJsonRangesDedup(jsonArrayStr);
|
}
|
|
/**
|
* 判断两个围栏策略索引的 Union 运营时间覆盖范围是否一致。
|
* <p>
|
* 注意:不关心 JSON 的原始顺序与分组,只比较合并后的 Union 覆盖范围是否相同。
|
* </p>
|
*
|
* @param other 待比较索引
|
* @return true 表示 Union 覆盖范围一致
|
*/
|
public boolean equalsNormalized(OperationStrategyIndex other) {
|
if (other == null) {
|
return false;
|
}
|
return equalsIntervals(this.unionAllowed(), other.unionAllowed());
|
}
|
|
/**
|
* 判断相对新索引是否发生“运营时间范围缩短”。
|
* <p>
|
* 缩短定义:新 Union 覆盖范围被旧 Union 完整覆盖,且与旧 Union 不相等(newUnion ⊂ oldUnion)。
|
* </p>
|
*
|
* @param newIndex 新索引(编辑后的围栏策略)
|
* @return true 表示发生缩短
|
*/
|
public boolean hasAnyShrinkComparedTo(OperationStrategyIndex newIndex) {
|
if (newIndex == null) {
|
return false;
|
}
|
List<TimeInterval> oldUnion = this.unionAllowed();
|
List<TimeInterval> newUnion = newIndex.unionAllowed();
|
if (equalsIntervals(oldUnion, newUnion)) {
|
return false;
|
}
|
return TimeInterval.isWithin(newUnion, oldUnion);
|
}
|
|
/**
|
* 获取当前索引的 Union 运营时间覆盖范围(合并后的时间区间列表)。
|
*
|
* @return Union 区间列表(已 merge 并排序)
|
*/
|
public List<TimeInterval> unionAllowed() {
|
if (strategyIntervals == null || strategyIntervals.isEmpty()) {
|
return Collections.emptyList();
|
}
|
List<TimeInterval> union = strategyIntervals.stream()
|
.filter(Objects::nonNull)
|
.flatMap(List::stream)
|
.collect(Collectors.toList());
|
return TimeInterval.merge(union);
|
}
|
|
/**
|
* 判断点位的时间段是否为正常。
|
* <p>
|
* 规则:只要点位的全部运营时间段被围栏“任意一个策略组”的时间段范围完整覆盖,则为正常。
|
* </p>
|
*
|
* @param pointIntervals 点位时间区间列表
|
* @return true 表示正常;false 表示不在任何一个策略组覆盖范围内
|
*/
|
public boolean isPointScheduleWithin(List<TimeInterval> pointIntervals) {
|
if (pointIntervals == null || pointIntervals.isEmpty()) {
|
return true;
|
}
|
if (strategyIntervals == null || strategyIntervals.isEmpty()) {
|
return false;
|
}
|
for (List<TimeInterval> groupIntervals : strategyIntervals) {
|
if (TimeInterval.isWithin(pointIntervals, groupIntervals)) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* 兼容旧接口:忽略 {@code effectivePeriod},统一按每日(Union 覆盖范围)判断。
|
*
|
* @param effectivePeriod 点位生效周期(不再使用)
|
* @param pointIntervals 点位时间区间列表
|
* @return true 表示点位时间段被围栏 Union 覆盖
|
*/
|
public boolean isPointScheduleWithin(Integer effectivePeriod, List<TimeInterval> pointIntervals) {
|
return isPointScheduleWithin(pointIntervals);
|
}
|
|
/**
|
* 区间列表归一化:merge 合并并排序。
|
*/
|
private static List<TimeInterval> normalizeIntervals(List<TimeInterval> intervals) {
|
return TimeInterval.merge(intervals);
|
}
|
|
/**
|
* 比较两组区间在归一化(merge)后的覆盖范围是否完全一致。
|
*/
|
private static boolean equalsIntervals(List<TimeInterval> a, List<TimeInterval> b) {
|
List<TimeInterval> x = normalizeIntervals(a);
|
List<TimeInterval> y = normalizeIntervals(b);
|
if (x.size() != y.size()) {
|
return false;
|
}
|
for (int i = 0; i < x.size(); i++) {
|
TimeInterval xi = x.get(i);
|
TimeInterval yi = y.get(i);
|
if (xi.startMinute != yi.startMinute || xi.endMinute != yi.endMinute) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
public static final class TimeInterval {
|
private final int startMinute; // inclusive
|
private final int endMinute; // inclusive
|
|
/**
|
* 构造时间区间(闭区间,单位:分钟)。
|
*/
|
private TimeInterval(int startMinute, int endMinute) {
|
this.startMinute = startMinute;
|
this.endMinute = endMinute;
|
}
|
|
/**
|
* 解析 JSON 数组字符串为时间区间列表,并做 merge 合并。
|
*
|
* @param jsonArrayStr JSON 数组字符串(例如 ["08:00-12:00","13:00-18:00"])
|
* @return merge 后的区间列表;解析失败返回空列表
|
*/
|
static List<TimeInterval> parseJsonRangesDedup(String jsonArrayStr) {
|
if (jsonArrayStr == null || jsonArrayStr.isEmpty()) {
|
return Collections.emptyList();
|
}
|
try {
|
JSONArray arr = JSONArray.parseArray(jsonArrayStr);
|
if (arr == null || arr.isEmpty()) {
|
return Collections.emptyList();
|
}
|
return merge(parseRangesDedup(arr));
|
} catch (Exception e) {
|
return Collections.emptyList();
|
}
|
}
|
|
/**
|
* 解析 JSONArray 中的时间段为区间列表(不做 merge)。
|
*
|
* @param timeArray 时间段数组(元素形如 "HH:mm-HH:mm")
|
* @return 区间列表
|
*/
|
static List<TimeInterval> parseRanges(JSONArray timeArray) {
|
List<TimeInterval> result = new java.util.ArrayList<>();
|
for (int i = 0; i < timeArray.size(); i++) {
|
String range = timeArray.getString(i);
|
result.addAll(parseRange(range));
|
}
|
return result;
|
}
|
|
/**
|
* 解析 JSONArray 中的时间段为区间列表(先按字符串去重,再解析;不做 merge)。
|
*/
|
static List<TimeInterval> parseRangesDedup(JSONArray timeArray) {
|
if (timeArray == null || timeArray.isEmpty()) {
|
return Collections.emptyList();
|
}
|
Set<String> uniq = new LinkedHashSet<>();
|
for (int i = 0; i < timeArray.size(); i++) {
|
String range = timeArray.getString(i);
|
if (range != null && !range.isEmpty()) {
|
uniq.add(range);
|
}
|
}
|
List<TimeInterval> result = new java.util.ArrayList<>();
|
for (String range : uniq) {
|
result.addAll(parseRange(range));
|
}
|
return result;
|
}
|
|
/**
|
* 解析单个时间段字符串为区间列表。
|
* <p>
|
* 注意:业务约束不会出现跨天时间段;若出现 end < start,则视为非法配置并忽略该时间段。
|
* </p>
|
*/
|
private static List<TimeInterval> parseRange(String range) {
|
if (range == null || !range.contains("-")) {
|
return Collections.emptyList();
|
}
|
String[] parts = range.split("-");
|
if (parts.length != 2) {
|
return Collections.emptyList();
|
}
|
Integer start = parseMinute(parts[0].trim());
|
Integer end = parseMinute(parts[1].trim());
|
if (start == null || end == null) {
|
return Collections.emptyList();
|
}
|
if (end < start) {
|
return Collections.emptyList();
|
}
|
return java.util.Collections.singletonList(new TimeInterval(start, end));
|
}
|
|
/**
|
* 将 HH:mm 解析为分钟数(0-1439),非法格式返回 null。
|
*/
|
private static Integer parseMinute(String hhmm) {
|
if (hhmm == null || hhmm.length() != 5 || hhmm.charAt(2) != ':') {
|
return null;
|
}
|
try {
|
int hour = Integer.parseInt(hhmm.substring(0, 2));
|
int minute = Integer.parseInt(hhmm.substring(3, 5));
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
return null;
|
}
|
return hour * 60 + minute;
|
} catch (Exception e) {
|
return null;
|
}
|
}
|
|
/**
|
* 合并重叠/相邻区间,并按起始分钟排序。
|
*/
|
static List<TimeInterval> merge(List<TimeInterval> intervals) {
|
if (intervals == null || intervals.isEmpty()) {
|
return Collections.emptyList();
|
}
|
List<TimeInterval> sorted = intervals.stream()
|
.filter(Objects::nonNull)
|
.sorted(java.util.Comparator.comparingInt(a -> a.startMinute))
|
.collect(Collectors.toList());
|
if (sorted.isEmpty()) {
|
return Collections.emptyList();
|
}
|
List<TimeInterval> merged = new java.util.ArrayList<>();
|
TimeInterval cur = sorted.get(0);
|
for (int i = 1; i < sorted.size(); i++) {
|
TimeInterval next = sorted.get(i);
|
if (next.startMinute <= cur.endMinute + 1) {
|
cur = new TimeInterval(cur.startMinute, Math.max(cur.endMinute, next.endMinute));
|
} else {
|
merged.add(cur);
|
cur = next;
|
}
|
}
|
merged.add(cur);
|
return merged;
|
}
|
|
/**
|
* 判断 pointIntervals 是否完全被 allowedIntervals 覆盖(每段都需被某个 allowed 段完全包含)。
|
*/
|
static boolean isWithin(List<TimeInterval> pointIntervals, List<TimeInterval> allowedIntervals) {
|
if (pointIntervals == null || pointIntervals.isEmpty()) {
|
return true;
|
}
|
List<TimeInterval> allowed = merge(allowedIntervals);
|
if (allowed.isEmpty()) {
|
return false;
|
}
|
for (TimeInterval p : pointIntervals) {
|
boolean covered = false;
|
for (TimeInterval a : allowed) {
|
if (a.startMinute <= p.startMinute && a.endMinute >= p.endMinute) {
|
covered = true;
|
break;
|
}
|
}
|
if (!covered) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
/**
|
* 字符串化:便于 main 输出与排查(格式:startMinute-endMinute)。
|
*/
|
@Override
|
public String toString() {
|
return startMinute + "-" + endMinute;
|
}
|
}
|
|
/**
|
* main 自测:使用固定样例打印 old/new Union、是否修改/缩短,以及点位在旧/新范围内判断结果。
|
*/
|
public static void main(String[] args) {
|
String pointJson = "[\"05:00-12:00\", \"13:00-21:59\"]";
|
String strategyJson = "[{\"name\": \"策略组1\", \"time\": [\"06:00-22:00\", \"22:00-23:59\"], \"type\": 0, \"typeStr\": []}, {\"name\": \"策略组2\", \"time\": [\"06:00-12:00\", \"12:00-23:00\"], \"type\": 2, \"typeStr\": \"\"}, {\"name\": \"策略组3\", \"time\": [\"09:00-23:59\"], \"type\": 1, \"typeStr\": \"\"}]";
|
|
OperationStrategyIndex index = OperationStrategyIndex.parse(strategyJson);
|
List<TimeInterval> pointIntervals = OperationStrategyIndex.parsePointEffectiveTimePeriod(pointJson);
|
|
System.out.println("pointIntervals=" + pointIntervals);
|
System.out.println("fenceUnion=" + index.unionAllowed());
|
boolean ok = index.isPointScheduleWithin(pointIntervals);
|
System.out.println("isNormal=" + ok);
|
}
|
}
|