businessIds);
/**
* 获取当前流程状态
diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java
index b4d146242..6c450853c 100644
--- a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DateUtils.java
@@ -1,5 +1,7 @@
package org.dromara.common.core.utils;
+import cn.hutool.core.date.DateUnit;
+import cn.hutool.core.date.DateUtil;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.dromara.common.core.enums.FormatsType;
import org.dromara.common.core.exception.ServiceException;
@@ -297,4 +299,80 @@ public class DateUtils extends org.apache.commons.lang3.time.DateUtils {
}
}
+ /**
+ * 根据指定日期时间获取时间段(凌晨 / 上午 / 中午 / 下午 / 晚上)
+ *
+ * @param date 日期时间
+ * @return 时间段描述
+ */
+ public static String getTodayHour(Date date) {
+ int hour = DateUtil.hour(date, true);
+ if (hour <= 6) {
+ return "凌晨";
+ } else if (hour < 12) {
+ return "上午";
+ } else if (hour == 12) {
+ return "中午";
+ } else if (hour <= 18) {
+ return "下午";
+ } else {
+ return "晚上";
+ }
+ }
+
+ /**
+ * 将日期格式化为仿微信的友好时间
+ *
+ * 规则说明:
+ * 1. 未来时间:yyyy-MM-dd HH:mm
+ * 2. 今天:
+ * - 1 分钟内:刚刚
+ * - 1 小时内:X 分钟前
+ * - 超过 1 小时:凌晨/上午/中午/下午/晚上 HH:mm
+ * 3. 昨天:昨天 HH:mm
+ * 4. 本周:周X HH:mm
+ * 5. 今年内:MM-dd HH:mm
+ * 6. 非今年:yyyy-MM-dd HH:mm
+ *
+ * @param date 日期时间
+ * @return 格式化后的时间描述
+ */
+ public static String formatFriendlyTime(Date date) {
+ if (date == null) {
+ return "";
+ }
+ Date now = DateUtil.date();
+
+ // 未来时间或非今年
+ if (date.after(now) || DateUtil.year(date) != DateUtil.year(now)) {
+ return parseDateToStr(FormatsType.YYYY_MM_DD_HH_MM, date);
+ }
+
+ // 今天
+ if (DateUtil.isSameDay(date, now)) {
+ long minutes = DateUtil.between(date, now, DateUnit.MINUTE);
+ if (minutes < 1) {
+ return "刚刚";
+ }
+ if (minutes < 60) {
+ return minutes + "分钟前";
+ }
+ return getTodayHour(date) + " " + DateUtil.format(date, "HH:mm");
+ }
+
+ // 昨天
+ if (DateUtil.isSameDay(date, DateUtil.yesterday())) {
+ return "昨天 " + DateUtil.format(date, "HH:mm");
+ }
+
+ // 本周
+ if (DateUtil.isSameWeek(date, now, true)) {
+ return DateUtil.dayOfWeekEnum(date).toChinese("周")
+ + " " + DateUtil.format(date, "HH:mm");
+ }
+
+ // 今年内其它时间
+ return DateUtil.format(date, "MM-dd HH:mm");
+ }
+
}
diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DesensitizedUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DesensitizedUtils.java
new file mode 100644
index 000000000..dafd8e6c4
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/DesensitizedUtils.java
@@ -0,0 +1,54 @@
+package org.dromara.common.core.utils;
+
+import cn.hutool.core.util.DesensitizedUtil;
+import cn.hutool.core.util.StrUtil;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * 脱敏工具类
+ *
+ * @author AprilWind
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class DesensitizedUtils extends DesensitizedUtil {
+
+ /**
+ * 灵活脱敏方法
+ *
+ * @param value 原始字符串
+ * @param prefixVisible 前面可见长度
+ * @param suffixVisible 后面可见长度
+ * @param maskLength 中间掩码长度(固定显示多少 *,如果总长度不足则自动缩减)
+ * @return 脱敏后字符串
+ */
+ public static String mask(String value, int prefixVisible, int suffixVisible, int maskLength) {
+ if (StrUtil.isBlank(value)) {
+ return value;
+ }
+
+ int len = value.length();
+
+ // 总长度小于等于前后可见长度 → 全部掩码
+ if (len <= prefixVisible + suffixVisible) {
+ return StrUtil.repeat('*', len);
+ }
+
+ // 可用长度 = 总长度 - 前后可见长度
+ int available = len - prefixVisible - suffixVisible;
+
+ // 中间掩码长度不能超过可用长度
+ int actualMaskLength = Math.min(maskLength, available);
+
+ // 剩余字符尽量显示在中间掩码旁
+ int remaining = available - actualMaskLength;
+ String middleChars = remaining > 0 ? value.substring(prefixVisible, prefixVisible + remaining) : "";
+ String middleMask = StrUtil.repeat('*', actualMaskLength);
+
+ String prefix = value.substring(0, prefixVisible);
+ String suffix = value.substring(len - suffixVisible);
+
+ return prefix + middleChars + middleMask + suffix;
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java
index 2fe3bd7b6..fe36d9cc0 100644
--- a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/AddressUtils.java
@@ -20,51 +20,24 @@ public class AddressUtils {
public static final String UNKNOWN_IP = "XX XX";
// 内网地址
public static final String LOCAL_ADDRESS = "内网IP";
- // 未知地址
- public static final String UNKNOWN_ADDRESS = "未知";
public static String getRealAddressByIP(String ip) {
// 处理空串并过滤HTML标签
ip = HtmlUtil.cleanHtmlTag(StringUtils.blankToDefault(ip,""));
// 判断是否为IPv4
- if (NetUtils.isIPv4(ip)) {
- return resolverIPv4Region(ip);
- }
+ boolean isIPv4 = NetUtils.isIPv4(ip);
// 判断是否为IPv6
- if (NetUtils.isIPv6(ip)) {
- return resolverIPv6Region(ip);
- }
+ boolean isIPv6 = NetUtils.isIPv6(ip);
// 如果不是IPv4或IPv6,则返回未知IP
- return UNKNOWN_IP;
- }
-
- /**
- * 根据IPv4地址查询IP归属行政区域
- * @param ip ipv4地址
- * @return 归属行政区域
- */
- private static String resolverIPv4Region(String ip){
+ if (!isIPv4 && !isIPv6) {
+ return UNKNOWN_IP;
+ }
// 内网不查询
- if (NetUtils.isInnerIP(ip)) {
+ if ((isIPv4 && NetUtils.isInnerIP(ip)) || (isIPv6 && NetUtils.isInnerIPv6(ip))) {
return LOCAL_ADDRESS;
}
- return RegionUtils.getCityInfo(ip);
- }
-
- /**
- * 根据IPv6地址查询IP归属行政区域
- * @param ip ipv6地址
- * @return 归属行政区域
- */
- private static String resolverIPv6Region(String ip){
- // 内网不查询
- if (NetUtils.isInnerIPv6(ip)) {
- return LOCAL_ADDRESS;
- }
- log.warn("ip2region不支持IPV6地址解析:{}", ip);
- // 不支持IPv6,不再进行没有必要的IP地址信息的解析,直接返回
- // 如有需要,可自行实现IPv6地址信息解析逻辑,并在这里返回
- return UNKNOWN_ADDRESS;
+ // Tips:Ip2Region 提供了精简的IPv6地址库,精简的IPv6地址库并不能完全支持IPv6地址的查询,且准确度上可能会存在问题,如需要准确的IPv6地址查询,建议自行实现
+ return RegionUtils.getRegion(ip);
}
}
diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java
index c9e867899..bab520838 100644
--- a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/ip/RegionUtils.java
@@ -1,50 +1,142 @@
package org.dromara.common.core.utils.ip;
-import cn.hutool.core.io.resource.NoResourceException;
import cn.hutool.core.io.resource.ResourceUtil;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
-import org.lionsoul.ip2region.xdb.Searcher;
+import org.lionsoul.ip2region.service.Config;
+import org.lionsoul.ip2region.service.Ip2Region;
+import org.lionsoul.ip2region.xdb.Util;
+
+import java.io.InputStream;
+import java.time.Duration;
/**
- * 根据ip地址定位工具类,离线方式
- * 参考地址:集成 ip2region 实现离线IP地址定位库
+ * IP地址行政区域工具类
+ * 参考地址:ip2region xdb java 查询客户端实现
+ * xdb数据库文件下载:ip2region data
*
- * @author lishuyan
+ * @author 秋辞未寒
*/
@Slf4j
public class RegionUtils {
- // IP地址库文件名称
- public static final String IP_XDB_FILENAME = "ip2region.xdb";
+ // 默认IPv4地址库文件路径
+ // 下载地址:https://gitee.com/lionsoul/ip2region/blob/master/data/ip2region_v4.xdb
+ public static final String DEFAULT_IPV4_XDB_PATH = "ip2region_v4.xdb";
- private static final Searcher SEARCHER;
+ // 默认IPv6地址库文件路径
+ // 下载地址:https://gitee.com/lionsoul/ip2region/blob/master/data/ip2region_v6.xdb
+ public static final String DEFAULT_IPV6_XDB_PATH = "ip2region_v6.xdb";
+ // 未知地址
+ public static final String UNKNOWN_ADDRESS = "未知";
+
+ // Ip2Region服务实例
+ private static Ip2Region ip2Region;
+
+ // 初始化Ip2Region服务实例
static {
try {
- // 1、将 ip2region 数据库文件 xdb 从 ClassPath 加载到内存。
- // 2、基于加载到内存的 xdb 数据创建一个 Searcher 查询对象。
- SEARCHER = Searcher.newWithBuffer(ResourceUtil.readBytes(IP_XDB_FILENAME));
- log.info("RegionUtils初始化成功,加载IP地址库数据成功!");
- } catch (NoResourceException e) {
- throw new ServiceException("RegionUtils初始化失败,原因:IP地址库数据不存在!");
+ // 注意:Ip2Region 的xdb文件加载策略 CachePolicy 有三种,分别是:BufferCache(全量读取xdb到内存中)、VIndexCache(默认策略,按需读取并缓存)、NoCache(实时读取)
+ // 本项目工具使用的 CachePolicy 为 BufferCache,BufferCache会加载整个xdb文件到内存中,setXdbInputStream 仅支持 BufferCache 策略。
+ // 因为加载整个xdb文件会耗费非常大的内存,如果你不希望加载整个xdb到内存中,更推荐使用 VIndexCache 或 NoCache(即实时读取文件)策略和 setXdbPath/setXdbFile 加载方法(需要注意的一点,setXdbPath 和 setXdbFile 不支持读取ClassPath(即源码和resource目录)中的文件)。
+ // 一般而言,更建议把xdb数据库放到一个指定的文件目录中(即不打包进jar包中),然后使用 NoCache + 配合SearcherPool的并发池读取数据,更方便随时更新xdb数据库
+
+ // IPv4配置
+ Config v4Config = Config.custom()
+ .setCachePolicy(Config.BufferCache)
+ .setXdbInputStream(ResourceUtil.getStream(DEFAULT_IPV4_XDB_PATH))
+ .asV4();
+
+ // IPv6配置
+ Config v6Config = null;
+ InputStream v6XdbInputStream = ResourceUtil.getStreamSafe(DEFAULT_IPV6_XDB_PATH);
+ if (v6XdbInputStream == null) {
+ log.warn("未加载 IPv6 地址库:未在类路径下找到文件 {}。当前仅启用 IPv4 查询。如需启用 IPv6,请将 ip2region_v6.xdb 放置到 resources 目录", DEFAULT_IPV6_XDB_PATH);
+ } else {
+ v6Config = Config.custom()
+ .setCachePolicy(Config.BufferCache)
+ .setXdbInputStream(v6XdbInputStream)
+ .asV6();
+ }
+
+ // 初始化Ip2Region实例
+ RegionUtils.ip2Region = Ip2Region.create(v4Config, v6Config);
+ log.debug("IP工具初始化成功,加载IP地址库数据成功!");
} catch (Exception e) {
- throw new ServiceException("RegionUtils初始化失败,原因:" + e.getMessage());
+ throw new ServiceException("RegionUtils初始化失败,原因:{}", e.getMessage());
}
}
/**
* 根据IP地址离线获取城市
+ *
+ * @param ipString ip地址字符串
*/
- public static String getCityInfo(String ip) {
+ public static String getRegion(String ipString) {
try {
- // 3、执行查询
- String region = SEARCHER.search(StringUtils.trim(ip));
- return region.replace("0|", "").replace("|0", "");
+ String region = ip2Region.search(ipString);
+ if (StringUtils.isBlank(region)) {
+ region = UNKNOWN_ADDRESS;
+ }
+ return region;
} catch (Exception e) {
- log.error("IP地址离线获取城市异常 {}", ip);
- return "未知";
+ log.error("IP地址离线获取城市异常 {}", ipString);
+ return UNKNOWN_ADDRESS;
+ }
+
+ }
+
+ /**
+ * 根据IP地址离线获取城市
+ *
+ * @param ipBytes ip地址字节数组
+ */
+ public static String getRegion(byte[] ipBytes) {
+ try {
+ String region = ip2Region.search(ipBytes);
+ if (StringUtils.isBlank(region)) {
+ region = UNKNOWN_ADDRESS;
+ }
+ return region;
+ } catch (Exception e) {
+ log.error("IP地址离线获取城市异常 {}", Util.ipToString(ipBytes));
+ return UNKNOWN_ADDRESS;
+ }
+ }
+
+ /**
+ * 关闭Ip2Region服务
+ */
+ public static void close() {
+ if (ip2Region == null) {
+ return;
+ }
+ try {
+ ip2Region.close(10000);
+ } catch (Exception e) {
+ log.error("Ip2Region服务关闭异常", e);
+ }
+ }
+
+ /**
+ * 关闭Ip2Region服务
+ *
+ * @param timeout 关闭超时时间
+ */
+ public static void close(final Duration timeout) {
+ if (ip2Region == null) {
+ return;
+ }
+ if (timeout == null) {
+ close();
+ return;
+ }
+ try {
+ ip2Region.close(timeout.toMillis());
+ } catch (Exception e) {
+ log.error("Ip2Region服务关闭异常", e);
}
}
diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDynamicOptions.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDynamicOptions.java
new file mode 100644
index 000000000..c26bd68ff
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/annotation/ExcelDynamicOptions.java
@@ -0,0 +1,23 @@
+package org.dromara.common.excel.annotation;
+
+import org.dromara.common.excel.core.ExcelOptionsProvider;
+
+import java.lang.annotation.*;
+
+/**
+ * Excel动态下拉选项注解
+ *
+ * @author Angus
+ */
+@Target({ElementType.FIELD})
+@Retention(RetentionPolicy.RUNTIME)
+@Inherited
+public @interface ExcelDynamicOptions {
+
+ /**
+ * 提供者类全限定名
+ *
+ * {@link org.dromara.common.excel.core.ExcelOptionsProvider} 接口实现类 class
+ */
+ Class extends ExcelOptionsProvider> providerClass();
+}
diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeHandler.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeHandler.java
index 204a88e25..6a1a3a72d 100644
--- a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeHandler.java
+++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeHandler.java
@@ -29,7 +29,10 @@ public class CellMergeHandler {
// 行合并开始下标
this.rowIndex = hasTitle ? 1 : 0;
}
-
+ private CellMergeHandler(final boolean hasTitle, final int rowIndex) {
+ this.hasTitle = hasTitle;
+ this.rowIndex = hasTitle ? rowIndex : 0;
+ }
@SneakyThrows
public List handle(List> rows) {
// 如果入参为空集合则返回空集
@@ -103,6 +106,10 @@ public class CellMergeHandler {
}
if (isAddResult && i > current) {
+ //如果是同一行,则跳过合并
+ if (current + rowIndex == lastRow) {
+ continue;
+ }
result.add(new CellRangeAddress(current + rowIndex, lastRow, colNum, colNum));
}
}
@@ -147,12 +154,12 @@ public class CellMergeHandler {
private boolean isMerge(Object currentRow, Object preRow, CellMerge cellMerge) {
final String[] mergeBy = cellMerge.mergeBy();
if (StrUtil.isAllNotBlank(mergeBy)) {
- //比对当前行和上一行的各个属性值一一比对 如果全为真 则为真
+ // 比对当前行和上一行的各个属性值一一比对 如果全为真 则为真
for (String fieldName : mergeBy) {
final Object valCurrent = ReflectUtil.getFieldValue(currentRow, fieldName);
final Object valPre = ReflectUtil.getFieldValue(preRow, fieldName);
if (!Objects.equals(valPre, valCurrent)) {
- //依赖字段如有任一不等值,则标记为不可合并
+ // 依赖字段如有任一不等值,则标记为不可合并
return false;
}
}
@@ -177,6 +184,16 @@ public class CellMergeHandler {
return new FieldColumnIndex(colIndex, cellMerge);
}
}
+ /**
+ * 创建一个单元格合并处理器实例
+ *
+ * @param hasTitle 是否合并标题
+ * @param rowIndex 行索引
+ * @return 单元格合并处理器
+ */
+ public static CellMergeHandler of(final boolean hasTitle, final int rowIndex) {
+ return new CellMergeHandler(hasTitle, rowIndex);
+ }
/**
* 创建一个单元格合并处理器实例
diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java
index 34bf4a42f..64d8c7779 100644
--- a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java
+++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/CellMergeStrategy.java
@@ -2,15 +2,16 @@ package org.dromara.common.excel.core;
import cn.hutool.core.collection.CollUtil;
import cn.idev.excel.metadata.Head;
-import cn.idev.excel.write.handler.WorkbookWriteHandler;
-import cn.idev.excel.write.handler.context.WorkbookWriteHandlerContext;
+import cn.idev.excel.write.handler.SheetWriteHandler;
import cn.idev.excel.write.merge.AbstractMergeStrategy;
+import cn.idev.excel.write.metadata.holder.WriteSheetHolder;
+import cn.idev.excel.write.metadata.holder.WriteWorkbookHolder;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
-import java.util.*;
+import java.util.List;
/**
* 列值重复合并策略
@@ -18,7 +19,7 @@ import java.util.*;
* @author Lion Li
*/
@Slf4j
-public class CellMergeStrategy extends AbstractMergeStrategy implements WorkbookWriteHandler {
+public class CellMergeStrategy extends AbstractMergeStrategy implements SheetWriteHandler {
private final List cellList;
@@ -30,29 +31,34 @@ public class CellMergeStrategy extends AbstractMergeStrategy implements Workbook
this.cellList = CellMergeHandler.of(hasTitle).handle(list);
}
+ public CellMergeStrategy(List> list, boolean hasTitle, int rowIndex) {
+ this.cellList = CellMergeHandler.of(hasTitle, rowIndex).handle(list);
+ }
+
@Override
protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {
- if (CollUtil.isEmpty(cellList)){
+ if (CollUtil.isEmpty(cellList)) {
return;
}
- //单元格写入了,遍历合并区域,如果该Cell在区域内,但非首行,则清空
+ // 单元格写入了,遍历合并区域,如果该Cell在区域内,但非首行,则清空
final int rowIndex = cell.getRowIndex();
for (CellRangeAddress cellAddresses : cellList) {
final int firstRow = cellAddresses.getFirstRow();
- if (cellAddresses.isInRange(cell) && rowIndex != firstRow){
+ if (cellAddresses.isInRange(cell) && rowIndex != firstRow) {
cell.setBlank();
}
}
}
@Override
- public void afterWorkbookDispose(final WorkbookWriteHandlerContext context) {
- if (CollUtil.isEmpty(cellList)){
+ public void afterSheetCreate(final WriteWorkbookHolder writeWorkbookHolder, final WriteSheetHolder writeSheetHolder) {
+ if (CollUtil.isEmpty(cellList)) {
return;
}
- //当前表格写完后,统一写入
+ // 在 Sheet 创建时提前写入合并区域;后续写入只会影响首格,不会移除合并
+ final Sheet sheet = writeSheetHolder.getSheet();
for (CellRangeAddress item : cellList) {
- context.getWriteContext().writeSheetHolder().getSheet().addMergedRegion(item);
+ sheet.addMergedRegion(item);
}
}
diff --git a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java
index 139728390..05c79c410 100644
--- a/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java
+++ b/ruoyi-common/ruoyi-common-excel/src/main/java/org/dromara/common/excel/core/ExcelDownHandler.java
@@ -23,6 +23,7 @@ import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.excel.annotation.ExcelDictFormat;
+import org.dromara.common.excel.annotation.ExcelDynamicOptions;
import org.dromara.common.excel.annotation.ExcelEnumFormat;
import java.lang.reflect.Field;
@@ -117,6 +118,15 @@ public class ExcelDownHandler implements SheetWriteHandler {
ExcelEnumFormat format = field.getDeclaredAnnotation(ExcelEnumFormat.class);
List