ruleList = StringUtils.split(accessUaBlocklist, StringUtils.LF);
+ return ruleMatcher.matchAny(ruleList, currentAccessUA);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java b/src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java
new file mode 100644
index 0000000..3eb59ad
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2002-2018 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.zhaojun.zfile.core.io;
+
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.InputStreamResource;
+
+import java.io.InputStream;
+
+/**
+ *
+ * 自定义 EnsureContentLengthInputStreamResource 可以保证必须实现 InputStream 的 contentLength 方法返回实际的长度.
+ * 此类相较于 {@link org.springframework.core.io.InputStreamResource} 仅实现了 contentLength 方法.
+ *
+ * {@link org.springframework.core.io.Resource} implementation for a given {@link InputStream}.
+ * Should only be used if no other specific {@code Resource} implementation
+ * is applicable. In particular, prefer {@link ByteArrayResource} or any of the
+ * file-based {@code Resource} implementations where possible.
+ *
+ *
In contrast to other {@code Resource} implementations, this is a descriptor
+ * for an already opened resource - therefore returning {@code true} from
+ * {@link #isOpen()}. Do not use an {@code InputStreamResource} if you need to
+ * keep the resource descriptor somewhere, or if you need to read from a stream
+ * multiple times.
+ *
+ * @author Juergen Hoeller
+ * @author Sam Brannen
+ * @since 28.12.2003
+ * @see ByteArrayResource
+ * @see org.springframework.core.io.ClassPathResource
+ * @see org.springframework.core.io.FileSystemResource
+ * @see org.springframework.core.io.UrlResource
+ */
+public class EnsureContentLengthInputStreamResource extends InputStreamResource {
+
+ private final long contentLength;
+
+ /**
+ * Create a new InputStreamResource.
+ * @param inputStream the InputStream to use
+ */
+ public EnsureContentLengthInputStreamResource(InputStream inputStream, long contentLength) {
+ super(inputStream);
+ this.contentLength = contentLength;
+ }
+
+ @Override
+ public long contentLength() {
+ return contentLength;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java b/src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java
new file mode 100644
index 0000000..839c75e
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java
@@ -0,0 +1,34 @@
+package im.zhaojun.zfile.core.model.request;
+
+import com.baomidou.mybatisplus.core.metadata.OrderItem;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Objects;
+
+/**
+ * 通用分页请求对象,可继承该类增加业务字段.
+ *
+ * @author zhaojun
+ */
+@Data
+public class PageQueryRequest {
+
+ @Schema(title="分页页数")
+ private Integer page = 1;
+
+ @Schema(title="每页条数")
+ private Integer limit = 10;
+
+ @Schema(title="排序字段")
+ private String orderBy = "create_date";
+
+ @Schema(title="排序顺序")
+ private String orderDirection = "desc";
+
+ public OrderItem getOrderItem() {
+ boolean asc = Objects.equals(orderDirection, "asc");
+ return asc ? OrderItem.asc(orderBy) : OrderItem.desc(orderBy);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java b/src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java
new file mode 100644
index 0000000..bea1738
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java
@@ -0,0 +1,107 @@
+package im.zhaojun.zfile.core.util;
+
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.ToString;
+
+import java.io.Serializable;
+
+/**
+ * ajax 请求返回 JSON 格式数据的封装
+ *
+ * @author zhaojun
+ */
+@Data
+@ToString
+public class AjaxJson implements Serializable {
+
+ private static final long serialVersionUID = 1L; // 序列化版本号
+
+ public static final String CODE_SUCCESS = "0"; // 成功状态码
+
+ @Schema(title = "业务状态码,0 为正常,其他值均为异常,异常情况下见响应消息", example = "0")
+ private final String code;
+
+ @Schema(title = "响应消息", example = "ok")
+ private String msg;
+
+ @Schema(title = "响应数据")
+ private T data;
+
+ @Schema(title = "数据总条数,分页情况有效")
+ private final Long dataCount;
+
+ @Schema(title = "跟踪 ID")
+ private String traceId;
+
+ public AjaxJson(String code, String msg) {
+ if (code == null) {
+ code = ErrorCode.SYSTEM_ERROR.getCode();
+ }
+ this.code = code;
+ this.msg = msg;
+ this.dataCount = null;
+ }
+
+ public AjaxJson(String code, String msg, T data) {
+ this.code = code;
+ this.msg = msg;
+ this.data = data;
+ this.dataCount = null;
+ }
+
+ public AjaxJson(String code, String msg, T data, Long dataCount) {
+ this.code = code;
+ this.msg = msg;
+ this.data = data;
+ this.dataCount = dataCount;
+ }
+
+ // 返回成功
+ public static AjaxJson getSuccess() {
+ return new AjaxJson<>(CODE_SUCCESS, "ok");
+ }
+
+ public static AjaxJson getSuccess(String msg) {
+ return new AjaxJson<>(CODE_SUCCESS, msg);
+ }
+
+ public static AjaxJson getSuccess(String msg, T data) {
+ return new AjaxJson<>(CODE_SUCCESS, msg, data);
+ }
+
+ public static AjaxJson getSuccessData(T data) {
+ return new AjaxJson<>(CODE_SUCCESS, "ok", data);
+ }
+
+ // 返回分页和数据的
+ public static AjaxJson getPageData(Long dataCount, T data) {
+ return new AjaxJson<>(CODE_SUCCESS, "ok", data, dataCount);
+ }
+
+ // 返回错误
+ public static AjaxJson getError(String msg) {
+ return new AjaxJson<>(ErrorCode.SYSTEM_ERROR.getCode(), msg);
+ }
+
+ // 返回未登录
+ public static AjaxJson> getUnauthorizedResult() {
+ return new AjaxJson<>(ErrorCode.BIZ_UNAUTHORIZED.getCode(), "未登录,请登录后再次访问");
+ }
+
+ // 返回没权限的
+ public static AjaxJson> getForbiddenResult() {
+ return new AjaxJson<>(ErrorCode.NO_FORBIDDEN.getCode(), "未授权,请登录正确权限账号再试");
+ }
+
+ // 返回未找到的
+ public static AjaxJson> getNotFoundResult() {
+ return new AjaxJson<>(ErrorCode.BIZ_NOT_FOUND.getCode(), ErrorCode.BIZ_NOT_FOUND.getMessage());
+ }
+
+ public static AjaxJson> getError(String code, String msg) {
+ return new AjaxJson<>(code, msg);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java b/src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java
new file mode 100644
index 0000000..cac7772
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java
@@ -0,0 +1,40 @@
+package im.zhaojun.zfile.core.util;
+
+/**
+ * 数组工具类
+ *
+ * @author zhaojun
+ */
+public class ArrayUtils {
+
+ /**
+ * 数组是否为空
+ *
+ * @param
+ * 数组元素类型
+ *
+ * @param array
+ * 数组
+ *
+ * @return 是否为空
+ */
+ public static boolean isEmpty(T[] array) {
+ return array == null || array.length == 0;
+ }
+
+ /**
+ * 数组是否不为空
+ *
+ * @param
+ * 数组元素类型
+ *
+ * @param array
+ * 数组
+ *
+ * @return 是否不为空
+ */
+ public static boolean isNotEmpty(T[] array) {
+ return !isEmpty(array);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/CharPool.java b/src/main/java/im/zhaojun/zfile/core/util/CharPool.java
new file mode 100644
index 0000000..480188e
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/CharPool.java
@@ -0,0 +1,10 @@
+package im.zhaojun.zfile.core.util;
+
+public interface CharPool {
+
+ /**
+ * CHAR 常量:斜杠 {@code '/'} ASCII 47
+ */
+ char SLASH_CHAR = '/';
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java b/src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java
new file mode 100644
index 0000000..ae1728a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java
@@ -0,0 +1,727 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.text.StrSplitter;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 字符串工具类
+ *
+ * @author zhaojun
+ */
+public class CharSequenceUtil implements CharPool {
+
+ /**
+ * 找不到索引时的返回值
+ */
+ public static final int INDEX_NOT_FOUND = -1;
+
+ /**
+ * 字符串常量:{@code "null"}
+ * 注意:{@code "null" != null}
+ */
+ public static final String NULL = "null";
+
+ /**
+ * 字符串常量:空字符串 {@code ""}
+ */
+ public static final String EMPTY = "";
+
+ /**
+ * 字符串常量:空格符 {@code " "}
+ */
+ public static final String SPACE = " ";
+
+
+ /**
+ * 获取 CharSequence 的长度, 如果为 null, 返回 0
+ *
+ * @param ch
+ * 要获取长度的 CharSequence, 可能为 null
+ *
+ * @return CharSequence 的长度
+ */
+ public static int length(final @Nullable CharSequence ch) {
+ return ch == null ? 0 : ch.length();
+ }
+
+
+ /**
+ * {@link CharSequence} 转为字符串
+ *
+ * @param cs
+ * {@link CharSequence}
+ *
+ * @return 字符串
+ */
+ public static String str(final @Nullable CharSequence cs) {
+ return null == cs ? null : cs.toString();
+ }
+
+
+ /**
+ * 判断 CharSequence 是否为空
+ *
+ * @param cs
+ * {@link CharSequence}
+ *
+ * @return 是否为空
+ */
+ public static boolean isEmpty(final @Nullable CharSequence cs) {
+ return cs == null || cs.isEmpty();
+ }
+
+
+ /**
+ * CharSequence 是否不为空
+ *
+ * @param cs
+ * {@link CharSequence}
+ *
+ * @return 是否不为空
+ */
+ public static boolean isNotEmpty(final @Nullable CharSequence cs) {
+ return !isEmpty(cs);
+ }
+
+
+ /**
+ * 指定字符串数组中的元素,是否全部为空字符串。
+ * 如果指定的字符串数组的长度为 0,或者所有元素都是空字符串,则返回 true。
+ *
+ *
+ * 例:
+ *
+ * - {@code CharSequenceUtil.isAllEmpty() // true}
+ * - {@code CharSequenceUtil.isAllEmpty("", null) // true}
+ * - {@code CharSequenceUtil.isAllEmpty("123", "") // false}
+ * - {@code CharSequenceUtil.isAllEmpty("123", "abc") // false}
+ * - {@code CharSequenceUtil.isAllEmpty(" ", "\t", "\n") // false}
+ *
+ *
+ * @param strs
+ * 字符串列表
+ *
+ * @return 所有字符串是否都为空
+ */
+ public static boolean isAllEmpty(final @Nullable CharSequence... strs) {
+ if (strs == null) {
+ return true;
+ }
+
+ for (CharSequence str : strs) {
+ if (isNotEmpty(str)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 是否包含空字符串。
+ * 如果指定的字符串数组的长度为 0,或者其中的任意一个元素是空字符串,则返回 true。
+ *
+ *
+ * 例:
+ *
+ * - {@code CharSequenceUtil.hasEmpty() // true}
+ * - {@code CharSequenceUtil.hasEmpty("", null) // true}
+ * - {@code CharSequenceUtil.hasEmpty("123", "") // true}
+ * - {@code CharSequenceUtil.hasEmpty("123", "abc") // false}
+ * - {@code CharSequenceUtil.hasEmpty(" ", "\t", "\n") // false}
+ *
+ *
+ * @param strs
+ * 字符串列表
+ *
+ * @return 是否包含空字符串
+ */
+ public static boolean hasEmpty(final @Nullable CharSequence... strs) {
+ if (ArrayUtils.isEmpty(strs)) {
+ return true;
+ }
+
+ for (CharSequence str : strs) {
+ if (isEmpty(str)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ /**
+ * 指定字符串数组中的元素,是否都不为空字符串。
+ * 如果指定的字符串数组的长度不为 0,或者所有元素都不是空字符串,则返回 true。
+ *
+ *
+ * 例:
+ *
+ * - {@code CharSequenceUtil.isAllNotEmpty() // false}
+ * - {@code CharSequenceUtil.isAllNotEmpty("", null) // false}
+ * - {@code CharSequenceUtil.isAllNotEmpty("123", "") // false}
+ * - {@code CharSequenceUtil.isAllNotEmpty("123", "abc") // true}
+ * - {@code CharSequenceUtil.isAllNotEmpty(" ", "\t", "\n") // true}
+ *
+ *
+ * @param args
+ * 字符串数组
+ *
+ * @return 所有字符串是否都不为为空白
+ */
+ public static boolean isAllNotEmpty(final @Nullable CharSequence... args) {
+ return !hasEmpty(args);
+ }
+
+
+ /**
+ * 字符串是否为空白
+ *
+ * @param ch
+ * 要判断的字符串, 可能为 null
+ *
+ * @return 是否为空白
+ */
+ public static boolean isBlank(final @Nullable CharSequence ch) {
+ final int strLen = ch == null ? 0 : ch.length();
+ if (strLen == 0) {
+ return true;
+ }
+ for (int i = 0; i < strLen; i++) {
+ if (!Character.isWhitespace(ch.charAt(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+
+ /**
+ * 字符串是否不为空白
+ *
+ * @param cs
+ * 字符串
+ *
+ * @return 是否不为空白
+ */
+ public static boolean isNotBlank(final @Nullable CharSequence cs) {
+ return !isBlank(cs);
+ }
+
+
+ /**
+ * 比较两个 CharSequence 是否相等, 区分大小写, 如果两个都为 null, 返回 true
+ *
+ * @param cs1
+ * CharSequence 1, 可能为 null
+ *
+ * @param cs2
+ * CharSequence 2, 可能为 null
+ *
+ * @return 是否相等
+ */
+ public static boolean equals(final @Nullable CharSequence cs1, final @Nullable CharSequence cs2) {
+ if (cs1 == cs2) {
+ return true;
+ }
+ if (cs1 == null || cs2 == null) {
+ return false;
+ }
+ if (cs1.length() != cs2.length()) {
+ return false;
+ }
+ if (cs1 instanceof String && cs2 instanceof String) {
+ return cs1.equals(cs2);
+ }
+ // 逐个比较
+ final int length = cs1.length();
+ for (int i = 0; i < length; i++) {
+ if (cs1.charAt(i) != cs2.charAt(i)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+
+ /**
+ * 比较两个 CharSequence 是否相等, 可以选择是否忽略大小写, 如果两个都为 null, 返回 true
+ *
+ * @param cs1
+ * 字符串 1
+ *
+ * @param cs2
+ * 字符串 2
+ *
+ * @param ignoreCase
+ * 是否忽略大小写
+ *
+ * @return 是否相等
+ */
+ public static boolean equals(final @Nullable CharSequence cs1,final @Nullable CharSequence cs2, boolean ignoreCase) {
+ return ignoreCase ? equalsIgnoreCase(cs1, cs2) : equals(cs1, cs2);
+ }
+
+
+ /**
+ * 字符串是否相等, 忽略大小写
+ *
+ * @param cs1
+ * 字符串 1
+ *
+ * @param cs2
+ * 字符串 2
+ *
+ * @return 忽略大小写后是否相等
+ */
+ public static boolean equalsIgnoreCase(final @Nullable CharSequence cs1, final @Nullable CharSequence cs2) {
+ if (cs1 == cs2) {
+ return true;
+ }
+ if (cs1 == null || cs2 == null) {
+ return false;
+ }
+ if (cs1.length() != cs2.length()) {
+ return false;
+ }
+
+ return cs1.toString().equalsIgnoreCase(cs2.toString());
+ }
+
+
+ /**
+ * 切分字符串,如果分隔符不存在则返回原字符串
+ *
+ * @param str
+ * 被切分的字符串
+ *
+ * @param separator
+ * 分隔符
+ *
+ * @return 字符串
+ */
+ public static List split(final CharSequence str, final CharSequence separator) {
+ return split(str, separator, false, false);
+ }
+
+
+ /**
+ * 切分字符串
+ *
+ * @param str
+ * 被切分的字符串
+ *
+ * @param separator
+ * 分隔符字符
+ *
+ * @param isTrim
+ * 是否去除切分字符串后每个元素两边的空格
+ *
+ * @param ignoreEmpty
+ * 是否忽略空串
+ *
+ * @return 切分后的集合
+ */
+ public static List split(CharSequence str, CharSequence separator, boolean isTrim, boolean ignoreEmpty) {
+ return split(str, separator, 0, isTrim, ignoreEmpty);
+ }
+
+
+ /**
+ * 切分字符串
+ *
+ * @param str
+ * 被切分的字符串
+ *
+ * @param separator
+ * 分隔符字符
+ *
+ * @param limit
+ * 限制分片数,-1 不限制
+ *
+ * @param isTrim
+ * 是否去除切分字符串后每个元素两边的空格
+ *
+ * @param ignoreEmpty
+ * 是否忽略空串
+ *
+ * @return 切分后的集合
+ */
+ public static List split(CharSequence str, CharSequence separator, int limit, boolean isTrim, boolean ignoreEmpty) {
+ final String separatorStr = (null == separator) ? null : separator.toString();
+ return StrSplitter.split(str, separatorStr, limit, isTrim, ignoreEmpty);
+ }
+
+
+ /**
+ * 指定字符串是否在字符串中出现过
+ *
+ * @param str
+ * 字符串
+ *
+ * @param searchStr
+ * 被查找的字符串
+ *
+ * @return 是否包含
+ */
+ public static boolean contains(final @Nullable CharSequence str, final @Nullable CharSequence searchStr) {
+ if (null == str || null == searchStr) {
+ return false;
+ }
+ return str.toString().contains(searchStr);
+ }
+
+
+ /**
+ * 查找指定字符串是否包含指定字符串列表中的任意一个字符串
+ *
+ * @param str
+ * 指定字符串
+ *
+ * @param testStrs
+ * 需要检查的字符串数组
+ *
+ * @return 是否包含任意一个字符串
+ */
+ public static boolean containsAny(final @Nullable CharSequence str, final @Nullable CharSequence... testStrs) {
+ if (isEmpty(str) || ArrayUtils.isEmpty(testStrs)) {
+ return false;
+ }
+ for (CharSequence checkStr : testStrs) {
+ if (null != checkStr && str.toString().contains(checkStr)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ /**
+ * 查找指定字符串是否包含指定字符串列表中的任意一个字符串
+ * 忽略大小写
+ *
+ * @param str
+ * 指定字符串
+ *
+ * @param testStrs
+ * 需要检查的字符串数组
+ *
+ * @return 是否包含任意一个字符串
+ */
+ public static boolean containsAnyIgnoreCase(final @Nullable CharSequence str, final @Nullable CharSequence... testStrs) {
+ return StringUtils.containsAnyIgnoreCase(str, testStrs);
+ }
+
+
+ /**
+ * 以 conjunction 为分隔符将多个对象转换为字符串
+ *
+ * @param conjunction
+ * 分隔符
+ *
+ * @param objs
+ * 数组
+ *
+ * @return 连接后的字符串
+ */
+ public static String join(CharSequence conjunction, Object... objs) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < objs.length; i++) {
+ Object item = objs[i];
+ sb.append(item);
+ if (i < objs.length - 1) {
+ sb.append(conjunction);
+ }
+ }
+ return sb.toString();
+ }
+
+
+ /**
+ * 以 conjunction 为分隔符将 Collection 对象转换为字符串
+ *
+ * @param conjunction
+ * 分隔符
+ *
+ * @param collection
+ * 集合
+ *
+ * @return 连接后的字符串
+ */
+ public static String join(CharSequence conjunction, Collection> collection) {
+ StringBuilder sb = new StringBuilder();
+ for (Object item : collection) {
+ sb.append(item).append(conjunction);
+ }
+ if (!sb.isEmpty()) {
+ sb.delete(sb.length() - conjunction.length(), sb.length());
+ }
+ return sb.toString();
+ }
+
+
+ /**
+ * 是否以指定字符串开头
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param prefix
+ * 开头字符串
+ *
+ * @return 是否以指定字符串开头
+ */
+ public static boolean startWith(CharSequence str, CharSequence prefix) {
+ return startWith(str, prefix, false);
+ }
+
+
+ /**
+ * 是否以指定字符串开头,忽略大小写
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param prefix
+ * 开头字符串
+ *
+ * @return 是否以指定字符串开头
+ */
+ public static boolean startWithIgnoreCase(CharSequence str, CharSequence prefix) {
+ return startWith(str, prefix, true);
+ }
+
+
+ /**
+ * 是否以指定字符串开头
+ * 如果给定的字符串和开头字符串都为null则返回true,否则任意一个值为null返回false
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param prefix
+ * 开头字符串
+ *
+ * @param ignoreCase
+ * 是否忽略大小写
+ *
+ * @return 是否以指定字符串开头
+ */
+ public static boolean startWith(CharSequence str, CharSequence prefix, boolean ignoreCase) {
+ return startWith(str, prefix, ignoreCase, false);
+ }
+
+
+ /**
+ * 是否以指定字符串开头
+ * 如果给定的字符串和开头字符串都为 null 则返回 true,否则任意一个值为 null 返回 false
+ *
+ * CharSequenceUtil.startWith("123", "123", false, true); -- false
+ * CharSequenceUtil.startWith("ABCDEF", "abc", true, true); -- true
+ * CharSequenceUtil.startWith("abc", "abc", true, true); -- false
+ *
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param prefix
+ * 开头字符串
+ *
+ * @param ignoreCase
+ * 是否忽略大小写
+ *
+ * @param ignoreEquals
+ * 是否忽略字符串相等的情况
+ *
+ * @return 是否以指定字符串开头
+ */
+ public static boolean startWith(final @Nullable CharSequence str, final @Nullable CharSequence prefix, boolean ignoreCase, boolean ignoreEquals) {
+ if (null == str || null == prefix) {
+ if (ignoreEquals) {
+ return false;
+ }
+ return null == str && null == prefix;
+ }
+
+ boolean isStartWith = str.toString()
+ .regionMatches(ignoreCase, 0, prefix.toString(), 0, prefix.length());
+
+ if (isStartWith) {
+ return (!ignoreEquals) || (!equals(str, prefix, ignoreCase));
+ }
+ return false;
+ }
+
+
+ /**
+ * 是否以指定字符串结尾
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param suffix
+ * 结尾字符串
+ *
+ * @return 是否以指定字符串结尾
+ */
+ public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix) {
+ return endWith(str, suffix, false);
+ }
+
+
+ /**
+ * 是否以指定字符串结尾
+ * 如果给定的字符串和开头字符串都为null则返回true,否则任意一个值为null返回false
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param suffix
+ * 结尾字符串
+ *
+ * @param ignoreCase
+ * 是否忽略大小写
+ *
+ * @return 是否以指定字符串结尾
+ */
+ public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix, boolean ignoreCase) {
+ return endWith(str, suffix, ignoreCase, false);
+ }
+
+
+ /**
+ * 是否以指定字符串结尾
+ * 如果给定的字符串和开头字符串都为null则返回true,否则任意一个值为null返回false
+ *
+ * @param str
+ * 被监测字符串
+ *
+ * @param suffix
+ * 结尾字符串
+ *
+ * @param ignoreCase
+ * 是否忽略大小写
+ *
+ * @param ignoreEquals
+ * 是否忽略字符串相等的情况
+ *
+ * @return 是否以指定字符串结尾
+ */
+ public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix, boolean ignoreCase, boolean ignoreEquals) {
+ if (null == str || null == suffix) {
+ if (ignoreEquals) {
+ return false;
+ }
+ return null == str && null == suffix;
+ }
+
+ final int strOffset = str.length() - suffix.length();
+ boolean isEndWith = str.toString()
+ .regionMatches(ignoreCase, strOffset, suffix.toString(), 0, suffix.length());
+
+ if (isEndWith) {
+ return (!ignoreEquals) || (!equals(str, suffix, ignoreCase));
+ }
+ return false;
+ }
+
+
+ /**
+ * 去掉指定前缀
+ *
+ * @param str
+ * 字符串
+ *
+ * @param prefix
+ * 前缀
+ *
+ * @return 切掉后的字符串,若前缀不是 preffix, 返回原字符串
+ */
+ public static String removePrefix(final @Nullable CharSequence str, final @Nullable CharSequence prefix) {
+ if (isEmpty(str) || isEmpty(prefix)) {
+ return str(str);
+ }
+ String str2 = str.toString();
+ String prefix2 = prefix.toString();
+ if (str2.startsWith(prefix2)) {
+ return str.subSequence(prefix.length(), str.length()).toString();
+ }
+ return str2; // 若前缀不是 prefix,返回原字符串
+ }
+
+
+ /**
+ * 返回第一个非 {@code null} 元素
+ *
+ * @param strs
+ * 多个元素
+ *
+ * @param
+ * 元素类型
+ *
+ * @return 第一个非空元素,如果给定的数组为空或者都为空,返回{@code null}
+ */
+ @SuppressWarnings("unchecked")
+ public static T firstNonNull(T... strs) {
+ if (ArrayUtils.isNotEmpty(strs)) {
+ for (T str : strs) {
+ if (isNotEmpty(str)) {
+ return str;
+ }
+ }
+ }
+ return null;
+ }
+
+
+ /**
+ * 截取分隔字符串之前的字符串,不包括分隔字符串
+ * 如果给定的字符串为空串(null或"")或者分隔字符串为null,返回原字符串
+ * 如果分隔字符串为空串"",则返回空串,如果分隔字符串未找到,返回原字符串,举例如下:
+ *
+ *
+ * CharSequenceUtil.subBefore(null, *, false) = null
+ * CharSequenceUtil.subBefore("", *, false) = ""
+ * CharSequenceUtil.subBefore("abc", "a", false) = ""
+ * CharSequenceUtil.subBefore("abcba", "b", false) = "a"
+ * CharSequenceUtil.subBefore("abc", "c", false) = "ab"
+ * CharSequenceUtil.subBefore("abc", "d", false) = "abc"
+ * CharSequenceUtil.subBefore("abc", "", false) = ""
+ * CharSequenceUtil.subBefore("abc", null, false) = "abc"
+ *
+ *
+ * @param string
+ * 被查找的字符串
+ *
+ * @param separator
+ * 分隔字符串(不包括)
+ *
+ * @param isLastSeparator
+ * 是否查找最后一个分隔字符串(多次出现分隔字符串时选取最后一个),true为选取最后一个
+ *
+ * @return 切割后的字符串
+ */
+ public static String subBefore(final @Nullable CharSequence string, final @Nullable CharSequence separator, boolean isLastSeparator) {
+ if (isEmpty(string) || separator == null) {
+ return null == string ? null : string.toString();
+ }
+
+ final String str = string.toString();
+ final String sep = separator.toString();
+ if (sep.isEmpty()) {
+ return EMPTY;
+ }
+ final int pos = isLastSeparator ? str.lastIndexOf(sep) : str.indexOf(sep);
+ if (INDEX_NOT_FOUND == pos) {
+ return str;
+ }
+ if (0 == pos) {
+ return EMPTY;
+ }
+ return str.substring(0, pos);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java b/src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java
new file mode 100644
index 0000000..057d55c
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java
@@ -0,0 +1,41 @@
+package im.zhaojun.zfile.core.util;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+
+/**
+ * Class & 反射相关工具类
+ *
+ * @author zhaojun
+ */
+public class ClassUtils {
+
+ public static Class> forName(String className) {
+ try {
+ return Class.forName(className);
+ } catch (ClassNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * 获取指定类的泛型类型, 只获取第一个泛型类型
+ *
+ * @param clazz
+ * 泛型类
+ *
+ * @return 泛型类型
+ */
+ public static Class> getClassFirstGenericsParam(Class> clazz) {
+ Type genericSuperclass = clazz.getGenericSuperclass();
+ Type actualTypeArgument = ((ParameterizedType) genericSuperclass).getActualTypeArguments()[0];
+ return (Class>) actualTypeArgument;
+ }
+
+ public static Class> getGenericType(Field field) {
+ ParameterizedType listType = (ParameterizedType) field.getGenericType();
+ return (Class>) listType.getActualTypeArguments()[0];
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java b/src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java
new file mode 100644
index 0000000..4749477
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java
@@ -0,0 +1,131 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.lang.func.Func1;
+
+import javax.annotation.Nullable;
+import java.util.*;
+
+public class CollectionUtils {
+
+
+ /**
+ * 判断集合是否为空
+ *
+ * @param collection
+ * 集合
+ *
+ * @return 是否为空
+ */
+ public static boolean isEmpty(@Nullable Collection> collection) {
+ return (collection == null || collection.isEmpty());
+ }
+
+
+ /**
+ * 判断集合是否不为空
+ *
+ * @param collection
+ * 集合
+ *
+ * @return 是否不为空
+ */
+ public static boolean isNotEmpty(@Nullable Collection> collection) {
+ return !isEmpty(collection);
+ }
+
+
+ /**
+ * 从集合中获取第一个元素, 如果集合为空则返回 {@code null}
+ *
+ * @param list
+ * 集合,可能为 {@code null}
+ *
+ * @return 第一个元素,如果集合为空则返回 {@code null}
+ */
+ @Nullable
+ public static T getFirst(@Nullable List list) {
+ if (isEmpty(list)) {
+ return null;
+ }
+ return list.get(0);
+ }
+
+
+ /**
+ * 从集合中获取最后一个元素, 如果集合为空则返回 {@code null}
+ *
+ * @param list
+ * 集合,可能为 {@code null}
+ *
+ * @return 最后一个元素,如果集合为空则返回 {@code null}
+ */
+ @Nullable
+ public static T getLast(@Nullable List list) {
+ if (isEmpty(list)) {
+ return null;
+ }
+ return list.get(list.size() - 1);
+ }
+
+
+ /**
+ * 加入全部
+ *
+ * @param
+ * 集合元素类型
+ *
+ * @param collection
+ * 被加入的集合 {@link Collection}
+ *
+ * @param values
+ * 要加入的内容数组
+ *
+ * @return 原集合
+ */
+ public static Collection addAll(Collection collection, T[] values) {
+ if (null != collection && null != values) {
+ Collections.addAll(collection, values);
+ }
+ return collection;
+ }
+
+
+ /**
+ * Iterable 转换为 Map, 根据指定的 keyFunc 函数生成 Key. Value 为 Iterable 中的元素.
+ * 可以指定将结果放入的 Map, 如不指定则会新建一个 HashMap 返回.
+ *
+ * @param
+ * Map Key 类型
+ *
+ * @param
+ * Map Value 类型
+ *
+ * @param values
+ * 被转换的 Iterable
+ *
+ * @param map
+ * 转换后的 Value 存放的 Map, 如果为 {@code null} 则新建一个 HashMap
+ *
+ * @param keyFunc
+ * 生成 Map 的 Key 的函数
+ *
+ * @return 转换后的 Map
+ */
+ public static Map toMap(final @Nullable Iterable values, final @Nullable Map map, final @Nullable Func1 keyFunc) {
+ if (values == null || keyFunc == null) {
+ return Collections.emptyMap();
+ }
+
+ final Map result = map == null ? new HashMap<>() : map;
+
+ for (V value : values) {
+ try {
+ result.put(keyFunc.call(value), value);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return result;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java b/src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java
new file mode 100644
index 0000000..4c636ff
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java
@@ -0,0 +1,52 @@
+package im.zhaojun.zfile.core.util;
+
+import com.alibaba.dcm.DnsCacheManipulator;
+import com.alibaba.fastjson2.JSONArray;
+import org.springframework.lang.Nullable;
+
+public class DnsUtil {
+
+ /**
+ * 通过 HTTP DNS 获取域名对应的 IP 地址
+ *
+ * @param domain
+ * 域名
+ *
+ * @return IP 地址数组
+ */
+ public static @Nullable String[] getDomainIpByHttpDns(String domain) {
+ String jsonArrayStr = cn.hutool.http.HttpUtil.get("http://223.5.5.5/resolve?name=" + domain + "&short=1", 3000);
+ JSONArray jsonArray = JSONArray.parseArray(jsonArrayStr);
+ if (!jsonArray.isEmpty()) {
+ String[] result = new String[jsonArray.size()];
+ for (int i = 0; i < jsonArray.size(); i++) {
+ result[i] = jsonArray.getString(i);
+ }
+ return result;
+ } else {
+ return null;
+ }
+ }
+
+
+ /**
+ * 通过 HTTP DNS 获取域名对应的 IP 地址, 并设置 DNS 缓存.
+ *
+ * @param domain
+ * 域名
+ *
+ * @param cacheTime
+ * 缓存时间, 单位: 毫秒
+ *
+ * @return IP 地址数组
+ */
+ public static String[] getDomainIpByHttpDnsAndCache(String domain, int cacheTime) {
+ String[] domainIpByHttpDns = getDomainIpByHttpDns(domain);
+ if (domainIpByHttpDns != null) {
+ // 设置 DNS 缓存
+ DnsCacheManipulator.setDnsCache(cacheTime, domain, domainIpByHttpDns);
+ }
+ return domainIpByHttpDns;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java b/src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java
new file mode 100644
index 0000000..0904836
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java
@@ -0,0 +1,82 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.util.ClassUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.ReflectUtil;
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import java.lang.reflect.Field;
+
+/**
+ * 枚举转换工具类
+ *
+ * @author zhaojun
+ */
+public class EnumConvertUtils {
+
+
+ /**
+ * 根据枚举 class 和值获取对应的枚举对象
+ *
+ * @param clazz
+ * 枚举类 Class
+ *
+ * @param value
+ * 枚举值
+ *
+ * @return 枚举对象
+ */
+ public static Enum> convertStrToEnum(Class> clazz, Object value) {
+ if (!ClassUtil.isEnum(clazz)) {
+ return null;
+ }
+
+ Field[] fields = ReflectUtil.getFields(clazz);
+ for (Field field : fields) {
+ boolean jsonValuePresent = field.isAnnotationPresent(JsonValue.class);
+ boolean enumValuePresent = field.isAnnotationPresent(EnumValue.class);
+
+ if (jsonValuePresent || enumValuePresent) {
+ Object[] enumConstants = clazz.getEnumConstants();
+
+ for (Object enumObj : enumConstants) {
+ if (ObjectUtil.equal(value, ReflectUtil.getFieldValue(enumObj, field))) {
+ return (Enum>) enumObj;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+
+ /**
+ * 转换枚举对象为字符串, 如果枚举对象没有定义 JsonValue 注解, 则使用 EnumValue 注解的值
+ *
+ * @param enumObj
+ * 枚举对象
+ *
+ * @return 字符串
+ */
+ public static String convertEnumToStr(Object enumObj) {
+ Class> clazz = enumObj.getClass();
+ if (!ClassUtil.isEnum(clazz)) {
+ return null;
+ }
+
+ Field[] fields = ReflectUtil.getFields(clazz);
+ for (Field field : fields) {
+ boolean jsonValuePresent = field.isAnnotationPresent(JsonValue.class);
+ boolean enumValuePresent = field.isAnnotationPresent(EnumValue.class);
+
+ if (jsonValuePresent || enumValuePresent) {
+ return Convert.toStr(ReflectUtil.getFieldValue(enumObj, field));
+ }
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileComparator.java b/src/main/java/im/zhaojun/zfile/core/util/FileComparator.java
new file mode 100644
index 0000000..2acda55
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/FileComparator.java
@@ -0,0 +1,70 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.comparator.CompareUtil;
+import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
+import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;
+
+import java.util.Comparator;
+
+/**
+ * 文件比较器
+ *
+ * - 文件夹始终比文件排序高
+ * - 默认按照名称排序
+ * - 默认排序为升序
+ * - 按名称排序不区分大小写
+ *
+ * @author zhaojun
+ */
+public class FileComparator implements Comparator {
+
+ private String sortBy;
+
+ private String order;
+
+ public FileComparator(String sortBy, String order) {
+ this.sortBy = sortBy;
+ this.order = order;
+ }
+
+
+ /**
+ * 比较两个文件的大小
+ *
+ * @param o1
+ * 第一个文件
+ *
+ * @param o2
+ * 第二个文件
+ *
+ * @return 比较结果
+ */
+ @Override
+ public int compare(FileItemResult o1, FileItemResult o2) {
+ if (sortBy == null) {
+ sortBy = "name";
+ }
+
+ if (order == null) {
+ order = "asc";
+ }
+ FileTypeEnum o1Type = o1.getType();
+ FileTypeEnum o2Type = o2.getType();
+ NaturalOrderComparator naturalOrderComparator = new NaturalOrderComparator();
+ if (o1Type.equals(o2Type)) {
+ int result = switch (sortBy) {
+ case "time" -> CompareUtil.compare(o1.getTime(), o2.getTime());
+ case "size" -> CompareUtil.compare(o1.getSize(), o2.getSize());
+ default -> naturalOrderComparator.compare(o1.getName(), o2.getName());
+ };
+ return "asc".equals(order) ? result : -result;
+ }
+
+ if (o1Type.equals(FileTypeEnum.FOLDER)) {
+ return -1;
+ } else {
+ return 1;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java b/src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java
new file mode 100644
index 0000000..8764f2f
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java
@@ -0,0 +1,64 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.io.FileUtil;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.status.NotFoundAccessException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 将文件输出对象
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class FileResponseUtil {
+
+
+ /**
+ * 文件下载,单线程,不支持断点续传
+ *
+ * @param file
+ * 文件对象
+ *
+ * @param fileName
+ * 要保存为的文件名
+ *
+ * @return 文件下载对象
+ */
+ public static ResponseEntity exportSingleThread(File file, String fileName) {
+ if (!file.exists()) {
+ throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST);
+ }
+
+ MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
+
+ HttpHeaders headers = new HttpHeaders();
+
+ if (StringUtils.isEmpty(fileName)) {
+ fileName = file.getName();
+ }
+
+ ContentDisposition contentDisposition = ContentDisposition
+ .builder("inline")
+ .filename(fileName, StandardCharsets.UTF_8)
+ .build();
+ headers.setContentDisposition(contentDisposition);
+
+ return ResponseEntity
+ .ok()
+ .headers(headers)
+ .contentLength(file.length())
+ .contentType(mediaType)
+ .body(new InputStreamResource(FileUtil.getInputStream(file)));
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java b/src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java
new file mode 100644
index 0000000..22b1699
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java
@@ -0,0 +1,65 @@
+package im.zhaojun.zfile.core.util;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class FileSizeConverter {
+
+ private static final long KB_FACTOR = 1024L;
+ private static final long MB_FACTOR = 1024L * KB_FACTOR;
+ private static final long GB_FACTOR = 1024L * MB_FACTOR;
+ private static final long TB_FACTOR = 1024L * GB_FACTOR;
+ private static final long PB_FACTOR = 1024L * TB_FACTOR;
+
+ private static final Pattern FILE_SIZE_PATTERN = Pattern.compile("([\\d.]+)\\s*([a-zA-Z]+)");
+
+ public static long convertFileSizeToBytes(String sizeStr) {
+ if (sizeStr == null || sizeStr.trim().isEmpty()) {
+ throw new IllegalArgumentException("输入字符串不能为空");
+ }
+
+ Matcher matcher = FILE_SIZE_PATTERN.matcher(sizeStr.trim());
+
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException("无效的文件大小格式: " + sizeStr);
+ }
+
+ String valueStr = matcher.group(1);
+ String unitStr = matcher.group(2).toUpperCase();
+
+ double value;
+ try {
+ value = Double.parseDouble(valueStr);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("无效的数字格式: " + valueStr, e);
+ }
+
+ if (value < 0) {
+ throw new IllegalArgumentException("文件大小不能为负数: " + valueStr);
+ }
+
+ long multiplier = switch (unitStr) {
+ case "B" ->
+ 1L;
+ case "KB", "KIB" ->
+ KB_FACTOR;
+ case "MB", "MIB" ->
+ MB_FACTOR;
+ case "GB", "GIB" ->
+ GB_FACTOR;
+ case "TB", "TIB" ->
+ TB_FACTOR;
+ case "PB", "PIB" ->
+ PB_FACTOR;
+ default -> throw new IllegalArgumentException("不支持的单位: " + unitStr + " (支持 B, KB, MB, GB, TB, PB)");
+ };
+
+ double bytesDouble = value * multiplier;
+ if (bytesDouble > Long.MAX_VALUE) {
+ throw new ArithmeticException("转换后的字节数超过了 Long 类型的最大值: " + bytesDouble);
+ }
+
+ return Math.round(bytesDouble);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileUtils.java b/src/main/java/im/zhaojun/zfile/core/util/FileUtils.java
new file mode 100644
index 0000000..b3b4571
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/FileUtils.java
@@ -0,0 +1,46 @@
+package im.zhaojun.zfile.core.util;
+
+import org.apache.commons.io.FilenameUtils;
+
+/**
+ * 文件相关工具类
+ *
+ * @author zhaojun
+ */
+public class FileUtils {
+
+ public static String getName(final String fileName) {
+ if (fileName == null) {
+ return null;
+ }
+
+ int i = fileName.lastIndexOf(CharSequenceUtil.SLASH_CHAR);
+ if (i >= 0 && i <= fileName.length() - 1) {
+ return fileName.substring(i + 1);
+ }
+
+ return fileName;
+ }
+
+ public static String getParentPath(final String fileName) {
+ String fullPathNoEndSeparator = FilenameUtils.getFullPathNoEndSeparator(StringUtils.trimEndSlashes(fileName));
+ if (fullPathNoEndSeparator == null || fullPathNoEndSeparator.isEmpty()) {
+ return StringUtils.SLASH;
+ }
+ return fullPathNoEndSeparator;
+ }
+
+ public static String getExtension(final String fileName) throws IllegalArgumentException {
+ if (fileName == null) {
+ return null;
+ }
+
+ int i = fileName.lastIndexOf('.');
+ if (i > 0 && i < fileName.length() - 1) {
+ return fileName.substring(i + 1);
+ }
+
+ return "";
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java b/src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java
new file mode 100644
index 0000000..496d35a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java
@@ -0,0 +1,69 @@
+package im.zhaojun.zfile.core.util;
+
+import im.zhaojun.zfile.core.constant.ZFileConstant;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.biz.GetPreviewTextContentBizException;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLConnection;
+
+/**
+ * 网络相关工具
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class HttpUtil {
+
+ /**
+ * 获取 URL 对应的文件内容
+ *
+ * @param url
+ * 文件 URL
+ *
+ * @return 文件内容
+ */
+ public static String getTextContent(String url) {
+ long maxFileSize = 1024 * ZFileConstant.TEXT_MAX_FILE_SIZE_KB;
+
+ if (getRemoteFileSize(url) > maxFileSize) {
+ throw new BizException(ErrorCode.BIZ_PREVIEW_FILE_SIZE_EXCEED);
+ }
+
+ String result;
+ try {
+ result = cn.hutool.http.HttpUtil.get(url);
+ } catch (Exception e) {
+ throw new GetPreviewTextContentBizException(url, e);
+ }
+
+ return result == null ? "" : result;
+ }
+
+
+ /**
+ * 获取远程文件大小
+ *
+ * @param url
+ * 文件 URL
+ *
+ * @return 文件大小
+ */
+ public static Long getRemoteFileSize(String url) {
+ long size = 0;
+ URL urlObject;
+ try {
+ urlObject = new URL(url);
+ URLConnection conn = urlObject.openConnection();
+ size = conn.getContentLength();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ return size;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java b/src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java
new file mode 100644
index 0000000..3a9b336
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java
@@ -0,0 +1,152 @@
+package im.zhaojun.zfile.core.util;
+/*
+ NaturalOrderComparator.java -- Perform 'natural order' comparisons of strings in Java.
+ Copyright (C) 2003 by Pierre-Luc Paour
+
+ Based on the C version by Martin Pool, of which this is more or less a straight conversion.
+ Copyright (C) 2000 by Martin Pool
+
+ This software is provided 'as-is', without any express or implied
+ warranty. In no event will the authors be held liable for any damages
+ arising from the use of this software.
+
+ Permission is granted to anyone to use this software for any purpose,
+ including commercial applications, and to alter it and redistribute it
+ freely, subject to the following restrictions:
+
+ 1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+ 2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+ 3. This notice may not be removed or altered from any source distribution.
+ */
+
+import java.util.Comparator;
+
+/**
+ * 类 windows 文件排序算法
+ *
+ * @author zhaojun
+ */
+public class NaturalOrderComparator implements Comparator {
+
+ private static final char ZERO_CHAR = '0';
+
+ private int compareRight(String a, String b) {
+ int bias = 0, ia = 0, ib = 0;
+
+ // The longest run of digits wins. That aside, the greatest
+ // value wins, but we can't know that it will until we've scanned
+ // both numbers to know that they have the same magnitude, so we
+ // remember it in BIAS.
+ for (; ; ia++, ib++) {
+ char ca = charAt(a, ia);
+ char cb = charAt(b, ib);
+
+ if (!isDigit(ca) && !isDigit(cb)) {
+ return bias;
+ }
+ if (!isDigit(ca)) {
+ return -1;
+ }
+ if (!isDigit(cb)) {
+ return +1;
+ }
+ if (ca == 0 && cb == 0) {
+ return bias;
+ }
+
+ if (bias == 0) {
+ if (ca < cb) {
+ bias = -1;
+ } else if (ca > cb) {
+ bias = +1;
+ }
+ }
+ }
+ }
+
+ @Override
+ public int compare(String a, String b) {
+ int ia = 0, ib = 0;
+ int nza, nzb;
+ char ca, cb;
+
+ while (true) {
+ // Only count the number of zeroes leading the last number compared
+ nza = nzb = 0;
+
+ ca = charAt(a, ia);
+ cb = charAt(b, ib);
+
+ // skip over leading spaces or zeros
+ while (Character.isSpaceChar(ca) || ca == ZERO_CHAR) {
+ if (ca == ZERO_CHAR) {
+ nza++;
+ } else {
+ // Only count consecutive zeroes
+ nza = 0;
+ }
+
+ ca = charAt(a, ++ia);
+ }
+
+ while (Character.isSpaceChar(cb) || cb == '0') {
+ if (cb == '0') {
+ nzb++;
+ } else {
+ // Only count consecutive zeroes
+ nzb = 0;
+ }
+
+ cb = charAt(b, ++ib);
+ }
+
+ // Process run of digits
+ if (Character.isDigit(ca) && Character.isDigit(cb)) {
+ int bias = compareRight(a.substring(ia), b.substring(ib));
+ if (bias != 0) {
+ return bias;
+ }
+ }
+
+ if (ca == 0 && cb == 0) {
+ // The strings compare the same. Perhaps the caller
+ // will want to call strcmp to break the tie.
+ return compareEqual(a, b, nza, nzb);
+ }
+ if (ca < cb) {
+ return -1;
+ }
+ if (ca > cb) {
+ return +1;
+ }
+
+ ++ia;
+ ++ib;
+ }
+ }
+
+ private static boolean isDigit(char c) {
+ return Character.isDigit(c) || c == '.' || c == ',';
+ }
+
+ private static char charAt(String s, int i) {
+ return i >= s.length() ? 0 : s.charAt(i);
+ }
+
+ private static int compareEqual(String a, String b, int nza, int nzb) {
+ if (nza - nzb != 0) {
+ return nza - nzb;
+ }
+
+ if (a.length() == b.length()) {
+ return a.compareTo(b);
+ }
+
+ return a.length() - b.length();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java b/src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java
new file mode 100644
index 0000000..fd7be9a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java
@@ -0,0 +1,18 @@
+package im.zhaojun.zfile.core.util;
+
+/**
+ * 数字工具类
+ *
+ * @author zhaojun
+ */
+public class NumberUtils {
+
+ public static boolean isNullOrZero(Integer number) {
+ return number == null || number == 0;
+ }
+
+ public static boolean isNotNullOrZero(Integer number) {
+ return number != null && number != 0;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java b/src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java
new file mode 100644
index 0000000..56036b8
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java
@@ -0,0 +1,141 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.cache.Cache;
+import cn.hutool.cache.CacheUtil;
+import cn.hutool.cache.impl.CacheObj;
+import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.RandomStringUtils;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * OnlyOffice 文件信息与 Key 缓存工具类
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class OnlyOfficeKeyCacheUtils {
+
+ /**
+ * 存储 OnlyOffice 文件信息与 Key 的映射关系. 最多存储 10000 个 Key, 防止内存溢出.
+ */
+ private static final Cache ONLY_OFFICE_FILE_KEY_MAP = CacheUtil.newLRUCache(10000);
+
+ /**
+ * 存储 OnlyOffice Key 与文件信息的映射关系. 最多存储 10000 个 Key, 防止内存溢出.
+ */
+ private static final Cache ONLY_OFFICE_KEY_FILE_MAP = CacheUtil.newLRUCache(10000);
+
+ /**
+ * 存储文件锁, 防止并发操作文件缓存时出现问题.
+ */
+ private static final Cache locks = CacheUtil.newLRUCache(300);
+
+ /**
+ * 获取该文件缓存的 key, 如果不存在则生成一个新的 key 并缓存.
+ *
+ * @param onlyOfficeFile
+ * OnlyOffice 文件信息
+ *
+ * @return 该文件唯一标识
+ */
+ public static String getKeyOrPutNew(OnlyOfficeFile onlyOfficeFile, long timeout) {
+ ReentrantLock lock = getLock(onlyOfficeFile);
+ try {
+ boolean getLock = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
+ if (BooleanUtils.isFalse(getLock)) {
+ log.warn("{} 尝试获取锁超时, 强制忽略锁直接操作文件.", onlyOfficeFile);
+ }
+ try {
+ if (ONLY_OFFICE_FILE_KEY_MAP.containsKey(onlyOfficeFile)) {
+ return ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile);
+ } else {
+ String key = RandomStringUtils.randomAlphabetic(10);
+ ONLY_OFFICE_FILE_KEY_MAP.put(onlyOfficeFile, key);
+ ONLY_OFFICE_KEY_FILE_MAP.put(key, onlyOfficeFile);
+ return key;
+ }
+ } finally {
+ lock.unlock();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Thread was interrupted", e);
+ }
+
+ }
+
+ /**
+ * 清理缓存中的 Key 与文件信息的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)
+ *
+ * @param key
+ * 文件唯一标识
+ */
+ public static OnlyOfficeFile removeByKey(String key) {
+ OnlyOfficeFile onlyOfficeFile = ONLY_OFFICE_KEY_FILE_MAP.get(key);
+ if (onlyOfficeFile == null) {
+ return null;
+ }
+ ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile);
+ ONLY_OFFICE_KEY_FILE_MAP.remove(key);
+ return onlyOfficeFile;
+ }
+
+ /**
+ * 清理缓存中的文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)
+ *
+ * @param onlyOfficeFile
+ * OnlyOffice 文件信息
+ */
+ public static OnlyOfficeFile removeByFile(OnlyOfficeFile onlyOfficeFile) {
+ String key = ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile);
+ if (key == null) {
+ return null;
+ }
+ ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile);
+ ONLY_OFFICE_KEY_FILE_MAP.remove(key);
+ return onlyOfficeFile;
+ }
+
+
+ /**
+ * 清理缓存中的某个文件夹下所有文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)
+ *
+ * @param onlyOfficeFile
+ * OnlyOffice 文件信息
+ */
+ public static List removeByFolder(OnlyOfficeFile onlyOfficeFile) {
+ List caches = new ArrayList<>();
+ Iterator> cacheObjIterator = ONLY_OFFICE_FILE_KEY_MAP.cacheObjIterator();
+ while (cacheObjIterator.hasNext()) {
+ CacheObj cacheObj = cacheObjIterator.next();
+ OnlyOfficeFile cacheOnlyOfficeFile = cacheObj.getKey();
+ if (cacheOnlyOfficeFile.getStorageKey().equals(onlyOfficeFile.getStorageKey())
+ && StringUtils.startWith(cacheOnlyOfficeFile.getPathAndName(), onlyOfficeFile.getPathAndName())) {
+ ONLY_OFFICE_FILE_KEY_MAP.remove(cacheObj.getKey());
+ ONLY_OFFICE_KEY_FILE_MAP.remove(cacheObj.getValue());
+ caches.add(cacheOnlyOfficeFile);
+ }
+ }
+ return caches;
+ }
+
+ /**
+ * 获取文件锁, 防止并发操作文件缓存时出现问题.
+ *
+ * @param key
+ * 文件唯一标识
+ *
+ * @return 锁对象
+ */
+ public static ReentrantLock getLock(OnlyOfficeFile key) {
+ return locks.get(key, true, ReentrantLock::new);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java b/src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java
new file mode 100644
index 0000000..3d931e5
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java
@@ -0,0 +1,69 @@
+package im.zhaojun.zfile.core.util;
+
+import java.nio.file.FileSystems;
+import java.nio.file.PathMatcher;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 规则表达式工具类
+ *
+ * @author zhaojun
+ */
+public class PatternMatcherUtils {
+
+ private static final Map PATH_MATCHER_MAP = new HashMap<>();
+
+ /**
+ * 兼容模式的 glob 表达式匹配.
+ * 默认的 glob 表达式是不支持以下情况的:
+ *
+ * - pattern: /a/**
+ * - test1: /a
+ * - test2: /a/
+ *
+ * test1 和 test 2 均无法匹配这种情况, 此方法兼容了这种情况, 即对 test 内容后拼接 "/xx"(其实任意字符都可以), 使其可以匹配上 pattern.
+ *
注意:但此方法对包含文件名的情况无效, 仅支持 test 为 路径的情况.
+ *
+ * @param pattern
+ * glob 规则表达式
+ *
+ * @param test
+ * 匹配内容
+ *
+ * @return 是否匹配.
+ */
+ public static boolean testCompatibilityGlobPattern(String pattern, String test) {
+ // 如果规则表达式最开始没有 /, 则兼容在最前方加上 /.
+ if (!StringUtils.startWith(pattern, StringUtils.SLASH)) {
+ pattern = StringUtils.SLASH + pattern;
+ }
+
+ // 兼容性处理.
+ test = StringUtils.concat(test, StringUtils.SLASH);
+ if (StringUtils.endWith(pattern, "/**") || StringUtils.endWith(pattern, "/*")) {
+ test += "xxx";
+ }
+ return testGlobPattern(pattern, test);
+ }
+
+
+ /**
+ * 测试密码规则表达式和文件路径是否匹配
+ *
+ * @param pattern
+ * glob 规则表达式
+ *
+ * @param test
+ * 测试字符串
+ */
+ private static boolean testGlobPattern(String pattern, String test) {
+ // 从缓存取出 PathMatcher, 防止重复初始化
+ PathMatcher pathMatcher = PATH_MATCHER_MAP.getOrDefault(pattern, FileSystems.getDefault().getPathMatcher("glob:" + pattern));
+ PATH_MATCHER_MAP.put(pattern, pathMatcher);
+
+ return pathMatcher.matches(Paths.get(test)) || StringUtils.equals(pattern, test);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java b/src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java
new file mode 100644
index 0000000..df1759d
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java
@@ -0,0 +1,141 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.extra.spring.SpringUtil;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.*;
+
+/**
+ * 配置文件或模板中的占位符替换工具类
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class PlaceholderUtils {
+
+ /**
+ * Prefix for system property placeholders: "${"
+ */
+ public static final String PLACEHOLDER_PREFIX = "${";
+
+ /**
+ * Suffix for system property placeholders: "}"
+ */
+ public static final String PLACEHOLDER_SUFFIX = "}";
+
+
+ /**
+ * 解析占位符, 将指定的占位符替换为指定的值. 变量值从 Spring 环境中获取, 如没取到, 则默认为空.
+ *
+ * 必须在 Spring 环境下使用, 否则会抛出异常.
+ *
+ *
+ * @param formatStr
+ * 模板字符串
+ *
+ * @return 替换后的字符串
+ */
+ public static String resolvePlaceholdersBySpringProperties(String formatStr) {
+ String placeholderName = getFirstPlaceholderName(formatStr);
+ if (StringUtils.isEmpty(placeholderName)) {
+ return formatStr;
+ }
+
+ String propertyValue = SpringUtil.getProperty(placeholderName);
+ Map map = new HashMap<>();
+ map.put(placeholderName, propertyValue);
+ return resolvePlaceholders(formatStr, map);
+ }
+
+
+ /**
+ * 解析占位符, 将指定的占位符替换为指定的值.
+ *
+ * @param formatStr
+ * 模板字符串
+ *
+ * @param parameter
+ * 参数列表
+ *
+ * @return 替换后的字符串
+ */
+ public static String resolvePlaceholders(String formatStr, Map parameter) {
+ if (parameter == null || parameter.isEmpty()) {
+ return formatStr;
+ }
+ StringBuilder sb = new StringBuilder(formatStr);
+ int startIndex = sb.indexOf(PLACEHOLDER_PREFIX);
+ while (startIndex != -1) {
+ int endIndex = sb.indexOf(PLACEHOLDER_SUFFIX, startIndex + PLACEHOLDER_PREFIX.length());
+ if (endIndex != -1) {
+ String placeholder = sb.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
+ int nextIndex = endIndex + PLACEHOLDER_SUFFIX.length();
+ try {
+ String propVal = parameter.get(placeholder);
+ if (propVal != null) {
+ sb.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal);
+ nextIndex = startIndex + propVal.length();
+ } else {
+ log.warn("Could not resolve placeholder '{}' in [{}] ", placeholder, formatStr);
+ }
+ } catch (Exception ex) {
+ log.error("Could not resolve placeholder '{}' in [{}]: ", placeholder, formatStr, ex);
+ }
+ startIndex = sb.indexOf(PLACEHOLDER_PREFIX, nextIndex);
+ } else {
+ startIndex = -1;
+ }
+ }
+ return sb.toString();
+ }
+
+
+ /**
+ * 获取模板字符串第一个占位符的名称, 如 "我的名字是: ${name}, 我的年龄是: ${age}", 返回 "name".
+ *
+ * @param formatStr
+ * 模板字符串
+ *
+ * @return 占位符名称
+ */
+ public static String getFirstPlaceholderName(String formatStr) {
+ List list = getPlaceholderNames(formatStr);
+ if (CollectionUtils.isNotEmpty(list)) {
+ return list.getFirst();
+ }
+ return null;
+ }
+
+
+ /**
+ * 获取模板字符串第一个占位符的名称, 如 "我的名字是: ${name}, 我的年龄是: ${age}", 返回 ["name", "age].
+ *
+ * @param formatStr
+ * 模板字符串
+ *
+ * @return 占位符名称
+ */
+ public static List getPlaceholderNames(String formatStr) {
+ if (StringUtils.isEmpty(formatStr)) {
+ return Collections.emptyList();
+ }
+
+ List placeholderNameList = new ArrayList<>();
+
+ StringBuilder sb = new StringBuilder(formatStr);
+ int startIndex = sb.indexOf(PLACEHOLDER_PREFIX);
+ while (startIndex != -1) {
+ int endIndex = sb.indexOf(PLACEHOLDER_SUFFIX, startIndex + PLACEHOLDER_PREFIX.length());
+ if (endIndex != -1) {
+ String placeholder = sb.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
+ int nextIndex = endIndex + PLACEHOLDER_SUFFIX.length();
+ startIndex = sb.indexOf(PLACEHOLDER_PREFIX, nextIndex);
+ placeholderNameList.add(placeholder);
+ } else {
+ startIndex = -1;
+ }
+ }
+ return placeholderNameList;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java b/src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java
new file mode 100644
index 0000000..84abe63
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java
@@ -0,0 +1,151 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.date.DateUtil;
+import cn.hutool.core.util.HexUtil;
+import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
+import cn.hutool.crypto.symmetric.SymmetricCrypto;
+import cn.hutool.extra.spring.SpringUtil;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 代理下载链接工具类
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class ProxyDownloadUrlUtils {
+
+ private static SystemConfigService systemConfigService;
+
+ private static final String PROXY_DOWNLOAD_LINK_DELIMITER = ":";
+
+ /**
+ * 永久签名的标记 (写入签名内容第三段以代替过期时间戳).
+ */
+ private static final String PERMANENT_SIGNATURE_MARKER = "PERMANENT";
+
+ private static final Map AES_CACHE = new HashMap<>();
+
+ /**
+ * 服务器代理下载 URL 有效期 (秒).
+ */
+ public static final Integer PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND = 1800;
+
+ /**
+ * 生成签名:根据系统设置中 AES 密钥对生成签名.
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param pathAndName
+ * 文件路径及文件名称
+ *
+ * @param effectiveSecond
+ * 有效时间, 单位: 秒
+ *
+ * @return 签名
+ */
+ public static String generatorSignature(Integer storageId, String pathAndName, Integer effectiveSecond) {
+ // 如果有效时间为空, 则设置 30 分钟过期
+ if (effectiveSecond == null || effectiveSecond < 1) {
+ effectiveSecond = PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND;
+ }
+
+ // 过期时间的秒数
+ long second = DateUtil.offsetSecond(DateUtil.date(), effectiveSecond).getTime();
+ String content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + second;
+
+ return encrypt(content);
+ }
+
+
+ /**
+ * 生成永久签名:用于代理公开下载场景, 签名内容包含路径但不带过期时间.
+ *
+ * 这样即使关闭了"私有空间"开关, 下载链接仍然携带签名,
+ * 服务端通过校验签名内容中的路径来阻止任意路径访问 (路径隔离).
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param pathAndName
+ * 文件路径及文件名称
+ *
+ * @return 永久签名
+ */
+ public static String generatorPermanentSignature(Integer storageId, String pathAndName) {
+ String content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + PERMANENT_SIGNATURE_MARKER;
+ return encrypt(content);
+ }
+
+
+ public static boolean validSignatureExpired(Integer expectedStorageId, String expectedPathAndName, String signature) {
+ if (StringUtils.isEmpty(signature)) {
+ return false;
+ }
+ if (systemConfigService == null) {
+ systemConfigService = SpringUtil.getBean(SystemConfigService.class);
+ }
+
+ String aesHexKey = systemConfigService.getAesHexKeyOrGenerate();
+ SymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));
+
+ long currentTimeMillis = System.currentTimeMillis();
+
+ String storageId = null;
+ String pathAndName = null;
+ String expiredSecond = null;
+
+ try {
+ //解密
+ String decryptStr = aes.decryptStr(signature);
+ List split = StringUtils.split(decryptStr, PROXY_DOWNLOAD_LINK_DELIMITER);
+ storageId = split.get(0);
+ pathAndName = split.get(1);
+ expiredSecond = split.get(2);
+
+ // 先校验存储源 ID 和文件路径必须匹配, 这是签名最核心的隔离能力.
+ boolean storageAndPathMatch = StringUtils.equals(storageId, Convert.toStr(expectedStorageId))
+ && StringUtils.equals(StringUtils.concat(pathAndName), StringUtils.concat(expectedPathAndName));
+
+ if (!storageAndPathMatch) {
+ log.warn("校验链接不匹配, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
+ return false;
+ }
+
+ // 永久签名跳过过期时间校验.
+ if (PERMANENT_SIGNATURE_MARKER.equals(expiredSecond)) {
+ return true;
+ }
+
+ // 普通签名继续校验是否过期.
+ if (currentTimeMillis < Convert.toLong(expiredSecond)) {
+ return true;
+ }
+
+ log.warn("校验链接已过期, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
+ } catch (Exception e) {
+ log.error("校验签名链接异常, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
+ return false;
+ }
+
+ return false;
+ }
+
+
+ private static String encrypt(String content) {
+ if (systemConfigService == null) {
+ systemConfigService = SpringUtil.getBean(SystemConfigService.class);
+ }
+ String aesHexKey = systemConfigService.getAesHexKeyOrGenerate();
+ SymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));
+ return aes.encryptHex(content);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java b/src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java
new file mode 100644
index 0000000..d6754d6
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java
@@ -0,0 +1,231 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.extra.servlet.JakartaServletUtil;
+import im.zhaojun.zfile.core.constant.ZFileHttpHeaderConstant;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.core.exception.core.SystemException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.IOUtils;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRange;
+import org.springframework.http.MediaType;
+import org.springframework.http.MediaTypeFactory;
+import org.springframework.util.StreamUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 获取 Request 工具类
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class RequestHolder {
+
+ /**
+ * 获取 HttpServletRequest
+ *
+ * @return HttpServletRequest
+ */
+ public static HttpServletRequest getRequest() {
+ return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
+ }
+
+
+ /**
+ * 获取 HttpServletResponse
+ *
+ * @return HttpServletResponse
+ */
+ public static HttpServletResponse getResponse() {
+ return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse();
+ }
+
+
+ /**
+ * 向 response 写入文件流.
+ *
+ * @param inputStream
+ * 文件输入流
+ *
+ * @param fileName
+ * 文件名称
+ *
+ * @param fileSize
+ * 文件大小
+ *
+ * @param isPartialContentFromInputStream
+ * 表示输入流是否为部分内容。
+ * 当该变量为 true 时,表示输入流已经根据 range 规则从存储源获取部分内容。
+ * 在这种情况下,不需要跳过 range start 部分,可以直接从输入流的全部内容复制到输出流。
+ *
+ * @param forceDownload
+ * 是否强制下载
+ */
+ public static void writeFile(InputStream inputStream, String fileName, Long fileSize, boolean isPartialContentFromInputStream, boolean forceDownload) {
+ if (inputStream == null) {
+ throw new BizException(ErrorCode.BIZ_FILE_NOT_EXIST);
+ }
+ OutputStream outputStream = null;
+ try (InputStream innerInputStream = inputStream) {
+ HttpServletResponse response = RequestHolder.getResponse();
+
+ ContentDisposition contentDisposition = ContentDisposition
+ .builder(forceDownload ? "attachment" : "inline")
+ .filename(fileName, StandardCharsets.UTF_8)
+ .build();
+ response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString());
+ if (forceDownload) {
+ response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+ } else {
+ response.setContentType(MediaTypeFactory.getMediaType(fileName).orElse(MediaType.APPLICATION_OCTET_STREAM).toString());
+ }
+
+ outputStream = response.getOutputStream();
+
+ if (fileSize != null && fileSize > 0) {
+ String range = RequestHolder.getRequest().getHeader(HttpHeaders.RANGE);
+ List httpRanges = HttpRange.parseRanges(range);
+ if (httpRanges.isEmpty()) {
+ httpRanges = Collections.singletonList(HttpRange.createByteRange(0, fileSize - 1));
+ } else {
+ response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+ }
+ HttpRange httpRange = CollectionUtils.getFirst(httpRanges);
+ long startPos = httpRange.getRangeStart(fileSize);
+ long endPos = httpRange.getRangeEnd(fileSize);
+ if (response.getStatus() == HttpServletResponse.SC_PARTIAL_CONTENT) {
+ response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + startPos + "-" + endPos + StringUtils.SLASH + fileSize);
+ }
+
+ response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
+ response.setContentLengthLong(endPos - startPos + 1);
+ if (isPartialContentFromInputStream) {
+ StreamUtils.copy(innerInputStream, outputStream);
+ } else {
+ StreamUtils.copyRange(innerInputStream, outputStream, startPos, endPos);
+ }
+ return;
+ }
+
+ StreamUtils.copy(innerInputStream, outputStream);
+ } catch (IOException e) {
+ boolean isBrokenPipe = e.getMessage().contains("Broken pipe");
+ boolean isConnectionResetByPeer = e.getMessage().contains("Connection reset by peer");
+ if (isBrokenPipe || isConnectionResetByPeer) {
+ if (log.isDebugEnabled()) {
+ log.debug("skip IOException: {}", e.getMessage());
+ }
+ } else {
+ throw new SystemException(e);
+ }
+ } finally {
+ IOUtils.closeQuietly(inputStream);
+ IOUtils.closeQuietly(outputStream);
+ }
+ }
+
+ public static boolean isAxiosRequest() {
+ HttpServletRequest request = RequestHolder.getRequest();
+ String axiosRequest = JakartaServletUtil.getHeaderIgnoreCase(request, ZFileHttpHeaderConstant.AXIOS_REQUEST);
+ return StringUtils.isNotEmpty(axiosRequest);
+ }
+
+
+ /**
+ * 获取请求头中的 Axios-From 字段
+ *
+ * @return Axios-From 字段
+ */
+ public static String getAxiosFrom() {
+ if (RequestContextHolder.getRequestAttributes() == null) {
+ return null;
+ }
+ HttpServletRequest request = RequestHolder.getRequest();
+ return JakartaServletUtil.getHeaderIgnoreCase(request, ZFileHttpHeaderConstant.AXIOS_FROM);
+ }
+
+ /**
+ * 获取后端服务地址,如果经过了反向代理,需反向代理正确配置
+ */
+ public static String getRequestServerAddress() {
+ if (RequestContextHolder.getRequestAttributes() == null) {
+ return null;
+ }
+ HttpServletRequest request = RequestHolder.getRequest();
+
+ String forwardedHost = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Host");
+ String forwardedPort = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Port");
+ String forwardedProto = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Proto");
+
+ String scheme = StringUtils.isBlank(forwardedProto) ? request.getScheme() : forwardedProto;
+
+ // 优先使用 X-Forwarded-Host,其次使用 Host 头,最后使用 request.getServerName()
+ String serverName;
+ String hostHeader = StringUtils.isNotBlank(forwardedHost) ? forwardedHost : request.getHeader("Host");
+ if (StringUtils.isNotBlank(hostHeader)) {
+ // Host 头可能包含端口信息,如 "example.com:8080"
+ String[] hostParts = hostHeader.split(":");
+ serverName = hostParts[0];
+ // 如果 Host 头包含端口且没有显式设置 X-Forwarded-Port,则使用 Host 头中的端口
+ if (hostParts.length > 1 && StringUtils.isBlank(forwardedPort)) {
+ forwardedPort = hostParts[1];
+ }
+ } else {
+ serverName = request.getServerName();
+ }
+
+ // 端口处理逻辑
+ String port;
+ if (StringUtils.isNotBlank(forwardedPort)) {
+ port = forwardedPort;
+ } else if (StringUtils.isNotBlank(forwardedProto)) {
+ // 如果设置了转发协议但没有设置端口,使用协议默认端口
+ port = "https".equalsIgnoreCase(forwardedProto) ? "443" : "80";
+ } else {
+ port = String.valueOf(request.getServerPort());
+ }
+
+ // 移除默认端口
+ if ("443".equals(port) && "https".equalsIgnoreCase(scheme)) {
+ port = "";
+ }
+ if ("80".equals(port) && "http".equalsIgnoreCase(scheme)) {
+ port = "";
+ }
+
+ if (StringUtils.isBlank(port)) {
+ return scheme + "://" + serverName;
+ } else {
+ return scheme + "://" + serverName + ":" + port;
+ }
+ }
+
+
+ /**
+ * 获取当前请求的 Origin 请求头
+ *
+ * @return Origin 请求头值
+ */
+ public static String getOriginAddress() {
+ if (RequestContextHolder.getRequestAttributes() == null) {
+ return null;
+ }
+ HttpServletRequest request = RequestHolder.getRequest();
+ return JakartaServletUtil.getHeaderIgnoreCase(request, HttpHeaders.ORIGIN);
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java b/src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java
new file mode 100644
index 0000000..8a70913
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java
@@ -0,0 +1,18 @@
+package im.zhaojun.zfile.core.util;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRange;
+import org.springframework.util.CollectionUtils;
+
+public class RequestUtils {
+
+ public static HttpRange getRequestRange(HttpServletRequest request) {
+ String rangeHeader = request.getHeader(HttpHeaders.RANGE);
+ if (rangeHeader == null) {
+ return null;
+ }
+ return CollectionUtils.firstElement(HttpRange.parseRanges(rangeHeader));
+ }
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java b/src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java
new file mode 100644
index 0000000..4eba421
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java
@@ -0,0 +1,51 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.util.NumberUtil;
+
+/**
+ * 文件大小或带宽大小转可读单位
+ *
+ * @author zhaojun
+ */
+public class SizeToStrUtils {
+
+ /**
+ * 将文件大小转换为可读单位
+ *
+ * @param bytes
+ * 字节数
+ *
+ * @return 文件大小可读单位
+ */
+ public static String bytesToSize(long bytes) {
+ if (bytes == 0) {
+ return "0";
+ }
+
+ double k = 1024;
+ String[] sizes = new String[]{"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
+ double i = Math.floor(Math.log(bytes) / Math.log(k));
+ return NumberUtil.round(bytes / Math.pow(k, i), 3) + " " + sizes[(int) i];
+ }
+
+
+ /**
+ * 将带宽大小转换为可读单位
+ *
+ * @param bps
+ * 字节数
+ *
+ * @return 带宽大小可读单位
+ */
+ public static String bpsToSize(long bps) {
+ if (bps == 0) {
+ return "0";
+ }
+
+ double k = 1000;
+ String[] sizes = new String[]{"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
+ double i = Math.floor(Math.log(bps) / Math.log(k));
+ return NumberUtil.round(bps / Math.pow(k, i), 3) + " " + sizes[(int) i];
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java b/src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java
new file mode 100644
index 0000000..b96cfda
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java
@@ -0,0 +1,21 @@
+package im.zhaojun.zfile.core.util;
+
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.servlet.HandlerMapping;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * @author zhaojun
+ */
+public class SpringMvcUtils {
+
+ public static String getExtractPathWithinPattern() {
+ HttpServletRequest httpServletRequest = RequestHolder.getRequest();
+ String path = (String) httpServletRequest.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
+ String bestMatchPattern = (String) httpServletRequest.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
+ AntPathMatcher apm = new AntPathMatcher();
+ return apm.extractPathWithinPattern(bestMatchPattern, path);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/StrPool.java b/src/main/java/im/zhaojun/zfile/core/util/StrPool.java
new file mode 100644
index 0000000..94c989a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/StrPool.java
@@ -0,0 +1,98 @@
+package im.zhaojun.zfile.core.util;
+
+public interface StrPool {
+
+ /**
+ * 字符串常量:制表符 {@code "\t"}
+ */
+ String TAB = " ";
+
+ /**
+ * 字符串常量:点 {@code "."}
+ */
+ String DOT = ".";
+
+ /**
+ * 字符串常量:双点 {@code ".."}
+ * 用途:作为指向上级文件夹的路径,如:{@code "../path"}
+ */
+ String DOUBLE_DOT = "..";
+
+ /**
+ * 字符串常量:斜杠 {@code "/"}
+ */
+ String SLASH = "/";
+
+ /**
+ * 字符串常量:反斜杠 {@code "\\"}
+ */
+ String BACKSLASH = "\\";
+
+ /**
+ * 字符串常量:回车符 {@code "\r"}
+ * 解释:该字符常用于表示 Linux 系统和 MacOS 系统下的文本换行
+ */
+ String CR = "\r";
+
+ /**
+ * 字符串常量:换行符 {@code "\n"}
+ */
+ String LF = "\n";
+
+ /**
+ * 字符串常量:Windows 换行 {@code "\r\n"}
+ * 解释:该字符串常用于表示 Windows 系统下的文本换行
+ */
+ String CRLF = "\r\n";
+
+ /**
+ * 字符串常量:下划线 {@code "_"}
+ */
+ String UNDERLINE = "_";
+
+ /**
+ * 字符串常量:减号(连接符) {@code "-"}
+ */
+ String DASHED = "-";
+
+ /**
+ * 字符串常量:逗号 {@code ","}
+ */
+ String COMMA = ",";
+
+ /**
+ * 字符串常量:花括号(左) "{"
+ */
+ String DELIM_START = "{";
+
+ /**
+ * 字符串常量:花括号(右) "}"
+ */
+ String DELIM_END = "}";
+
+ /**
+ * 字符串常量:中括号(左) {@code "["}
+ */
+ String BRACKET_START = "[";
+
+ /**
+ * 字符串常量:中括号(右) {@code "]"}
+ */
+ String BRACKET_END = "]";
+
+ /**
+ * 字符串常量:冒号 {@code ":"}
+ */
+ String COLON = ":";
+
+ /**
+ * 字符串常量:艾特 {@code "@"}
+ */
+ String AT = "@";
+
+ /**
+ * 字符串常量:空 JSON {@code "{}"}
+ */
+ String EMPTY_JSON = "{}";
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/core/util/StringUtils.java b/src/main/java/im/zhaojun/zfile/core/util/StringUtils.java
new file mode 100644
index 0000000..bddddd0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/StringUtils.java
@@ -0,0 +1,490 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.cache.CacheUtil;
+import cn.hutool.cache.impl.LRUCache;
+import cn.hutool.core.net.URLEncodeUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.core.util.URLUtil;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 字符串相关工具类
+ *
+ * @author zhaojun
+ */
+public class StringUtils extends CharSequenceUtil implements StrPool {
+
+ public static final String HTTP = "http";
+
+ public static final String PROTOCOL_MARKER = "://";
+
+ private static final LRUCache CACHE = CacheUtil.newLRUCache(1000);
+
+ /**
+ * 移除 URL 中的前后的所有 '/'
+ *
+ * @param path
+ * 路径
+ *
+ * @return 如 path = '/folder1/file1/', 返回 'folder1/file1'
+ * 如 path = '///folder1/file1//', 返回 'folder1/file1'
+ */
+ public static String trimSlashes(String path) {
+ path = trimStartSlashes(path);
+ path = trimEndSlashes(path);
+ return path;
+ }
+
+
+ /**
+ * 移除 URL 中的前面的所有 '/'
+ *
+ * @param path
+ * 路径
+ *
+ * @return 如 path = '/folder1/file1', 返回 'folder1/file1'
+ * 如 path = '//folder1/file1', 返回 'folder1/file1'
+ *
+ */
+ public static String trimStartSlashes(String path) {
+ if (isEmpty(path)) {
+ return path;
+ }
+
+ while (path.startsWith(SLASH)) {
+ path = path.substring(1);
+ }
+
+ return path;
+ }
+
+
+ /**
+ * 移除 URL 中结尾的所有 '/'
+ *
+ * @param path
+ * 路径
+ *
+ * @return 如 path = '/folder1/file1/', 返回 '/folder1/file1'
+ * 如 path = '/folder1/file1///', 返回 '/folder1/file1'
+ */
+ public static String trimEndSlashes(String path) {
+ if (isEmpty(path)) {
+ return path;
+ }
+
+ while (path.endsWith(SLASH)) {
+ path = path.substring(0, path.length() - 1);
+ }
+
+ return path;
+ }
+
+
+ /**
+ * 去除路径中所有重复的 '/',如果最开始的协议头前有 / 也一并去除。
+ *
+ * @param path
+ * 路径
+ *
+ * @return 如 path = '/folder1//file1/', 返回 '/folder1/file1/'
+ * 如 path = '/folder1////file1///', 返回 '/folder1/file1/'
+ */
+ public static String removeDuplicateSlashes(String path) {
+ if (isEmpty(path)) {
+ return path;
+ }
+
+ return CACHE.get(path, false, () -> {
+ StringBuilder sb = new StringBuilder(path.length());
+ int protocolIndex = path.indexOf(PROTOCOL_MARKER);
+
+ int pathStartIndex = 0;
+
+ // 1. 处理协议部分
+ if (protocolIndex > -1) {
+ // 找到协议名称的实际开始位置
+ int schemeStartIndex = 0;
+ while (schemeStartIndex < protocolIndex && path.charAt(schemeStartIndex) == '/') {
+ schemeStartIndex++;
+ }
+
+ sb.append(path, schemeStartIndex, protocolIndex);
+ sb.append(PROTOCOL_MARKER);
+
+ pathStartIndex = protocolIndex + PROTOCOL_MARKER.length();
+ }
+
+ if (pathStartIndex < path.length()) {
+ char lastChar;
+ char firstPathChar = path.charAt(pathStartIndex);
+ sb.append(firstPathChar);
+ lastChar = firstPathChar;
+
+ for (int i = pathStartIndex + 1; i < path.length(); i++) {
+ char current = path.charAt(i);
+ if (current != SLASH_CHAR || lastChar != SLASH_CHAR) {
+ sb.append(current);
+ lastChar = current;
+ }
+ }
+ }
+
+ return sb.toString();
+ });
+ }
+
+
+ /**
+ * 去除路径中所有重复的 '/', 并且去除开头的 '/'
+ *
+ * @param path
+ * 路径
+ *
+ * @return 如 path = '/folder1//file1/', 返回 'folder1/file1/'
+ * 如 path = '///folder1////file1///', 返回 'folder1/file1/'
+ */
+ public static String removeDuplicateSlashesAndTrimStart(String path) {
+ path = removeDuplicateSlashes(path);
+ path = trimStartSlashes(path);
+ return path;
+ }
+
+
+ /**
+ * 去除路径中所有重复的 '/', 并且去除结尾的 '/'
+ *
+ * @param path
+ * 路径
+ *
+ * @return 如 path = '/folder1//file1/', 返回 '/folder1/file1'
+ * 如 path = '///folder1////file1///', 返回 '/folder1/file1'
+ */
+ public static String removeDuplicateSlashesAndTrimEnd(String path) {
+ path = removeDuplicateSlashes(path);
+ path = trimEndSlashes(path);
+ return path;
+ }
+
+
+ /**
+ * 拼接 URL,并去除重复的分隔符 '/',并去除开头的 '/', 但不会影响 http:// 和 https:// 这种头部.
+ *
+ * @param strs
+ * 拼接的字符数组
+ *
+ * @return 拼接结果
+ */
+ public static String concatTrimStartSlashes(String... strs) {
+ return trimStartSlashes(concat(strs));
+ }
+
+
+ /**
+ * 拼接 URL,并去除重复的分隔符 '/',并去除结尾的 '/', 但不会影响 http:// 和 https:// 这种头部.
+ *
+ * @param strs
+ * 拼接的字符数组
+ *
+ * @return 拼接结果
+ */
+ public static String concatTrimEndSlashes(String... strs) {
+ return trimEndSlashes(concat(strs));
+ }
+
+
+ /**
+ * 拼接 URL,并去除重复的分隔符 '/',并去除开头和结尾的 '/', 但不会影响 http:// 和 https:// 这种头部.
+ *
+ * @param strs
+ * 拼接的字符数组
+ *
+ * @return 拼接结果
+ */
+ public static String concatTrimSlashes(String... strs) {
+ return trimSlashes(concat(strs));
+ }
+
+
+ /**
+ * 拼接 URL,并去除重复的分隔符 '/',但不会影响 http:// 和 https:// 这种头部.
+ *
+ * @param strs
+ * 拼接的字符数组
+ *
+ * @return 拼接结果
+ */
+ public static String concat(String... strs) {
+ StringBuilder sb = new StringBuilder(SLASH);
+ for (int i = 0; i < strs.length; i++) {
+ String str = strs[i];
+ if (isEmpty(str)) {
+ continue;
+ }
+ sb.append(str);
+ if (i != strs.length - 1) {
+ sb.append(SLASH_CHAR);
+ }
+ }
+ return removeDuplicateSlashes(sb.toString());
+ }
+
+
+ /**
+ * 拼接 URL,并去除重复的分隔符 '/',但不会影响 http:// 和 https:// 这种头部.
+ *
+ * @param encodeAllIgnoreSlashes
+ * 是否 encode 编码 (忽略 /)
+ *
+ * @param strs
+ * 拼接的字符数组
+ *
+ * @return 拼接结果
+ */
+ public static String concat(boolean encodeAllIgnoreSlashes, String... strs) {
+ String res = concat(strs);
+ if (encodeAllIgnoreSlashes) {
+ return encodeAllIgnoreSlashes(res);
+ } else {
+ return res;
+ }
+ }
+
+
+ /**
+ * 替换 URL 中的 Host 部分,如替换 http://a.com/1.txt 为 https://abc.com/1.txt
+ *
+ * @param originUrl
+ * 原 URL
+ *
+ * @param replaceHost
+ * 替换的 HOST
+ *
+ * @return 替换后的 URL
+ */
+ public static String replaceHost(String originUrl, String replaceHost) {
+ try {
+ String path = new URL(originUrl).getFile();
+ return concat(replaceHost, path);
+ } catch (MalformedURLException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+
+ /**
+ * 编码 URL,默认使用 UTF-8 编码
+ * URL 的 Fragment URLEncoder
+ * 默认的编码器针对Fragment,定义如下:
+ *
+ *
+ * fragment = *( pchar / "/" / "?" )
+ * pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
+ * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
+ * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
+ *
+ *
+ * 具体见:https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
+ *
+ * @param url
+ * 被编码内容
+ *
+ * @return 编码后的字符
+ */
+ public static String encode(String url) {
+ return URLEncodeUtil.encodeFragment(url);
+ }
+
+
+ /**
+ * 编码全部字符
+ *
+ * @param str
+ * 被编码内容
+ *
+ * @return 编码后的字符
+ */
+ public static String encodeAllIgnoreSlashes(String str) {
+ if (isEmpty(str)) {
+ return str;
+ }
+
+ StringBuilder sb = new StringBuilder();
+
+ int prevIndex = -1;
+ for (int i = 0; i < str.length(); i++) {
+ char c = str.charAt(i);
+ if (c == StringUtils.SLASH_CHAR) {
+ if (prevIndex < i) {
+ String substring = str.substring(prevIndex + 1, i);
+ sb.append(URLEncodeUtil.encodeAll(substring));
+ prevIndex = i;
+ }
+ sb.append(c);
+ }
+
+ if (i == str.length() - 1 && prevIndex < i) {
+ String substring = str.substring(prevIndex + 1, i + 1);
+ sb.append(URLEncodeUtil.encodeAll(substring));
+ }
+ }
+
+ return sb.toString();
+ }
+
+
+ /**
+ * 解码 URL, 默认使用 UTF8 编码. 不会将 + 转为空格.
+ *
+ * @param url
+ * 被解码内容
+ *
+ * @return 解码后的内容
+ */
+ public static String decode(String url) {
+ return URLUtil.decode(url, StandardCharsets.UTF_8, false);
+ }
+
+
+ /**
+ * 移除字符串中所有换行符并去除前后空格
+ *
+ * @param str
+ * URL
+ *
+ * @return 移除协议后的 URL
+ */
+ public static String removeAllLineBreaksAndTrim(String str) {
+ String removeResult = StrUtil.removeAllLineBreaks(str);
+ return trim(removeResult);
+ }
+
+
+ /**
+ * 移除字符串前后空格
+ *
+ * @param str
+ * 字符串
+ *
+ * @return 移除前后空格后的字符串
+ */
+ public static String trim(final String str) {
+ return str == null ? null : str.trim();
+ }
+
+
+ /**
+ * 如果给定字符串不是以suffix结尾的,在尾部补充 suffix
+ *
+ * @param str 字符串
+ * @param suffix 后缀
+ * @return 补充后的字符串
+ */
+ public static String addSuffixIfNot(CharSequence str, CharSequence suffix) {
+ if (isEmpty(str) || isEmpty(suffix)) {
+ return str.toString();
+ }
+
+ if (str.toString().endsWith(suffix.toString())) {
+ return str.toString();
+ }
+
+ return str.toString() + suffix;
+ }
+
+ /**
+ * 是否包含特定字符,忽略大小写,如果给定两个参数都为{@code null},返回true
+ *
+ * @param str 被检测字符串
+ * @param testStr 被测试是否包含的字符串
+ * @return 是否包含
+ */
+ public static boolean containsIgnoreCase(CharSequence str, CharSequence testStr) {
+ if (null == str) {
+ // 如果被监测字符串和
+ return null == testStr;
+ }
+ return StrUtil.indexOfIgnoreCase(str, testStr) > -1;
+ }
+
+
+ /**
+ * 指定范围内查找指定字符
+ *
+ * @param str
+ * 字符串
+ *
+ * @param searchChar
+ * 被查找的字符
+ *
+ * @return 位置
+ */
+ public static int indexOf(String str, char searchChar) {
+ if (isEmpty(str)) {
+ return INDEX_NOT_FOUND;
+ }
+ return str.indexOf(searchChar);
+ }
+
+
+ /**
+ * 字符串驼峰转下划线格式
+ *
+ * @param param
+ * 驼峰格式字符串
+ *
+ * @return 下划线格式字符串
+ */
+ public static String camelToUnderline(String param) {
+ if (isEmpty(param)) {
+ return EMPTY;
+ }
+
+ int len = param.length();
+ StringBuilder sb = new StringBuilder(len);
+ for (int i = 0; i < len; i++) {
+ char c = param.charAt(i);
+ if (Character.isUpperCase(c) && i > 0) {
+ sb.append(UNDERLINE);
+ }
+ sb.append(Character.toLowerCase(c));
+ }
+ return sb.toString();
+ }
+
+
+ /**
+ * 强制给 URL 设置协议
+ *
+ * @param url
+ * URL 地址,可以是带协议的,也可以是不带协议的,写会忽略大小写
+ *
+ * @param schema
+ * 协议,如 http, https, http://, https://
+ *
+ * @return 设置协议后的 URL
+ */
+ public static String setSchema(String url, String schema) {
+ if (StringUtils.isEmpty(url) || StringUtils.isEmpty(schema)) {
+ return url;
+ }
+
+ if (!schema.endsWith("://")) {
+ schema += "://";
+ }
+
+ String lowerUrl = url.toLowerCase();
+ if (lowerUrl.startsWith("http://")) {
+ url = url.substring(7);
+ } else if (lowerUrl.startsWith("https://")) {
+ url = url.substring(8);
+ }
+
+ return schema + url;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java b/src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java
new file mode 100644
index 0000000..f154fc0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java
@@ -0,0 +1,112 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * url 相关工具类
+ *
+ * @author zhaojun
+ */
+public class UrlUtils {
+
+ /**
+ * 判断 URL 是否包含协议部分
+ *
+ * @param url
+ * URL 地址
+ *
+ * @return 是否包含协议部分
+ */
+ public static boolean hasScheme(String url) {
+ return url.startsWith("http://") || url.startsWith("https://");
+ }
+
+ /**
+ * 为 URL 拼接参数
+ *
+ * @param url
+ * 原始 URL
+ *
+ * @param name
+ * 参数名称
+ *
+ * @param value
+ * 参数值
+ *
+ * @return 拼接后的 URL
+ */
+ public static String concatQueryParam(String url, String name, String value) {
+ if (StringUtils.contains(url, "?")) {
+ return url + "&" + name + "=" + value;
+ } else {
+ return url + "?" + name + "=" + value;
+ }
+ }
+
+ /**
+ * 获取 URL 中的协议部分
+ *
+ * @param url
+ * URL 地址
+ *
+ * @return 协议部分
+ */
+ public static String getSchema(String url) {
+ if (StringUtils.startWithIgnoreCase(url, "http://")) {
+ return "http";
+ } else if (StringUtils.startWithIgnoreCase(url, "https://")) {
+ return "https";
+ } else {
+ return "http";
+ }
+ }
+
+ /**
+ * 移除 URL 中的协议部分
+ *
+ * @param url
+ * URL 地址
+ *
+ * @return 移除协议部分后的 URL
+ */
+ public static String removeScheme(String url) {
+ if (StringUtils.startWithIgnoreCase(url, "http://")) {
+ url = url.substring(7);
+ } else if (StringUtils.startWithIgnoreCase(url, "https://")) {
+ url = url.substring(8);
+ }
+
+ return url;
+ }
+
+ /**
+ * 获取 URL 中的域名部分
+ *
+ * @param url
+ * URL 地址
+ *
+ * @return 域名部分
+ */
+ public static String getDomain(String url) {
+ if (!StringUtils.isEmpty(url)) {
+ //替换指定前缀
+ String newStr = url.replace("http://", "");
+ newStr = newStr.replace("https://", "");
+
+ int index = StrUtil.indexOf(newStr, '/');
+ if (index > 0) {
+ newStr = newStr.substring(0, index);
+ }
+
+ String[] split = newStr.split(":");
+ if (split.length > 1) {
+ return split[0];
+ } else {
+ return newStr;
+ }
+ } else {
+ return null;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java b/src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java
new file mode 100644
index 0000000..08578bb
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java
@@ -0,0 +1,50 @@
+package im.zhaojun.zfile.core.util;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.extra.spring.SpringUtil;
+import im.zhaojun.zfile.module.share.context.ShareAccessContext;
+import im.zhaojun.zfile.module.user.model.constant.UserConstant;
+import im.zhaojun.zfile.module.user.model.entity.User;
+import im.zhaojun.zfile.module.user.service.UserService;
+
+/**
+ * 登录认证工具类
+ *
+ * @author zhaojun
+ */
+public class ZFileAuthUtil {
+
+ private static UserService userService;
+
+ public static User getCurrentUser() {
+ if (userService == null) {
+ userService = SpringUtil.getBean(UserService.class);
+ }
+
+ // 检查是否为分享访问,如果是则返回分享者用户 ID
+ if (ShareAccessContext.isShareAccess()) {
+ Integer shareUserId = ShareAccessContext.getShareUserId();
+ return userService.getById(shareUserId);
+ }
+
+ return userService.getById(StpUtil.getLoginId(UserConstant.ANONYMOUS_ID));
+ }
+
+ public static Integer getCurrentUserId() {
+ if (userService == null) {
+ userService = SpringUtil.getBean(UserService.class);
+ }
+
+ // 检查是否为分享访问,如果是则返回分享者用户 ID
+ if (ShareAccessContext.isShareAccess()) {
+ return ShareAccessContext.getShareUserId();
+ }
+
+ try {
+ return StpUtil.getLoginId(UserConstant.ANONYMOUS_ID);
+ } catch (Exception e) {
+ return UserConstant.ANONYMOUS_ID;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java
new file mode 100644
index 0000000..cb0f625
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java
@@ -0,0 +1,43 @@
+package im.zhaojun.zfile.core.util.matcher;
+
+import java.util.Collection;
+
+/**
+ * 抽象规则匹配器, 实现了部分方法, 用于简化规则匹配器的实现.
+ *
+ * @author zhaojun
+ */
+public abstract class AbstractRuleMatcher implements IRuleMatcher {
+
+ @Override
+ public boolean contains(String ruleExpression, String testStr) {
+ return match(ruleExpression, testStr);
+ }
+
+ @Override
+ public boolean matchAny(Collection ruleExpressionList, String testStr) {
+ if (ruleExpressionList == null || ruleExpressionList.isEmpty()) {
+ return false;
+ }
+ for (String ruleExpression : ruleExpressionList) {
+ if (match(ruleExpression, testStr)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public String matchAnyReturnFirst(Collection ruleExpressionList, String testStr) {
+ if (ruleExpressionList == null || ruleExpressionList.isEmpty()) {
+ return null;
+ }
+ for (String ruleExpression : ruleExpressionList) {
+ if (match(ruleExpression, testStr)) {
+ return ruleExpression;
+ }
+ }
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java
new file mode 100644
index 0000000..a7b0b9c
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java
@@ -0,0 +1,75 @@
+package im.zhaojun.zfile.core.util.matcher;
+
+import java.util.Collection;
+
+/**
+ * 规则匹配器接口
+ *
+ * @author zhaojun
+ */
+public interface IRuleMatcher {
+
+ /**
+ * 匹配规则
+ *
+ * @param ruleExpression
+ * 规则表达式
+ *
+ * @param testStr
+ * 测试字符串
+ *
+ * @return 是否匹配
+ */
+ boolean match(String ruleExpression, String testStr);
+
+ /**
+ * 部分匹配规则
+ *
+ * @param ruleExpression
+ * 规则表达式
+ *
+ * @param testStr
+ * 测试字符串
+ *
+ * @return 是否部分匹配
+ */
+ boolean contains(String ruleExpression, String testStr);
+
+
+ /**
+ * 匹配规则, 可以匹配多个规则表达式, 只要有一个匹配成功, 则返回 true
+ *
+ * @param ruleExpressionList
+ * 规则表达式列表
+ *
+ * @param testStr
+ * 测试字符串
+ *
+ * @return 是否匹配
+ */
+ boolean matchAny(Collection ruleExpressionList, String testStr);
+
+
+ /**
+ * 匹配规则, 可以匹配多个规则表达式, 只要有一个匹配成功, 则返回第一个匹配成功的表达式。
+ *
+ * @param ruleExpressionList
+ * 规则表达式列表
+ *
+ * @param testStr
+ * 测试字符串
+ *
+ * @return 匹配成功的第一个表达式
+ */
+ String matchAnyReturnFirst(Collection ruleExpressionList, String testStr);
+
+
+ /**
+ * 获取规则类型
+ *
+ * @return 规则类型
+ */
+ String getRuleType();
+
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java
new file mode 100644
index 0000000..1b654d9
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java
@@ -0,0 +1,42 @@
+package im.zhaojun.zfile.core.util.matcher;
+
+import im.zhaojun.zfile.core.util.matcher.impl.AntPathRuleMatcher;
+import im.zhaojun.zfile.core.util.matcher.impl.IpRuleMatcher;
+import im.zhaojun.zfile.core.util.matcher.impl.RegexRuleMatcher;
+import im.zhaojun.zfile.core.util.matcher.impl.SpringSimpleRuleMatcher;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 规则匹配器工厂, 用于获取规则匹配器实例.
+ *
+ * @author zhaojun
+ */
+public class RuleMatcherFactory {
+
+ private static final Map RULE_MATCHER_MAP = new HashMap<>();
+
+ static {
+ IpRuleMatcher ipRuleMatcher = new IpRuleMatcher();
+ RULE_MATCHER_MAP.put(ipRuleMatcher.getRuleType(), ipRuleMatcher);
+
+ RegexRuleMatcher regexRuleMatcher = new RegexRuleMatcher();
+ RULE_MATCHER_MAP.put(regexRuleMatcher.getRuleType(), regexRuleMatcher);
+
+ AntPathRuleMatcher antPathRuleMatcher = new AntPathRuleMatcher();
+ RULE_MATCHER_MAP.put(antPathRuleMatcher.getRuleType(), antPathRuleMatcher);
+
+ SpringSimpleRuleMatcher springSimpleRuleMatcher = new SpringSimpleRuleMatcher();
+ RULE_MATCHER_MAP.put(springSimpleRuleMatcher.getRuleType(), springSimpleRuleMatcher);
+ }
+
+ public static IRuleMatcher getRuleMatcher(String ruleType) {
+ if (ruleType == null || ruleType.isEmpty()) {
+ return null;
+ }
+
+ return RULE_MATCHER_MAP.get(ruleType);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java
new file mode 100644
index 0000000..20186f0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java
@@ -0,0 +1,32 @@
+package im.zhaojun.zfile.core.util.matcher.impl;
+
+import im.zhaojun.zfile.core.constant.RuleTypeConstant;
+import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.AntPathMatcher;
+
+/**
+ * Ant 路径匹配器, 用于匹配路径规则.
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class AntPathRuleMatcher extends AbstractRuleMatcher {
+
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+ @Override
+ public boolean match(String ruleExpression, String testStr) {
+ boolean match = pathMatcher.match(ruleExpression, testStr);
+ if (log.isDebugEnabled()) {
+ log.debug("Ant 表达式匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr);
+ }
+ return match;
+ }
+
+ @Override
+ public String getRuleType() {
+ return RuleTypeConstant.ANT_PATH;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java
new file mode 100644
index 0000000..6cf0c21
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java
@@ -0,0 +1,272 @@
+package im.zhaojun.zfile.core.util.matcher.impl;
+
+import im.zhaojun.zfile.core.constant.RuleTypeConstant;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;
+import lombok.extern.slf4j.Slf4j;
+
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.regex.Pattern;
+
+/**
+ * IP 匹配器, 用于 IP 规则,支持匹配完整 IP 或 IP 段,同时支持 IPV4 和 IPV6.
+ *
+ *
+ *
+ * - IPV4 示例:
+ *
+ * - 127.0.0.1
+ * - 192.168.0.0/24
+ *
+ *
+ *
+ *
+ *
+ *
+ * - IPV6 示例:
+ *
+ * - 0:0:0:0:0:0:0:1
+ * - 0:0:0:0:0:0:0:0/64
+ *
+ *
+ *
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class IpRuleMatcher extends AbstractRuleMatcher {
+
+ @Override
+ public boolean match(String ruleExpression, String testStr) {
+ try {
+ InetAddress inetAddress = InetAddress.getByName(testStr);
+ IpRule rule = createRule(ruleExpression);
+ boolean match = rule != null && rule.matches(inetAddress);
+ if (log.isDebugEnabled()) {
+ log.debug("IP 匹配结果: {}, 规则表达式: {}, 测试值: {}, 校验规则: {}", match, ruleExpression, testStr, rule);
+ }
+ return match;
+ } catch (UnknownHostException e) {
+ log.error("IP 地址解析失败, ruleExpression: {}, testStr: {}", ruleExpression, testStr);
+ }
+ return false;
+ }
+
+ @Override
+ public String getRuleType() {
+ return RuleTypeConstant.IP;
+ }
+
+ private IpRule createRule(String ruleExpression) {
+ if (isValidIpv4(ruleExpression)) {
+ return new Ipv4Rule(ruleExpression);
+ } else if (isValidIpv6(ruleExpression)) {
+ return new Ipv6Rule(ruleExpression);
+ } else if (isValidIpv4Range(ruleExpression)) {
+ return new Ipv4RangeRule(ruleExpression);
+ } else if (isValidIpv6Range(ruleExpression)) {
+ return new Ipv6RangeRule(ruleExpression);
+ } else {
+ return null;
+ }
+ }
+
+ private boolean isValidIpv4(String ipAddress) {
+ String ipv4Pattern = "^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\."
+ + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\."
+ + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\."
+ + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])$";
+ return Pattern.matches(ipv4Pattern, ipAddress);
+ }
+
+ private boolean isValidIpv6(String ipAddress) {
+ try {
+ InetAddress.getByName(ipAddress);
+ return ipAddress.contains(":");
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ }
+
+ private boolean isValidIpv4Range(String ipRange) {
+ String ipv4RangePattern = "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$";
+ return Pattern.matches(ipv4RangePattern, ipRange);
+ }
+
+ private boolean isValidIpv6Range(String ipRange) {
+ String ipv6RangePattern = "^([0-9A-Fa-f]{0,4}:){1,7}([0-9A-Fa-f]{0,4})?/[0-9]{1,3}$";
+ return Pattern.matches(ipv6RangePattern, ipRange);
+ }
+
+ private interface IpRule {
+
+ boolean matches(InetAddress ipAddress);
+
+ String getExpression();
+
+ }
+
+ private static class Ipv4Rule implements IpRule {
+
+ private final String expression;
+
+ Ipv4Rule(String expression) {
+ this.expression = expression;
+ }
+
+ @Override
+ public boolean matches(InetAddress ipAddress) {
+ if (ipAddress instanceof Inet6Address) {
+ return false;
+ }
+ return ipAddress.getHostAddress().equals(expression);
+ }
+
+ @Override
+ public String getExpression() {
+ return expression;
+ }
+ }
+
+ private static class Ipv6Rule implements IpRule {
+
+ private final String expression;
+
+ Ipv6Rule(String expression) {
+ this.expression = expression;
+ }
+
+ @Override
+ public boolean matches(InetAddress ipAddress) {
+ if (ipAddress instanceof Inet6Address) {
+ return ipAddress.getHostAddress().equals(expression);
+ }
+ return false;
+ }
+
+ @Override
+ public String getExpression() {
+ return expression;
+ }
+ }
+
+ private static class Ipv4RangeRule implements IpRule {
+
+ private final String expression;
+ private final int prefixLength;
+
+ Ipv4RangeRule(String expression) {
+ this.expression = expression.substring(0, expression.indexOf('/'));
+ this.prefixLength = Integer.parseInt(expression.substring(expression.indexOf('/') + 1));
+ }
+
+ @Override
+ public boolean matches(InetAddress ipAddress) {
+ if (ipAddress instanceof Inet6Address) {
+ return false;
+ }
+ String[] rangeParts = expression.split("\\.");
+ byte[] rangeAddrBytes = new byte[4];
+ for (int i = 0; i < rangeParts.length; i++) {
+ rangeAddrBytes[i] = (byte) Integer.parseInt(rangeParts[i]);
+ }
+
+ byte[] ipValue = ipAddress.getAddress();
+ if (ipValue.length != 4) {
+ return false;
+ }
+
+ for (int i = 0; i < prefixLength / 8; i++) {
+ if (rangeAddrBytes[i] != ipValue[i]) {
+ return false;
+ }
+ }
+
+ int remainingBits = prefixLength % 8;
+ if (remainingBits != 0) {
+ int rangeByte = rangeAddrBytes[prefixLength / 8];
+ int ipByte = ipValue[prefixLength / 8];
+
+ int shift = 8 - remainingBits;
+ int mask = 0xFF >> shift;
+
+ return (rangeByte >> shift & mask) == (ipByte >> shift & mask);
+ }
+
+ return true;
+ }
+
+ @Override
+ public String getExpression() {
+ return expression + StringUtils.SLASH + prefixLength;
+ }
+ }
+
+ private static class Ipv6RangeRule implements IpRule {
+
+ private final String expression;
+ private final int prefixLength;
+
+ Ipv6RangeRule(String expression) {
+ this.expression = expression.substring(0, expression.indexOf('/'));
+ this.prefixLength = Integer.parseInt(expression.substring(expression.indexOf('/') + 1));
+ }
+
+ @Override
+ public boolean matches(InetAddress ipAddress) {
+ if (ipAddress instanceof Inet6Address) {
+ byte[] ipValue = ipAddress.getAddress();
+ byte[] rangeValue = Inet6AddressConverter.convert(expression);
+
+ if (ipValue.length != 16) {
+ return false;
+ }
+
+ for (int i = 0; i < prefixLength / 8; i++) {
+ if (rangeValue[i] != ipValue[i]) {
+ return false;
+ }
+ }
+
+ int remainingBits = prefixLength % 8;
+ if (remainingBits != 0) {
+ int rangeByte = rangeValue[prefixLength / 8];
+ int ipByte = ipValue[prefixLength / 8];
+
+ int shift = 8 - remainingBits;
+ int mask = 0xFF >> shift;
+
+ return (rangeByte >> shift & mask) == (ipByte >> shift & mask);
+ }
+
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public String getExpression() {
+ return expression + StringUtils.SLASH + prefixLength;
+ }
+ }
+
+ // Utility class to convert IPv6 address to byte array
+ private static class Inet6AddressConverter {
+ public static byte[] convert(String ipv6Address) {
+ byte[] ipAddress = new byte[16];
+ String[] blocks = ipv6Address.split(":");
+
+ for (int i = 0; i < blocks.length; i++) {
+ String block = blocks[i];
+ if (!block.isEmpty()) {
+ ipAddress[i * 2] = (byte) Integer.parseInt(block.substring(0, 2), 16);
+ ipAddress[i * 2 + 1] = (byte) Integer.parseInt(block.substring(2, 4), 16);
+ }
+ }
+
+ return ipAddress;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java
new file mode 100644
index 0000000..ee82e04
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java
@@ -0,0 +1,39 @@
+package im.zhaojun.zfile.core.util.matcher.impl;
+
+import cn.hutool.core.util.ReUtil;
+import im.zhaojun.zfile.core.constant.RuleTypeConstant;
+import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 正则匹配器
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class RegexRuleMatcher extends AbstractRuleMatcher {
+
+ @Override
+ public boolean match(String ruleExpression, String testStr) {
+ boolean match = ReUtil.isMatch(ruleExpression, testStr);
+ if (log.isDebugEnabled()) {
+ log.debug("正则匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr);
+ }
+ return match;
+ }
+
+ @Override
+ public boolean contains(String ruleExpression, String testStr) {
+ boolean match = ReUtil.contains(ruleExpression, testStr);
+ if (log.isDebugEnabled()) {
+ log.debug("正则部分匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr);
+ }
+ return match;
+ }
+
+ @Override
+ public String getRuleType() {
+ return RuleTypeConstant.REGEX;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java
new file mode 100644
index 0000000..c72de6c
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java
@@ -0,0 +1,35 @@
+package im.zhaojun.zfile.core.util.matcher.impl;
+
+import im.zhaojun.zfile.core.constant.RuleTypeConstant;
+import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.PatternMatchUtils;
+
+/**
+ * 使用 {@link org.springframework.util.PatternMatchUtils} 来匹配规则
+ *
+ * @author zhaojun
+ */
+@Slf4j
+public class SpringSimpleRuleMatcher extends AbstractRuleMatcher {
+
+ @Override
+ public boolean match(String ruleExpression, String testStr) {
+ boolean match = PatternMatchUtils.simpleMatch(ruleExpression, testStr);
+ if (log.isDebugEnabled()) {
+ log.debug("Spring Simple 规则匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr);
+ }
+ return match;
+ }
+
+ @Override
+ public String getRuleType() {
+ return RuleTypeConstant.SPRING_SIMPLE;
+ }
+
+ @Override
+ public boolean contains(String ruleExpression, String testStr) {
+ return match("*" + ruleExpression + "*", testStr);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java b/src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java
new file mode 100644
index 0000000..3999eb4
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java
@@ -0,0 +1,31 @@
+package im.zhaojun.zfile.core.validation;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * 字符串列表值校验注解
+ *
+ * @author zhaojun
+ */
+@Documented
+@Constraint(validatedBy = { StringListValueConstraintValidator.class })
+@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
+@Retention(RUNTIME)
+public @interface StringListValue {
+
+ String message() default "";
+
+ Class>[] groups() default { };
+
+ Class extends Payload>[] payload() default { };
+
+ String[] vals() default { };
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java b/src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java
new file mode 100644
index 0000000..92a4431
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java
@@ -0,0 +1,53 @@
+package im.zhaojun.zfile.core.validation;
+
+import im.zhaojun.zfile.core.util.StringUtils;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 字符串列表值校验器
+ *
+ * @author zhaojun
+ */
+public class StringListValueConstraintValidator implements ConstraintValidator {
+
+ private final Set set = new HashSet<>();
+
+ /**
+ * 初始化方法
+ *
+ * @param constraintAnnotation
+ * 校验注解对象
+ */
+ @Override
+ public void initialize(StringListValue constraintAnnotation) {
+ String[] vals = constraintAnnotation.vals();
+ set.addAll(Arrays.asList(vals));
+
+ }
+
+
+ /**
+ * 判断是否校验成功
+ *
+ * @param value
+ * 需要校验的值
+ *
+ * @param context
+ * 校验上下文
+ *
+ * @return 是否校验成功
+ */
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ if (StringUtils.isEmpty(value)) {
+ return true;
+ }
+ return set.contains(value);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java b/src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java
new file mode 100644
index 0000000..1a88f76
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java
@@ -0,0 +1,56 @@
+package im.zhaojun.zfile.module.admin.controller;
+
+import cn.hutool.extra.servlet.JakartaServletUtil;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONWriter;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.RequestHolder;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author zhaojun
+ */
+@Tag(name = "IP 地址辅助 Controller")
+@Slf4j
+@RequestMapping("/admin")
+@RestController
+public class IpHelperController {
+
+ @Resource
+ private HttpServletRequest httpServletRequest;
+
+ @GetMapping("clientIp")
+ @Operation(summary = "获取客户端IP", description ="获取当前请求的客户端IP地址")
+ public AjaxJson clientIp() {
+ String clientIp = JakartaServletUtil.getClientIP(httpServletRequest);
+ return AjaxJson.getSuccessData(clientIp);
+ }
+
+ @GetMapping("serverAddress")
+ @Operation(summary = "获取服务器地址", description = "获取当前请求的服务器地址(如果是反向代理过,可能获取到的是反向代理服务器的地址)")
+ public AjaxJson serverAddress() {
+ return AjaxJson.getSuccessData(RequestHolder.getRequestServerAddress());
+ }
+
+ @GetMapping("headers")
+ @Operation(summary = "获取 Headers", description = "获取服务器接收到的请求头信息,可用于排查反向代理配置问题")
+ public AjaxJson headers() {
+ Map> headersMap = JakartaServletUtil.getHeadersMap(httpServletRequest);
+ Map singleValueHeaderMap = headersMap.entrySet().stream()
+ .collect(java.util.stream.Collectors.toMap(
+ Map.Entry::getKey,
+ entry -> String.join(",", entry.getValue())
+ ));
+ return AjaxJson.getSuccessData(JSON.toJSONString(singleValueHeaderMap, JSONWriter.Feature.PrettyFormat));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java b/src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java
new file mode 100644
index 0000000..e73d267
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java
@@ -0,0 +1,50 @@
+package im.zhaojun.zfile.module.admin.controller;
+
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.core.util.matcher.IRuleMatcher;
+import im.zhaojun.zfile.core.util.matcher.RuleMatcherFactory;
+import im.zhaojun.zfile.module.admin.model.request.TestRuleMatcherRequest;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * @author zhaojun
+ */
+@Tag(name = "规则匹配辅助 Controller")
+@Slf4j
+@RequestMapping("/admin")
+@RestController
+public class RuleMatcherTestController {
+
+ /**
+ * 根据传入的规则和测试值, 测试规则是否匹配, 规则支持多个, 用换行符分割. 如果匹配, 则返回匹配的规则表达式行.
+ * @param testRuleMatcherRequest
+ * 测试规则匹配请求
+ *
+ * @return 匹配成功的第一个表达式
+ */
+ @PostMapping("/rule-test")
+ public AjaxJson testRule(@RequestBody @Valid TestRuleMatcherRequest testRuleMatcherRequest) {
+ if (testRuleMatcherRequest == null) {
+ return AjaxJson.getSuccessData(null);
+ }
+ String rules = testRuleMatcherRequest.getRules();
+ String testValue = testRuleMatcherRequest.getTestValue();
+ if (StringUtils.isBlank(testValue) || StringUtils.isBlank(rules)) {
+ return AjaxJson.getSuccessData(null);
+ }
+
+ List ruleList = StringUtils.split(rules, StringUtils.LF);
+ IRuleMatcher ipRuleMatcher = RuleMatcherFactory.getRuleMatcher(testRuleMatcherRequest.getRuleType());
+ return AjaxJson.getSuccessData(ipRuleMatcher.matchAnyReturnFirst(ruleList, testValue));
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java b/src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java
new file mode 100644
index 0000000..f8c7d80
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java
@@ -0,0 +1,19 @@
+package im.zhaojun.zfile.module.admin.model.request;
+
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+
+@Data
+public class TestRuleMatcherRequest {
+
+ @NotBlank(message = "规则类型不能为空")
+ private String ruleType;
+
+ @NotBlank(message = "规则不能为空")
+ private String rules;
+
+ @NotBlank(message = "测试值不能为空")
+ private String testValue;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java b/src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java
new file mode 100644
index 0000000..1c8706c
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java
@@ -0,0 +1,17 @@
+package im.zhaojun.zfile.module.config.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标注是否按 JSON 字符串解析
+ *
+ * @author zhaojun
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JSONStringParse {
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java b/src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java
new file mode 100644
index 0000000..7fc8e55
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java
@@ -0,0 +1,19 @@
+package im.zhaojun.zfile.module.config.constant;
+
+/**
+ * 系统设置字段常量.
+ *
+ * @author zhaojun
+ */
+public class SystemConfigConstant {
+
+ public static final String USERNAME = "username";
+
+ public static final String PASSWORD = "password";
+
+ public static final String LOGIN_VERIFY_MODE = "loginVerifyMode";
+
+ // 这里名称和值不一样是历史遗留问题,最开始设计时弄混了名称,实际使用的是 aes
+ public static final String AES_HEX_KEY = "rsaHexKey";
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java b/src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java
new file mode 100644
index 0000000..c710590
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java
@@ -0,0 +1,117 @@
+package im.zhaojun.zfile.module.config.controller;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson2.JSON;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.annotation.DemoDisable;
+import im.zhaojun.zfile.core.config.ZFileProperties;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.model.request.*;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.apache.commons.lang3.BooleanUtils;
+import org.springframework.beans.BeanUtils;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 站点设定值接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "站点设置模块")
+@ApiSort(2)
+@RestController
+@RequestMapping("/admin")
+public class SettingController {
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private ZFileProperties zFileProperties;
+
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "获取站点信息", description = "获取站点相关信息,如站点名称,风格样式,是否显示公告,是否显示文档区,自定义 CSS,JS 等参数")
+ @GetMapping("/config")
+ public AjaxJson getConfig() {
+ SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();
+ if (zFileProperties != null && zFileProperties.isDemoSite()) {
+ SystemConfigDTO copy = JSON.parseObject(JSON.toJSONString(systemConfigDTO), SystemConfigDTO.class);
+ copy.setAuthCode(null);
+ copy.setRsaHexKey(null);
+ return AjaxJson.getSuccessData(copy);
+ }
+ return AjaxJson.getSuccessData(systemConfigDTO);
+ }
+
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "修改站点设置")
+ @PutMapping("/config/site")
+ @DemoDisable
+ public AjaxJson updateSiteSetting(@Valid @RequestBody UpdateSiteSettingRequest settingRequest) {
+ if (StrUtil.length(settingRequest.getAuthCode()) > 36 && StrUtil.length(settingRequest.getAuthCode()) < 100) {
+ throw new BizException("授权码长度异常,请检查是否额外复制了空格或特殊字符!");
+ }
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ BeanUtils.copyProperties(settingRequest, systemConfigDTO);
+ systemConfigService.updateSystemConfig(systemConfigDTO);
+ return AjaxJson.getSuccess();
+ }
+
+
+ @ApiOperationSupport(order = 4)
+ @Operation(summary = "修改显示设置")
+ @PutMapping("/config/view")
+ @DemoDisable
+ public AjaxJson updateViewSetting(@Valid @RequestBody UpdateViewSettingRequest settingRequest) {
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ BeanUtils.copyProperties(settingRequest, systemConfigDTO);
+ systemConfigService.updateSystemConfig(systemConfigDTO);
+ return AjaxJson.getSuccess();
+ }
+
+
+ @ApiOperationSupport(order = 5)
+ @Operation(summary = "修改登陆安全设置")
+ @PutMapping("/config/security")
+ @DemoDisable
+ public AjaxJson updateSecuritySetting(@Valid @RequestBody UpdateSecuritySettingRequest settingRequest) {
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ BeanUtils.copyProperties(settingRequest, systemConfigDTO);
+ if (BooleanUtils.isNotTrue(settingRequest.getAdminTwoFactorVerify())) {
+ systemConfigDTO.setLoginVerifySecret("");
+ }
+ systemConfigService.updateSystemConfig(systemConfigDTO);
+ return AjaxJson.getSuccess();
+ }
+
+
+ @ApiOperationSupport(order = 6)
+ @Operation(summary = "修改直链设置")
+ @PutMapping("/config/link")
+ @DemoDisable
+ public AjaxJson updateLinkSetting(@Valid @RequestBody UpdateLinkSettingRequest settingRequest) {
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ BeanUtils.copyProperties(settingRequest, systemConfigDTO);
+ systemConfigService.updateSystemConfig(systemConfigDTO);
+ return AjaxJson.getSuccess();
+ }
+
+ @ApiOperationSupport(order = 7)
+ @Operation(summary = "修改访问控制设置")
+ @PutMapping("/config/access")
+ @DemoDisable
+ public AjaxJson updateSecuritySetting(@Valid @RequestBody UpdateAccessSettingRequest updateAccessSettingRequest) {
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ BeanUtils.copyProperties(updateAccessSettingRequest, systemConfigDTO);
+ systemConfigService.updateSystemConfig(systemConfigDTO);
+ return AjaxJson.getSuccess();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java b/src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java
new file mode 100644
index 0000000..4a705aa
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java
@@ -0,0 +1,88 @@
+package im.zhaojun.zfile.module.config.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.config.ZFileProperties;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.core.util.ZFileAuthUtil;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.model.result.FrontSiteConfigResult;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.storage.annotation.ProCheck;
+import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
+import im.zhaojun.zfile.module.storage.model.request.base.FileListConfigRequest;
+import im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult;
+import im.zhaojun.zfile.module.storage.service.StorageSourceService;
+import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
+import im.zhaojun.zfile.module.user.model.constant.UserConstant;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Objects;
+
+/**
+ * 面向前台的站点基础模块接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "站点基础模块")
+@ApiSort(1)
+@Slf4j
+@RequestMapping("/api/site")
+@RestController
+public class SiteController {
+
+ @Resource
+ private ZFileProperties zFileProperties;
+
+ @Resource
+ private StorageSourceService storageSourceService;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "获取站点全局设置", description = "获取站点全局设置, 包括是否页面布局、列表尺寸、公告、配置信息")
+ @GetMapping("/config/global")
+ @ProCheck
+ public AjaxJson globalConfig() {
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+
+ FrontSiteConfigResult frontSiteConfigResult = new FrontSiteConfigResult();
+ BeanUtils.copyProperties(systemConfig, frontSiteConfigResult);
+
+ frontSiteConfigResult.setDebugMode(zFileProperties.isDebug());
+ boolean guestUser = Objects.equals(ZFileAuthUtil.getCurrentUserId(), UserConstant.ANONYMOUS_ID);
+ boolean guestIndexNotBlank = StringUtils.isNotBlank(systemConfig.getGuestIndexHtml());
+ frontSiteConfigResult.setGuest(guestUser && guestIndexNotBlank);
+ return AjaxJson.getSuccessData(frontSiteConfigResult);
+ }
+
+
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "获取存储源设置", description = "获取某个存储源的设置信息, 包括是否启用, 名称, 存储源类型, 存储源配置信息")
+ @PostMapping("/config/storage")
+ public AjaxJson storageList(@Valid @RequestBody FileListConfigRequest fileListConfigRequest) {
+ StorageSourceConfigResult storageSourceConfigResult = storageSourceService.getStorageConfigSource(fileListConfigRequest);
+ return AjaxJson.getSuccessData(storageSourceConfigResult);
+ }
+
+
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "获取用户存储源路径", description = "获取用户存储源路径")
+ @GetMapping("/config/userRootPath/{storageKey}")
+ public AjaxJson getUserRootPath(@PathVariable("storageKey") String storageKey) {
+ AbstractBaseFileService> baseFileService = StorageSourceContext.getByStorageKey(storageKey);
+ if (baseFileService == null || baseFileService.getCurrentUserBasePath() == null) {
+ return AjaxJson.getSuccessData("");
+ }
+ return AjaxJson.getSuccessData(baseFileService.getCurrentUserBasePath());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java b/src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java
new file mode 100644
index 0000000..a21b163
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java
@@ -0,0 +1,43 @@
+package im.zhaojun.zfile.module.config.event;
+
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+import im.zhaojun.zfile.module.link.controller.DirectLinkController;
+import im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+
+/**
+ * 接收系统设置修改事件, 修改直链前缀时, 动态更新直链前缀.
+ *
+ * @author zhaojun
+ */
+@Slf4j
+@Component
+public class DirectLinkPrefixModifyHandler implements ISystemConfigModifyHandler {
+
+ @Resource
+ private DynamicDirectLinkPrefixService dynamicDirectLinkPrefixService;
+
+ @Override
+ public void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {
+ String oldValue = originalSystemConfig.getValue();
+ String newValue = newSystemConfig.getValue();
+ if (StringUtils.equals(oldValue, newValue)) {
+ log.info("检测到修改了直链前缀, 但是新值和旧值相同, 不做处理.");
+ return;
+ }
+
+ RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(newValue + DirectLinkController.DIRECT_LINK_SUFFIX_PATH).build();
+ dynamicDirectLinkPrefixService.updateRegisterMappingHandler(SystemConfig.DIRECT_LINK_PREFIX_NAME, requestMappingInfo);
+ log.info("检测到修改了直链前缀, [{}] -> [{}], 已自动更新直链前缀.", oldValue, newValue);
+ }
+
+ @Override
+ public boolean matches(String name) {
+ return SystemConfig.DIRECT_LINK_PREFIX_NAME.equals(name);
+ }
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java b/src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java
new file mode 100644
index 0000000..576006e
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java
@@ -0,0 +1,28 @@
+package im.zhaojun.zfile.module.config.event;
+
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+
+/**
+ * 系统设置修改事件
+ *
+ * @author zhaojun
+ */
+public interface ISystemConfigModifyHandler {
+
+ /**
+ * 修改系统设置时会触发此事件
+ *
+ */
+ void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig);
+
+ /**
+ * 判断是否匹配当前处理器
+ *
+ * @param name
+ * 配置项名称
+ *
+ * @return 是否匹配
+ */
+ boolean matches(String name);
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java b/src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java
new file mode 100644
index 0000000..e3c2de7
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java
@@ -0,0 +1,44 @@
+package im.zhaojun.zfile.module.config.event;
+
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+import im.zhaojun.zfile.module.user.service.DynamicLoginEntryService;
+import im.zhaojun.zfile.module.user.util.LoginEntryPathUtils;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+
+/**
+ * 监听安全登录入口配置变更,动态更新登录接口映射。
+ *
+ * @author zhaojun
+ */
+@Slf4j
+@Component
+public class SecureLoginEntryModifyHandler implements ISystemConfigModifyHandler {
+
+ @Resource
+ private DynamicLoginEntryService dynamicLoginEntryService;
+
+ @Override
+ public void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {
+ String oldPath = LoginEntryPathUtils.resolveLoginPath(originalSystemConfig.getValue());
+ String newPath = LoginEntryPathUtils.resolveLoginPath(newSystemConfig.getValue());
+
+ if (StringUtils.equals(oldPath, newPath)) {
+ log.info("检测到修改安全登录入口,但实际登录路径未变化,跳过处理。");
+ return;
+ }
+
+ RequestMappingInfo requestMappingInfo = dynamicLoginEntryService.buildLoginRequestMappingInfo(newSystemConfig.getValue());
+ dynamicLoginEntryService.updateRegisterMappingHandler(SystemConfig.SECURE_LOGIN_ENTRY_NAME, requestMappingInfo);
+ log.info("安全登录入口已更新,{} -> {}", oldPath, newPath);
+ }
+
+ @Override
+ public boolean matches(String name) {
+ return SystemConfig.SECURE_LOGIN_ENTRY_NAME.equals(name);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java b/src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java
new file mode 100644
index 0000000..319a328
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java
@@ -0,0 +1,24 @@
+package im.zhaojun.zfile.module.config.event;
+
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+import org.springframework.stereotype.Component;
+
+import jakarta.annotation.Resource;
+import java.util.List;
+
+/**
+ * @author zhaojun
+ */
+@Component
+public class SystemConfigModifyHandlerChain {
+
+ @Resource
+ private List handlers;
+
+ public void execute(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {
+ handlers.stream()
+ .filter(handler -> handler.matches(originalSystemConfig.getName()))
+ .forEach(handler -> handler.modify(originalSystemConfig, newSystemConfig));
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java
new file mode 100644
index 0000000..80bda6b
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java
@@ -0,0 +1,48 @@
+package im.zhaojun.zfile.module.config.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+
+/**
+ * 系统配置 Mapper 接口
+ *
+ * @author zhaojun
+ */
+@Mapper
+public interface SystemConfigMapper extends BaseMapper {
+
+ /**
+ * 获取所有系统设置
+ *
+ * @return 系统设置列表
+ */
+ List findAll();
+
+
+ /**
+ * 根据系统设置名称获取设置信息
+ *
+ * @param name
+ * 系统设置名称
+ *
+ * @return 系统设置信息
+ */
+ SystemConfig findByName(@Param("name")String name);
+
+
+ /**
+ * 批量保存系统设置
+ *
+ * @param list
+ * 系统设置列表
+ *
+ * @return 保存记录数
+ */
+ int saveAll(@Param("list")List list);
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java b/src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java
new file mode 100644
index 0000000..fcfbea7
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java
@@ -0,0 +1,18 @@
+package im.zhaojun.zfile.module.config.model.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class LinkExpireDTO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private Integer value;
+
+ private String unit;
+
+ private Long seconds;
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java b/src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java
new file mode 100644
index 0000000..ac4f1f6
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java
@@ -0,0 +1,316 @@
+package im.zhaojun.zfile.module.config.model.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import im.zhaojun.zfile.module.config.annotation.JSONStringParse;
+import im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum;
+import im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum;
+import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;
+import im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 系统设置传输类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "系统设置类")
+public class SystemConfigDTO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Schema(title = "站点名称", example = "ZFile Site Name")
+ private String siteName;
+
+ @Schema(title = "用户名", example = "admin")
+ @Deprecated
+ private String username;
+
+ @Schema(title = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png")
+ private String avatar;
+
+ @Schema(title = "备案号", example = "冀ICP备12345678号-1")
+ private String icp;
+
+ @JsonIgnore
+ @Deprecated
+ private String password;
+
+ @Schema(title = "自定义 JS")
+ private String customJs;
+
+ @Schema(title = "自定义 CSS")
+ private String customCss;
+
+ @Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default")
+ private String tableSize;
+
+ @Schema(title = "是否显示文档区", example = "true")
+ private Boolean showDocument;
+
+ @Schema(title = "网站公告", example = "ZFile 网站公告")
+ private String announcement;
+
+ @Schema(title = "是否显示网站公告", example = "true")
+ private Boolean showAnnouncement;
+
+ @Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full")
+ private String layout;
+
+ @Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
+ private String mobileLayout;
+
+ @Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true")
+ private Boolean mobileShowSize;
+
+ @Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true")
+ private Boolean showLinkBtn;
+
+ @Schema(title = "是否显示生成短链功能", example = "true")
+ private Boolean showShortLink;
+
+ @Schema(title = "是否显示生成路径链接功能", example = "true")
+ private Boolean showPathLink;
+
+ @Schema(title = "是否已初始化", example = "true")
+ private Boolean installed;
+
+ @Schema(title = "自定义视频文件后缀格式")
+ private String customVideoSuffix;
+
+ @Schema(title = "自定义图像文件后缀格式")
+ private String customImageSuffix;
+
+ @Schema(title = "自定义音频文件后缀格式")
+ private String customAudioSuffix;
+
+ @Schema(title = "自定义文本文件后缀格式")
+ private String customTextSuffix;
+
+ @Schema(title = "自定义Office后缀格式")
+ private String customOfficeSuffix;
+
+ @Schema(title = "自定义kkFileView后缀格式")
+ private String customKkFileViewSuffix;
+
+ @Schema(title = "直链地址前缀")
+ private String directLinkPrefix;
+
+ @Schema(title = "直链 Referer 防盗链类型")
+ private RefererTypeEnum refererType;
+
+ @Schema(title = "是否记录下载日志", example = "true")
+ private Boolean recordDownloadLog;
+
+ @Schema(title = "直链 Referer 是否允许为空")
+ private Boolean refererAllowEmpty;
+
+ @Schema(title = "直链 Referer 值")
+ private String refererValue;
+
+ /**
+ * 废弃的字段,改为使用 {@link #adminTwoFactorVerify} 和 {@link #loginVerifySecret} 代替
+ */
+ @Schema(title = "管理员登陆验证方式,目前仅支持 2FA 认证或关闭")
+ @Deprecated
+ private LoginVerifyModeEnum loginVerifyMode;
+
+ @Schema(title = "登陆验证 Secret")
+ private String loginVerifySecret;
+
+ @Schema(title = "是否启用登陆验证码", example = "true")
+ private Boolean loginImgVerify;
+
+ @Schema(title = "是否为管理员启用双因素认证", example = "true")
+ private Boolean adminTwoFactorVerify;
+
+ @Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean rootShowStorage;
+
+ @Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com")
+ private String forceBackendAddress;
+
+ @Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
+ private String frontDomain;
+
+ @Schema(title = "是否在前台显示登陆按钮", example = "true")
+ private Boolean showLogin;
+
+ @Schema(title = "安全登录入口", description = "用于隐藏默认登录地址的安全入口,不包含 '/'", example = "admin")
+ private String secureLoginEntry;
+
+ @Schema(title = "登录日志模式", example = "all")
+ private LoginLogModeEnum loginLogMode;
+
+ @Schema(title = "RAS Hex Key", example = "r2HKbzc1DfvOs5uHhLn7pA==")
+ private String rsaHexKey;
+
+ @Schema(title = "默认文件点击习惯", example = "click")
+ private FileClickModeEnum fileClickMode;
+
+ @Schema(title = "移动端默认文件点击习惯", example = "click")
+ private FileClickModeEnum mobileFileClickMode;
+
+ @Schema(title = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5")
+ private String authCode;
+
+ @Schema(title = "最大同时上传文件数", example = "5")
+ private Integer maxFileUploads;
+
+ @Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
+ private String onlyOfficeUrl;
+
+ @Schema(title = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed")
+ private String onlyOfficeSecret;
+
+ @Schema(title = "kkFileView 在线预览地址", example = "http://kkfile.zfile.vip")
+ private String kkFileViewUrl;
+
+ @Schema(title = "kkFileView 预览方式", example = "iframe/newTab")
+ private String kkFileViewOpenMode;
+
+ @Schema(title = "启用 WebDAV", example = "true")
+ private Boolean webdavEnable;
+
+ @Schema(title = "WebDAV 服务器中转下载", example = "true")
+ private Boolean webdavProxy;
+
+ @Schema(title = "WebDAV 匿名用户访问", example = "true")
+ private Boolean webdavAllowAnonymous;
+
+ @Schema(title = "WebDAV 账号", example = "admin")
+ private String webdavUsername;
+
+ @Schema(title = "WebDAV 密码", example = "123456")
+ private String webdavPassword;
+
+ @Schema(title = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean allowPathLinkAnonAccess;
+
+ @Schema(title = "默认最大显示文件数", example = "1000")
+ private Integer maxShowSize;
+
+ @Schema(title = "每次加载更多文件数", example = "50")
+ private Integer loadMoreSize;
+
+ @Schema(title = "默认排序字段", example = "name")
+ private String defaultSortField;
+
+ @Schema(title = "默认排序方向", example = "asc")
+ private String defaultSortOrder;
+
+ @Schema(title = "站点 Home 名称", example = "xxx 的小站")
+ private String siteHomeName;
+
+ @Schema(title = "站点 Home Logo", example = "true")
+ private String siteHomeLogo;
+
+ @Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
+ private String siteHomeLogoLink;
+
+ @Schema(title = "站点 Logo 链接打开方式", example = "_blank")
+ private String siteHomeLogoTargetMode;
+
+ @Schema(title = "管理员页面点击 Logo 回到首页打开方式", example = "_blank")
+ private String siteAdminLogoTargetMode;
+
+ @Schema(title = "管理员页面点击版本号打开更新日志", example = "true")
+ private Boolean siteAdminVersionOpenChangeLog;
+
+ @Schema(title = "限制直链下载秒数", example = "_blank")
+ private Integer linkLimitSecond;
+
+ @Schema(title = "限制直链下载次数", example = "_blank")
+ private Integer linkDownloadLimit;
+
+ @Schema(title = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico")
+ private String faviconUrl;
+
+ @Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
+ @JSONStringParse
+ private List linkExpireTimes;
+
+ @Schema(title = "是否默认记住密码", example = "true")
+ private Boolean defaultSavePwd;
+
+ @Schema(title = "普通下载是否启用确认弹窗", example = "true")
+ private Boolean enableNormalDownloadConfirm;
+
+ @Schema(title = "打包下载是否启用确认弹窗", example = "true")
+ private Boolean enablePackageDownloadConfirm;
+
+ @Schema(title = "批量下载是否启用确认弹窗", example = "true")
+ private Boolean enableBatchDownloadConfirm;
+
+ /**
+ * 废弃的字段,不再使用悬浮菜单
+ */
+ @Deprecated
+ @Schema(title = "是否启用 hover 菜单", example = "true")
+ private Boolean enableHoverMenu;
+
+ @Schema(title = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1")
+ private String accessIpBlocklist;
+
+ @Schema(title = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*")
+ private String accessUaBlocklist;
+
+ @Schema(title = "匿名用户首页显示内容")
+ private String guestIndexHtml;
+
+ public String getAnnouncement() {
+ return announcement == null ? "" : announcement;
+ }
+
+ public List getLinkExpireTimes() {
+ if (linkExpireTimes == null) {
+ LinkExpireDTO linkExpireDTO = new LinkExpireDTO();
+ linkExpireDTO.setValue(1);
+ linkExpireDTO.setUnit("d");
+ linkExpireDTO.setSeconds(86400L);
+ linkExpireTimes = new ArrayList<>();
+ linkExpireTimes.add(linkExpireDTO);
+ }
+ return linkExpireTimes;
+ }
+
+ public String getLayout() {
+ return layout == null ? "full" : layout;
+ }
+
+ public String getMobileLayout() {
+ return mobileLayout == null ? getLayout() : mobileLayout;
+ }
+
+ /**
+ * 获取普通下载是否启用确认弹窗配置.
+ *
+ * @return 若为空则返回 true
+ */
+ public Boolean getEnableNormalDownloadConfirm() {
+ return enableNormalDownloadConfirm == null ? Boolean.TRUE : enableNormalDownloadConfirm;
+ }
+
+ /**
+ * 获取打包下载是否启用确认弹窗配置.
+ *
+ * @return 若为空则返回 true
+ */
+ public Boolean getEnablePackageDownloadConfirm() {
+ return enablePackageDownloadConfirm == null ? Boolean.TRUE : enablePackageDownloadConfirm;
+ }
+
+ /**
+ * 获取批量下载是否启用确认弹窗配置.
+ *
+ * @return 若为空则返回 true
+ */
+ public Boolean getEnableBatchDownloadConfirm() {
+ return enableBatchDownloadConfirm == null ? Boolean.TRUE : enableBatchDownloadConfirm;
+ }
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java b/src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java
new file mode 100644
index 0000000..7dca87a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java
@@ -0,0 +1,47 @@
+package im.zhaojun.zfile.module.config.model.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 系统设置 entity
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "系统设置")
+@TableName(value = "system_config")
+public class SystemConfig implements Serializable {
+
+ public static final String DIRECT_LINK_PREFIX_NAME = "directLinkPrefix";
+
+ public static final String SECURE_LOGIN_ENTRY_NAME = "secureLoginEntry";
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.AUTO)
+ @Schema(title = "ID, 新增无需填写", example = "1")
+ private Integer id;
+
+
+ @TableField(value = "name")
+ @Schema(title = "系统设置名称", example = "siteName")
+ private String name;
+
+
+ @TableField(value = "`value`")
+ @Schema(title = "系统设置值", example = "ZFile 演示站")
+ private String value;
+
+
+ @TableField(value = "title")
+ @Schema(title = "系统设置描述", example = "站点名称")
+ private String title;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java b/src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java
new file mode 100644
index 0000000..c3d7982
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java
@@ -0,0 +1,31 @@
+package im.zhaojun.zfile.module.config.model.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文件点击模式枚举, 用于控制文件是单击打开还是双击打开
+ *
+ * @author zhaojun
+ */
+@Getter
+@AllArgsConstructor
+public enum FileClickModeEnum {
+
+ /**
+ * 单击打开文件/文件夹
+ */
+ CLICK("click"),
+
+ /**
+ * 双击打开文件/文件夹
+ */
+ DBCLICK("dbclick");
+
+ @EnumValue
+ @JsonValue
+ private final String value;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java
new file mode 100644
index 0000000..077be4a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java
@@ -0,0 +1,21 @@
+package im.zhaojun.zfile.module.config.model.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 站点访问控制请求参数类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "站点访问控制参数类")
+public class UpdateAccessSettingRequest {
+
+ @Schema(title = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1")
+ private String accessIpBlocklist;
+
+ @Schema(title = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*")
+ private String accessUaBlocklist;
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java
new file mode 100644
index 0000000..3dadb61
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java
@@ -0,0 +1,57 @@
+package im.zhaojun.zfile.module.config.model.request;
+
+import im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO;
+import im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 直链设置请求参数类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "直链设置请求参数类")
+public class UpdateLinkSettingRequest {
+
+ @Schema(title = "是否记录下载日志", example = "true")
+ private Boolean recordDownloadLog;
+
+ @Schema(title = "直链 Referer 防盗链类型")
+ private RefererTypeEnum refererType;
+
+ @Schema(title = "直链 Referer 是否允许为空")
+ private Boolean refererAllowEmpty;
+
+ @Schema(title = "直链 Referer 值")
+ private String refererValue;
+
+ @Schema(title = "直链地址前缀")
+ @NotBlank(message = "直链地址前缀不能为空")
+ private String directLinkPrefix;
+
+ @Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showLinkBtn;
+
+ @Schema(title = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showShortLink;
+
+ @Schema(title = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showPathLink;
+
+ @Schema(title = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean allowPathLinkAnonAccess;
+
+ @Schema(title = "限制直链下载秒数", example = "_blank")
+ private Integer linkLimitSecond;
+
+ @Schema(title = "限制直链下载次数", example = "_blank")
+ private Integer linkDownloadLimit;
+
+ @Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
+ private List linkExpireTimes;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java
new file mode 100644
index 0000000..19a1bcd
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java
@@ -0,0 +1,41 @@
+package im.zhaojun.zfile.module.config.model.request;
+
+import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 登陆安全设置请求参数类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "登陆安全设置请求参数类")
+public class UpdateSecuritySettingRequest {
+
+ @Schema(title = "是否在前台显示登陆按钮", example = "true")
+ private Boolean showLogin;
+
+ @Schema(title = "安全登录入口", description = "仅允许字母、数字、短横线和下划线,长度不超过 32", example = "admin")
+ @Size(max = 32, message = "安全登录入口长度不能超过 32 个字符")
+ @Pattern(regexp = "^[A-Za-z0-9_-]*$", message = "安全登录入口只能包含字母、数字、短横线和下划线")
+ private String secureLoginEntry;
+
+ @Schema(title = "登录日志模式", example = "all")
+ private LoginLogModeEnum loginLogMode;
+
+ @Schema(title = "是否启用登陆验证码", example = "true")
+ private Boolean loginImgVerify;
+
+ @Schema(title = "是否为管理员启用双因素认证", example = "true")
+ private Boolean adminTwoFactorVerify;
+
+ @Schema(title = "2FA登陆验证 Secret")
+ private String loginVerifySecret;
+
+ @Schema(title = "匿名用户首页显示内容")
+ private String guestIndexHtml;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java
new file mode 100644
index 0000000..7f240ef
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java
@@ -0,0 +1,60 @@
+package im.zhaojun.zfile.module.config.model.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 站点设置请求参数类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "站点设置请求参数类")
+public class UpdateSiteSettingRequest {
+
+ @Schema(title = "站点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ZFile Site Name")
+ @NotBlank(message = "站点名称不能为空")
+ private String siteName;
+
+ @Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com")
+ private String forceBackendAddress;
+
+ @Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
+ private String frontDomain;
+
+ @Schema(title = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png")
+ private String avatar;
+
+ @Schema(title = "备案号", example = "冀ICP备12345678号-1")
+ private String icp;
+
+ @Schema(title = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5")
+ private String authCode;
+
+ @Schema(title = "最大同时上传文件数", example = "5")
+ private Integer maxFileUploads;
+
+ @Schema(title = "站点 Home 名称", example = "xxx 的小站")
+ private String siteHomeName;
+
+ @Schema(title = "站点 Home Logo", example = "true")
+ private String siteHomeLogo;
+
+ @Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
+ private String siteHomeLogoLink;
+
+ @Schema(title = "站点 Logo 链接打开方式", example = "_blank")
+ private String siteHomeLogoTargetMode;
+
+ @Schema(title = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico")
+ private String faviconUrl;
+
+ @Schema(title = "管理员页面点击 Logo 回到首页打开方式", example = "_blank")
+ private String siteAdminLogoTargetMode;
+
+ @Schema(title = "管理员页面点击版本号打开更新日志", example = "true")
+ private Boolean siteAdminVersionOpenChangeLog;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java
new file mode 100644
index 0000000..4a7899b
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java
@@ -0,0 +1,25 @@
+package im.zhaojun.zfile.module.config.model.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 用户修改密码请求参数类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "用户修改密码请求参数类")
+public class UpdateUserNameAndPasswordRequest {
+
+ @Schema(title = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
+ @NotBlank(message = "用户名不能为空")
+ private String username;
+
+ @Schema(title = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
+ @NotBlank(message = "密码不能为空")
+ private String password;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java
new file mode 100644
index 0000000..353dd3f
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java
@@ -0,0 +1,113 @@
+package im.zhaojun.zfile.module.config.model.request;
+
+import im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 显示设置请求参数类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "显示设置请求参数类")
+public class UpdateViewSettingRequest {
+
+ @Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean rootShowStorage;
+
+ @Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String layout;
+
+ @Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
+ private String mobileLayout;
+
+ @Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true")
+ private Boolean mobileShowSize;
+
+ @Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String tableSize;
+
+ @Schema(title = "自定义视频文件后缀格式")
+ private String customVideoSuffix;
+
+ @Schema(title = "自定义图像文件后缀格式")
+ private String customImageSuffix;
+
+ @Schema(title = "自定义音频文件后缀格式")
+ private String customAudioSuffix;
+
+ @Schema(title = "自定义文本文件后缀格式")
+ private String customTextSuffix;
+
+ @Schema(title = "自定义Office后缀格式")
+ private String customOfficeSuffix;
+
+ @Schema(title = "自定义kkFileView后缀格式")
+ private String customKkFileViewSuffix;
+
+ @Schema(title = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showDocument;
+
+ @Schema(title = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showAnnouncement;
+
+ @Schema(title = "网站公告", example = "ZFile 网站公告")
+ private String announcement;
+
+ @Schema(title = "自定义 CSS")
+ private String customCss;
+
+ @Schema(title = "自定义 JS")
+ private String customJs;
+
+ @Schema(title = "默认文件点击习惯", example = "click")
+ private FileClickModeEnum fileClickMode;
+
+ @Schema(title = "移动端默认文件点击习惯", example = "click")
+ private FileClickModeEnum mobileFileClickMode;
+
+ @Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
+ private String onlyOfficeUrl;
+
+ @Schema(title = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed")
+ private String onlyOfficeSecret;
+
+ @Schema(title = "kkFileView 在线预览地址", example = "http://kkfile.zfile.vip")
+ private String kkFileViewUrl;
+
+ @Schema(title = "kkFileView 预览方式", example = "iframe/newTab")
+ private String kkFileViewOpenMode;
+
+ @Schema(title = "默认最大显示文件数", example = "1000")
+ private Integer maxShowSize;
+
+ @Schema(title = "每次加载更多文件数", example = "50")
+ private Integer loadMoreSize;
+
+ @Schema(title = "默认排序字段", example = "name")
+ private String defaultSortField;
+
+ @Schema(title = "默认排序方向", example = "asc")
+ private String defaultSortOrder;
+
+ @Schema(title = "是否默认记住密码", example = "true")
+ private Boolean defaultSavePwd;
+
+ @Schema(title = "普通下载是否启用确认弹窗", example = "true")
+ private Boolean enableNormalDownloadConfirm;
+
+ @Schema(title = "打包下载是否启用确认弹窗", example = "true")
+ private Boolean enablePackageDownloadConfirm;
+
+ @Schema(title = "批量下载是否启用确认弹窗", example = "true")
+ private Boolean enableBatchDownloadConfirm;
+
+ /**
+ * 废弃的字段,不再使用悬浮菜单
+ */
+ @Deprecated
+ @Schema(title = "是否启用 hover 菜单", example = "true")
+ private Boolean enableHoverMenu;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java b/src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java
new file mode 100644
index 0000000..7818ee8
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java
@@ -0,0 +1,171 @@
+package im.zhaojun.zfile.module.config.model.result;
+
+import im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO;
+import im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum;
+import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 全局站点设置响应类
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(title="全局站点设置响应类")
+public class FrontSiteConfigResult {
+
+ @Schema(title = "是否已初始化", example = "true")
+ private Boolean installed;
+
+ @Schema(title = "Debug 模式", example = "true", description ="开启 debug 模式后,可重置管理员密码")
+ private Boolean debugMode;
+
+ @Schema(title = "直链地址前缀", example = "true", description ="直链地址前缀, 如 http(s)://ip:port/${直链前缀}/path/filename")
+ private String directLinkPrefix;
+
+ @Schema(title = "站点名称", example = "ZFile Site Name")
+ private String siteName;
+
+ @Schema(title = "备案号", example = "冀ICP备12345678号-1")
+ private String icp;
+
+ @Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String layout;
+
+ @Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
+ private String mobileLayout;
+
+ @Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true")
+ private Boolean mobileShowSize;
+
+ @Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String tableSize;
+
+ @Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showLinkBtn;
+
+ @Schema(title = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showShortLink;
+
+ @Schema(title = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showPathLink;
+
+ @Schema(title = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showDocument;
+
+ @Schema(title = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean showAnnouncement;
+
+ @Schema(title = "网站公告", example = "ZFile 网站公告")
+ private String announcement;
+
+ @Schema(title = "自定义 JS")
+ private String customJs;
+
+ @Schema(title = "自定义 CSS")
+ private String customCss;
+
+ @Schema(title = "自定义视频文件后缀格式")
+ private String customVideoSuffix;
+
+ @Schema(title = "自定义图像文件后缀格式")
+ private String customImageSuffix;
+
+ @Schema(title = "自定义音频文件后缀格式")
+ private String customAudioSuffix;
+
+ @Schema(title = "自定义文本文件后缀格式")
+ private String customTextSuffix;
+
+ @Schema(title = "自定义Office后缀格式")
+ private String customOfficeSuffix;
+
+ @Schema(title = "自定义kkFileView后缀格式")
+ private String customKkFileViewSuffix;
+
+ @Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
+ private Boolean rootShowStorage;
+
+ @Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com")
+ private String forceBackendAddress;
+
+ @Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
+ private String frontDomain;
+
+ @Schema(title = "是否在前台显示登陆按钮", example = "true")
+ private Boolean showLogin;
+
+ @Schema(title = "登录日志模式", example = "all")
+ private LoginLogModeEnum loginLogMode;
+
+ @Schema(title = "默认文件点击习惯", example = "click")
+ private FileClickModeEnum fileClickMode;
+
+ @Schema(title = "移动端默认文件点击习惯", example = "click")
+ private FileClickModeEnum mobileFileClickMode;
+
+ @Schema(title = "最大同时上传文件数", example = "5")
+ private Integer maxFileUploads;
+
+ @Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
+ private String onlyOfficeUrl;
+
+ @Schema(title = "kkFileView 在线预览地址", example = "http://kkfile.zfile.vip")
+ private String kkFileViewUrl;
+
+ @Schema(title = "kkFileView 预览方式", example = "iframe/newTab")
+ private String kkFileViewOpenMode;
+
+ @Schema(title = "默认最大显示文件数", example = "1000")
+ private Integer maxShowSize;
+
+ @Schema(title = "每次加载更多文件数", example = "50")
+ private Integer loadMoreSize;
+
+ @Schema(title = "默认排序字段", example = "name")
+ private String defaultSortField;
+
+ @Schema(title = "默认排序方向", example = "asc")
+ private String defaultSortOrder;
+
+ @Schema(title = "站点 Home 名称", example = "xxx 的小站")
+ private String siteHomeName;
+
+ @Schema(title = "站点 Home Logo", example = "true")
+ private String siteHomeLogo;
+
+ @Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
+ private String siteHomeLogoLink;
+
+ @Schema(title = "站点 Logo 链接打开方式", example = "_blank")
+ private String siteHomeLogoTargetMode;
+
+ @Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
+ private List linkExpireTimes;
+
+ @Schema(title = "是否默认记住密码", example = "true")
+ private Boolean defaultSavePwd;
+
+ @Schema(title = "普通下载是否启用确认弹窗", example = "true")
+ private Boolean enableNormalDownloadConfirm;
+
+ @Schema(title = "打包下载是否启用确认弹窗", example = "true")
+ private Boolean enablePackageDownloadConfirm;
+
+ @Schema(title = "批量下载是否启用确认弹窗", example = "true")
+ private Boolean enableBatchDownloadConfirm;
+
+ /**
+ * 废弃的字段,不再使用悬浮菜单
+ */
+ @Deprecated
+ @Schema(title = "是否启用 hover 菜单", example = "true")
+ private Boolean enableHoverMenu;
+
+ @Schema(title = "是否是游客", example = "true")
+ private boolean guest;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java b/src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java
new file mode 100644
index 0000000..5c3aa34
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java
@@ -0,0 +1,381 @@
+package im.zhaojun.zfile.module.config.service;
+
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.convert.ConvertException;
+import cn.hutool.core.net.url.UrlBuilder;
+import cn.hutool.core.util.EnumUtil;
+import cn.hutool.core.util.HexUtil;
+import cn.hutool.core.util.ObjUtil;
+import cn.hutool.crypto.SecureUtil;
+import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import im.zhaojun.zfile.core.util.*;
+import im.zhaojun.zfile.module.config.annotation.JSONStringParse;
+import im.zhaojun.zfile.module.config.constant.SystemConfigConstant;
+import im.zhaojun.zfile.module.config.event.SystemConfigModifyHandlerChain;
+import im.zhaojun.zfile.module.config.mapper.SystemConfigMapper;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+import im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.aop.framework.AopContext;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.CacheConfig;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.lang.reflect.Field;
+import java.util.*;
+
+import static im.zhaojun.zfile.module.config.service.SystemConfigService.CACHE_NAME;
+
+
+/**
+ * 系统设置 Service
+ *
+ * @author zhaojun
+ */
+@Slf4j
+@Service
+@CacheConfig(cacheNames = CACHE_NAME)
+public class SystemConfigService {
+
+ public static final String CACHE_NAME = "systemConfig";
+
+ private static final String SERIAL_VERSION_UID_FIELD_NAME = "serialVersionUID";
+
+ @Resource
+ private SystemConfigMapper systemConfigMapper;
+
+ @Resource
+ private CacheManager cacheManager;
+
+ @Resource
+ private SystemConfigModifyHandlerChain systemConfigModifyHandlerChain;
+
+ private final Class systemConfigClazz = SystemConfigDTO.class;
+
+ public static final List ignoreFieldList = Arrays.asList("domain");
+
+ /**
+ * 获取系统设置, 如果缓存中有, 则去缓存取, 没有则查询数据库并写入到缓存中.
+ *
+ * @return 系统设置
+ */
+ @Cacheable(key = "'dto'")
+ public SystemConfigDTO getSystemConfig() {
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ List systemConfigList = systemConfigMapper.findAll();
+
+ for (SystemConfig systemConfig : systemConfigList) {
+ String key = systemConfig.getName();
+ if (ignoreFieldList.contains(key)) {
+ if (log.isTraceEnabled()) {
+ log.trace("从数据库加载字段填充到 DTO 时,忽略字段: {}", key);
+ }
+ continue;
+ }
+ try {
+ Field field = systemConfigClazz.getDeclaredField(key);
+ field.setAccessible(true);
+ String strVal = systemConfig.getValue();
+ Class> fieldType = field.getType();
+
+ Object convertVal;
+ if (EnumUtil.isEnum(fieldType)) {
+ convertVal = EnumConvertUtils.convertStrToEnum(fieldType, strVal);
+ } else if (field.isAnnotationPresent(JSONStringParse.class)) {
+ // 如果类是 Collection 类型, 则需要将 JSON 字符串转换为 List
+ if (Collection.class.isAssignableFrom(fieldType)) {
+ Class> genericType = ClassUtils.getGenericType(field);
+ convertVal = JSONArray.parseArray(strVal, genericType);
+ } else {
+ // 否则转换为普通对象
+ convertVal = JSONObject.parseObject(strVal, fieldType);
+ }
+ } else {
+ convertVal = Convert.convert(fieldType, strVal);
+ }
+ field.set(systemConfigDTO, convertVal);
+ } catch (NoSuchFieldException | IllegalAccessException | ConvertException e) {
+ log.error("通过反射, 将字段 {} 注入 SystemConfigDTO 时出现异常:", key, e);
+ }
+ }
+
+ return systemConfigDTO;
+ }
+
+
+ /**
+ * 更新系统设置, 并清空缓存中的内容.
+ *
+ * @param systemConfigDTO
+ * 系统设置 dto
+ */
+ @Transactional(rollbackFor = Exception.class)
+ @CacheEvict(allEntries = true)
+ public synchronized void updateSystemConfig(SystemConfigDTO systemConfigDTO) {
+ // 获取更新前的值
+ List systemConfigListInDb = systemConfigMapper.findAll();
+ Map systemConfigMapInDb = CollectionUtils.toMap(systemConfigListInDb, null, SystemConfig::getName);
+
+ // 存储更新后的值
+ List updateSystemConfigList = new ArrayList<>();
+
+ Field[] fields = systemConfigClazz.getDeclaredFields();
+ for (Field field : fields) {
+ // 获取数据库中的值对象
+ String key = field.getName();
+ if (SERIAL_VERSION_UID_FIELD_NAME.equals(key)) {
+ continue;
+ }
+ SystemConfig systemConfig = systemConfigMapInDb.get(key);
+ if (systemConfig != null) {
+ field.setAccessible(true);
+ Object val = null;
+
+ try {
+ val = field.get(systemConfigDTO);
+ } catch (IllegalAccessException e) {
+ log.error("通过反射, 从 SystemConfigDTO 获取字段 {} 时出现异常:", key, e);
+ }
+
+ if (val != null) {
+ // 如果是枚举类型, 则取 value 值.
+ if (EnumUtil.isEnum(val)) {
+ val = EnumConvertUtils.convertEnumToStr(val);
+ } else if (field.isAnnotationPresent(JSONStringParse.class)) {
+ val = JSONObject.toJSONString(val);
+ }
+ // 如果和原来的值一样, 则跳过
+ String originVal = systemConfig.getValue();
+ if (ObjUtil.equals(originVal, val)) {
+ continue;
+ }
+ // 将更新后的值存到更新列表中
+ SystemConfig updateSystemConfig = new SystemConfig();
+ updateSystemConfig.setId(systemConfig.getId());
+ updateSystemConfig.setName(systemConfig.getName());
+ updateSystemConfig.setValue(Convert.toStr(val));
+ updateSystemConfig.setTitle(systemConfig.getTitle());
+ updateSystemConfigList.add(updateSystemConfig);
+ }
+ } else {
+ log.warn("尝试保存系统配置表中不存在字段: {}", key);
+ }
+ }
+
+ updateSystemConfigList.forEach(systemConfigInForm -> {
+ SystemConfig systemConfigInDb = systemConfigMapInDb.get(systemConfigInForm.getName());
+ systemConfigModifyHandlerChain.execute(systemConfigInDb, systemConfigInForm);
+ systemConfigMapper.updateById(systemConfigInForm);
+ });
+ }
+
+
+ /**
+ * 获取 AES Hex 格式密钥
+ *
+ * @return AES Hex 格式密钥
+ */
+ public synchronized String getAesHexKeyOrGenerate() {
+ SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();
+ String aesHexKey = systemConfigDTO.getRsaHexKey();
+ if (StringUtils.isEmpty(aesHexKey)) {
+ byte[] key = SecureUtil.generateKey(SymmetricAlgorithm.AES.getValue()).getEncoded();
+ aesHexKey = HexUtil.encodeHexStr(key);
+
+ SystemConfig loginVerifyModeConfig = systemConfigMapper.findByName(SystemConfigConstant.AES_HEX_KEY);
+ loginVerifyModeConfig.setValue(aesHexKey);
+ systemConfigMapper.updateById(loginVerifyModeConfig);
+ systemConfigDTO.setRsaHexKey(aesHexKey);
+
+ Cache cache = cacheManager.getCache(CACHE_NAME);
+ Optional.ofNullable(cache).ifPresent(cache1 -> cache1.put("dto", systemConfigDTO));
+ }
+ return aesHexKey;
+ }
+
+
+ /**
+ * 获取前端站点域名
+ *
+ * @return 前端站点域名
+ */
+ public String getFrontDomain() {
+ SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();
+ return systemConfigDTO.getFrontDomain();
+ }
+
+
+ /**
+ * 获取实际的前端站点域名
+ *
+ * @return 实际的前端站点域名
+ */
+ public String getRealFrontDomain() {
+ SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();
+ return StringUtils.firstNonNull(systemConfigDTO.getFrontDomain(), getAxiosFromDomainOrSetting(), RequestHolder.getOriginAddress());
+ }
+
+
+ /**
+ * 优先级:
+ * 1. 如果设置了强制后端地址,则使用强制后端地址。
+ * 2. 如果请求中有 axios-from 参数,则使用该参数。
+ * 3. 如果没有强制后端地址和 axios-from 参数,则使用请求的服务器地址(如果经过多个代理,可能不是实际的后端地址)。
+ *
+ * @return 后端站点地址
+ */
+ public String getAxiosFromDomainOrSetting() {
+ SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();
+ if (StringUtils.isNotBlank(systemConfigDTO.getForceBackendAddress())) {
+ return systemConfigDTO.getForceBackendAddress();
+ } else if (StringUtils.isNotEmpty(RequestHolder.getAxiosFrom())) {
+ return RequestHolder.getAxiosFrom();
+ } else {
+ return RequestHolder.getRequestServerAddress();
+ }
+ }
+
+ /**
+ * 获取前端地址下的 401 页面地址.
+ *
+ * @return 前端地址下的 401 页面地址.
+ *
+ */
+ public String getUnauthorizedUrl() {
+ return getUnauthorizedUrl(null, null);
+ }
+
+ /**
+ * 获取前端地址下的 401 页面地址. 可以指定 code 和 message.
+ *
+ * @param code
+ * 指定错误码
+ *
+ * @param message
+ * 指定错误信息
+ *
+ * @return 前端地址下的 401 页面地址.
+ */
+ public String getUnauthorizedUrl(String code, String message) {
+ String url = StringUtils.concat(getRealFrontDomain(), "/401");
+ UrlBuilder urlBuilder = UrlBuilder.of(url);
+ if (StringUtils.isNotBlank(code)) {
+ urlBuilder.addQuery("code", code);
+ }
+ if (StringUtils.isNotBlank(message)) {
+ urlBuilder.addQuery("message", message);
+ }
+ return urlBuilder.build();
+ }
+
+ /**
+ * 获取前端地址下的 403 页面地址.
+ *
+ * @return 前端地址下的 403 页面地址.
+ *
+ */
+ public String getForbiddenUrl() {
+ return getForbiddenUrl(null, null);
+ }
+
+ /**
+ * 获取前端地址下的 403 页面地址. 可以指定 code 和 message.
+ *
+ * @param code
+ * 指定错误码
+ *
+ * @param message
+ * 指定错误信息
+ *
+ * @return 前端地址下的 403 页面地址.
+ */
+ public String getForbiddenUrl(String code, String message) {
+ String url = StringUtils.concat(getRealFrontDomain(), "/403");
+ UrlBuilder urlBuilder = UrlBuilder.of(url);
+ if (StringUtils.isNotBlank(code)) {
+ urlBuilder.addQuery("code", code);
+ }
+ if (StringUtils.isNotBlank(message)) {
+ urlBuilder.addQuery("message", message);
+ }
+ return urlBuilder.build();
+ }
+
+ /**
+ * 获取前端地址下的 404 页面地址.
+ *
+ * @return 前端地址下的 404 页面地址.
+ *
+ */
+ public String getNotFoundUrl() {
+ return getNotFoundUrl(null, null);
+ }
+
+ /**
+ * 获取前端地址下的 404 页面地址. 可以指定 code 和 message.
+ *
+ * @param code
+ * 指定错误码
+ *
+ * @param message
+ * 指定错误信息
+ *
+ * @return 前端地址下的 404 页面地址.
+ */
+ public String getNotFoundUrl(String code, String message) {
+ String url = StringUtils.concat(getRealFrontDomain(), "/404");
+ UrlBuilder urlBuilder = UrlBuilder.of(url);
+ if (StringUtils.isNotBlank(code)) {
+ urlBuilder.addQuery("code", code);
+ }
+ if (StringUtils.isNotBlank(message)) {
+ urlBuilder.addQuery("message", message);
+ }
+ return urlBuilder.build();
+ }
+
+ /**
+ * 获取前端地址下的 500 页面地址. 可以指定 code 和 message.
+ *
+ * @param code
+ * 指定错误码
+ *
+ * @param message
+ * 指定错误信息
+ *
+ * @return 前端地址下的 500 页面地址.
+ */
+ public String getErrorPageUrl(String code, String message) {
+ String url = StringUtils.concat(getRealFrontDomain(), "/500");
+ UrlBuilder urlBuilder = UrlBuilder.of(url);
+ if (StringUtils.isNotBlank(code)) {
+ urlBuilder.addQuery("code", code);
+ }
+ if (StringUtils.isNotBlank(message)) {
+ urlBuilder.addQuery("message", message);
+ }
+ return urlBuilder.build();
+ }
+
+
+ /**
+ * 重置登录验证模式,去除所有登录额外验证方式.
+ */
+ public void resetLoginVerifyMode() {
+ SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();
+ systemConfigDTO.setLoginImgVerify(false);
+ systemConfigDTO.setAdminTwoFactorVerify(false);
+ systemConfigDTO.setLoginVerifySecret("");
+ systemConfigDTO.setLoginVerifyMode(LoginVerifyModeEnum.OFF_MODE);
+ ((SystemConfigService)AopContext.currentProxy()).updateSystemConfig(systemConfigDTO);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java b/src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java
new file mode 100644
index 0000000..e922fcb
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java
@@ -0,0 +1,52 @@
+package im.zhaojun.zfile.module.filter.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.annotation.DemoDisable;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.module.filter.model.entity.FilterConfig;
+import im.zhaojun.zfile.module.filter.service.FilterConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 存储源过滤器维护接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "存储源模块-过滤文件")
+@ApiSort(6)
+@RestController
+@RequestMapping("/admin")
+public class StorageSourceFilterController {
+
+ @Resource
+ private FilterConfigService filterConfigService;
+
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "获取存储源过滤文件列表", description ="根据存储源 ID 获取存储源设置的过滤文件列表")
+ @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer"))
+ @GetMapping("/storage/{storageId}/filters")
+ public AjaxJson> getFilters(@PathVariable Integer storageId) {
+ return AjaxJson.getSuccessData(filterConfigService.findByStorageId(storageId));
+ }
+
+
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "保存存储源过滤文件列表", description ="保存指定存储源 ID 设置的过滤文件列表")
+ @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer"))
+ @PostMapping("/storage/{storageId}/filters")
+ @DemoDisable
+ public AjaxJson saveFilters(@PathVariable Integer storageId, @RequestBody List filter) {
+ filterConfigService.batchSave(storageId, filter);
+ return AjaxJson.getSuccess();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java
new file mode 100644
index 0000000..26801b4
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java
@@ -0,0 +1,61 @@
+package im.zhaojun.zfile.module.filter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import im.zhaojun.zfile.module.filter.model.entity.FilterConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 过滤器配置表 Mapper 接口
+ *
+ * @author zhaojun
+ */
+@Mapper
+public interface FilterConfigMapper extends BaseMapper {
+
+ /**
+ * 根据存储源 ID 获取存储源配置列表
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @return 存储源过滤器配置列表
+ */
+ List findByStorageId(@Param("storageId") Integer storageId);
+
+
+ /**
+ * 根据存储源 ID 删除过滤器配置
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @return 删除条数
+ */
+ int deleteByStorageId(@Param("storageId") Integer storageId);
+
+
+ /**
+ * 获取所有类型为禁止访问的过滤规则
+ *
+ * @param storageId
+ * 存储 ID
+ *
+ * @return 禁止访问的过滤规则列表
+ */
+ List findByStorageIdAndInaccessible(@Param("storageId")Integer storageId);
+
+
+ /**
+ * 获取所有类型为禁止下载的过滤规则
+ *
+ * @param storageId
+ * 存储 ID
+ *
+ * @return 禁止下载的过滤规则列表
+ */
+ List findByStorageIdAndDisableDownload(@Param("storageId")Integer storageId);
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java b/src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java
new file mode 100644
index 0000000..301e9d0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java
@@ -0,0 +1,50 @@
+package im.zhaojun.zfile.module.filter.model.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import im.zhaojun.zfile.module.filter.model.enums.FilterConfigHiddenModeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 存储源过滤配置 entity
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(title="存储源过滤配置")
+@TableName(value = "filter_config")
+public class FilterConfig implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.AUTO)
+ @Schema(title = "ID, 新增无需填写", example = "1")
+ private Integer id;
+
+
+ @TableField(value = "storage_id")
+ @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer storageId;
+
+
+ @TableField(value = "expression")
+ @Schema(title = "过滤表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png")
+ private String expression;
+
+
+ @TableField(value = "description")
+ @Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
+ private String description;
+
+
+ @TableField(value = "mode")
+ @Schema(title = "模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "隐藏模式,仅隐藏: hidden, 隐藏后不可访问: inaccessible, 隐藏后不可下载: disable_download")
+ private FilterConfigHiddenModeEnum mode;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java b/src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java
new file mode 100644
index 0000000..97d50a1
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java
@@ -0,0 +1,36 @@
+package im.zhaojun.zfile.module.filter.model.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文件夹隐藏模式枚举
+ *
+ * @author zhaojun
+ */
+@Getter
+@AllArgsConstructor
+public enum FilterConfigHiddenModeEnum {
+
+ /**
+ * 仅隐藏
+ */
+ HIDDEN("hidden"),
+
+ /**
+ * 隐藏并不可访问 (针对目录)
+ */
+ INACCESSIBLE("inaccessible"),
+
+ /**
+ * 隐藏并不可访问 (针对文件)
+ */
+ DISABLE_DOWNLOAD("disable_download");
+
+ @EnumValue
+ @JsonValue
+ private final String value;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java b/src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java
new file mode 100644
index 0000000..7a668d1
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java
@@ -0,0 +1,291 @@
+package im.zhaojun.zfile.module.filter.service;
+
+import im.zhaojun.zfile.core.util.CollectionUtils;
+import im.zhaojun.zfile.core.util.FileUtils;
+import im.zhaojun.zfile.core.util.PatternMatcherUtils;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.filter.mapper.FilterConfigMapper;
+import im.zhaojun.zfile.module.filter.model.entity.FilterConfig;
+import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;
+import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;
+import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
+import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.aop.framework.AopContext;
+import org.springframework.beans.BeanUtils;
+import org.springframework.cache.annotation.CacheConfig;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cache.annotation.Caching;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 存储源过滤规则 Service
+ *
+ * @author zhaojun
+ */
+@Slf4j
+@Service
+@CacheConfig(cacheNames = "filterConfig")
+public class FilterConfigService {
+
+ @Resource
+ private FilterConfigMapper filterConfigMapper;
+
+ @Resource
+ private UserStorageSourceService userStorageSourceService;
+
+ /**
+ * 根据存储源 ID 获取存储源配置列表
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @return 存储源过滤规则配置列表
+ */
+ @Cacheable(key = "'filter-base-' + #storageId",
+ condition = "#storageId != null")
+ public List findByStorageId(Integer storageId) {
+ return filterConfigMapper.findByStorageId(storageId);
+ }
+
+
+ /**
+ * 获取所有类型为禁止访问的过滤规则
+ *
+ * @param storageId
+ * 存储 ID
+ *
+ * @return 禁止访问的过滤规则列表
+ */
+ @Cacheable(key = "'filter-inaccessible-' + #storageId",
+ condition = "#storageId != null")
+ public List findByStorageIdAndInaccessible(Integer storageId) {
+ return filterConfigMapper.findByStorageIdAndInaccessible(storageId);
+ }
+
+
+ /**
+ * 获取所有类型为禁止下载的过滤规则
+ *
+ * @param storageId
+ * 存储 ID
+ *
+ * @return 禁止下载的过滤规则列表
+ */
+ @Cacheable(key = "'filter-disable-download-' + #storageId",
+ condition = "#storageId != null")
+ public List findByStorageIdAndDisableDownload(Integer storageId) {
+ return filterConfigMapper.findByStorageIdAndDisableDownload(storageId);
+ }
+
+
+ /**
+ * 批量保存存储源过滤规则配置, 会先删除之前的所有配置(在事务中运行)
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param filterConfigList
+ * 存储源过滤规则配置列表
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void batchSave(Integer storageId, List filterConfigList) {
+ ((FilterConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);
+ log.info("更新存储源 ID 为 {} 的过滤规则 {} 条", storageId, filterConfigList.size());
+
+ for (FilterConfig filterConfig : filterConfigList) {
+ filterConfig.setId(null);
+ filterConfig.setStorageId(storageId);
+ filterConfigMapper.insert(filterConfig);
+
+ if (log.isDebugEnabled()) {
+ log.debug("新增过滤规则, 存储源 ID: {}, 表达式: {}, 描述: {}, 隐藏模式: {}",
+ filterConfig.getStorageId(), filterConfig.getExpression(),
+ filterConfig.getDescription(), filterConfig.getMode().getValue());
+ }
+ }
+ }
+
+
+ /**
+ * 根据存储源 ID 删除所有过滤规则配置
+ *
+ * @param storageId
+ * 存储源 ID
+ */
+ @Caching(evict = {
+ @CacheEvict(key = "'filter-base-' + #storageId"),
+ @CacheEvict(key = "'filter-inaccessible-' + #storageId"),
+ @CacheEvict(key = "'filter-disable-download-' + #storageId")
+ })
+ public int deleteByStorageId(Integer storageId) {
+ int deleteSize = filterConfigMapper.deleteByStorageId(storageId);
+ log.info("删除存储源 ID 为 {} 的过滤规则 {} 条", storageId, deleteSize);
+ return deleteSize;
+ }
+
+ /**
+ * 监听存储源删除事件,根据存储源 id 删除相关的过滤条件设置
+ *
+ * @param storageSourceDeleteEvent
+ * 存储源删除事件
+ */
+ @EventListener
+ public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {
+ Integer storageId = storageSourceDeleteEvent.getId();
+ int updateRows = ((FilterConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);
+ if (log.isDebugEnabled()) {
+ log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源过滤条件设置 {} 条",
+ storageId,
+ storageSourceDeleteEvent.getName(),
+ storageSourceDeleteEvent.getType().getDescription(),
+ updateRows);
+ }
+
+ }
+
+ /**
+ * 判断访问的路径是否是不允许访问的
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param path
+ * 请求路径
+ *
+ */
+ public boolean checkFileIsInaccessible(Integer storageId, String path) {
+ List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageIdAndInaccessible(storageId);
+ return testPattern(storageId, filterConfigList, path);
+ }
+
+
+ /**
+ * 指定存储源下的文件名称, 根据过滤表达式判断是否会显示, 如果符合任意一条表达式, 表示隐藏则返回 true, 反之表示不隐藏则返回 false.
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param fileName
+ * 文件名
+ *
+ * @return 是否是隐藏文件夹
+ */
+ public boolean checkFileIsHidden(Integer storageId, String fileName) {
+ List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageId(storageId);
+ return testPattern(storageId, filterConfigList, fileName);
+ }
+
+
+ /**
+ * 指定存储源下的文件名称, 根据过滤表达式判断文件名和所在路径是否禁止下载, 如果符合任意一条表达式, 则返回 true, 反之则返回 false.
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param fileName
+ * 文件名
+ *
+ * @return 是否显示
+ */
+ public boolean checkFileIsDisableDownload(Integer storageId, String fileName) {
+ List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageIdAndDisableDownload(storageId);
+ String filePath = FileUtils.getParentPath(fileName);
+ if (StringUtils.isEmpty(filePath)) {
+ return testPattern(storageId, filterConfigList, fileName);
+ } else {
+ return testPattern(storageId, filterConfigList, fileName) || testPattern(storageId, filterConfigList, filePath);
+ }
+ }
+
+
+ /**
+ * 根据规则表达式和测试字符串进行匹配,如测试字符串和其中一个规则匹配上,则返回 true,反之返回 false。
+ *
+ * @param patternList
+ * 规则列表
+ *
+ * @param test
+ *
+ * 测试字符串
+ *
+ * @return 是否显示
+ */
+ private boolean testPattern(Integer storageId, List patternList, String test) {
+ // 如果规则列表为空, 则表示不需要过滤, 直接返回 false
+ if (CollectionUtils.isEmpty(patternList)) {
+ if (log.isDebugEnabled()) {
+ log.debug("过滤规则列表为空, 存储源 ID: {}, 测试字符串: {}", storageId, test);
+ }
+ return false;
+ }
+
+ // 判断是否需要忽略文件隐藏校验
+ boolean isIgnoreHidden = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.IGNORE_HIDDEN);
+ if (isIgnoreHidden) {
+ if (log.isDebugEnabled()) {
+ log.debug("权限配置忽略过滤规则, 存储源 ID: {}, 测试字符串: {}", storageId, test);
+ }
+ return false;
+ }
+
+ // 校验表达式
+ for (FilterConfig filterConfig : patternList) {
+ String expression = filterConfig.getExpression();
+
+ if (StringUtils.isEmpty(expression)) {
+ if (log.isDebugEnabled()) {
+ log.debug("存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 表达式为空,跳过该规则校验", storageId, expression, test);
+ }
+ continue;
+ }
+
+ try {
+ boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, test);
+
+ if (log.isDebugEnabled()) {
+ log.debug("存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 匹配结果: {}", storageId, expression, test, match);
+ }
+
+ if (match) {
+ return true;
+ }
+ } catch (Exception e) {
+ log.error("存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 匹配异常,跳过该规则.", storageId, expression, test, e);
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 监听存储源复制事件, 复制存储源的过滤条件设置到新的存储源
+ *
+ * @param storageSourceCopyEvent
+ * 存储源复制事件
+ */
+ @EventListener
+ public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {
+ Integer fromId = storageSourceCopyEvent.getFromId();
+ Integer newId = storageSourceCopyEvent.getNewId();
+
+ List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageId(fromId);
+
+ filterConfigList.forEach(filterConfig -> {
+ FilterConfig newFilterConfig = new FilterConfig();
+ BeanUtils.copyProperties(filterConfig, newFilterConfig);
+ newFilterConfig.setId(null);
+ newFilterConfig.setStorageId(newId);
+ filterConfigMapper.insert(newFilterConfig);
+ });
+
+ log.info("复制存储源 ID 为 {} 的存储源过滤条件设置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, filterConfigList.size());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java b/src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java
new file mode 100644
index 0000000..8291ecc
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java
@@ -0,0 +1,42 @@
+package im.zhaojun.zfile.module.install.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import im.zhaojun.zfile.core.annotation.DemoDisable;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.module.install.model.request.InstallSystemRequest;
+import im.zhaojun.zfile.module.install.service.InstallService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 系统初始化接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "初始化模块")
+@RestController
+@RequestMapping("/api")
+public class InstallController {
+
+ @Resource
+ private InstallService installService;
+
+ @GetMapping("/install/status")
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "获取系统初始化状态", description = "根据管理员用户名是否存在判断系统已初始化, 已初始化返回 true, 未初始化返回 false")
+ public AjaxJson isInstall() {
+ return AjaxJson.getSuccessData(installService.getSystemIsInstalled());
+ }
+
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "初始化系统", description = "根据管理员用户名是否存在判断系统已初始化, 已初始化返回 true, 未初始化返回 false")
+ @PostMapping("/install")
+ @DemoDisable
+ public AjaxJson install(@RequestBody InstallSystemRequest installSystemRequest) {
+ installService.install(installSystemRequest);
+ return AjaxJson.getSuccess();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java b/src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java
new file mode 100644
index 0000000..5abe710
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java
@@ -0,0 +1,24 @@
+package im.zhaojun.zfile.module.install.model.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 系统初始化请求参数
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "系统初始化请求类")
+public class InstallSystemRequest {
+
+ @Schema(title = "站点名称", example = "ZFile Site Name")
+ private String siteName;
+
+ @Schema(title = "用户名", example = "admin")
+ private String username;
+
+ @Schema(title = "密码", example = "123456")
+ private String password;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java b/src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java
new file mode 100644
index 0000000..d89de95
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java
@@ -0,0 +1,52 @@
+package im.zhaojun.zfile.module.install.service;
+
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.core.exception.core.SystemException;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.install.model.request.InstallSystemRequest;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.user.service.UserService;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Slf4j
+public class InstallService {
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private UserService userService;
+
+ @Transactional(rollbackFor = Exception.class)
+ public void install(InstallSystemRequest installSystemRequest) {
+ if (getSystemIsInstalled()) {
+ throw new BizException(ErrorCode.BIZ_SYSTEM_ALREADY_INIT);
+ }
+
+ boolean updateFlag = userService.initAdminUser(installSystemRequest.getUsername(),
+ installSystemRequest.getPassword());
+ if (!updateFlag) {
+ throw new SystemException(ErrorCode.BIZ_SYSTEM_INIT_ERROR);
+ }
+
+ SystemConfigDTO systemConfigDTO = new SystemConfigDTO();
+ systemConfigDTO.setSiteName(installSystemRequest.getSiteName());
+ systemConfigDTO.setInstalled(true);
+ systemConfigService.updateSystemConfig(systemConfigDTO);
+ }
+
+ /**
+ * 获取系统是否已初始化
+ *
+ * @return 管理员名称
+ */
+ public Boolean getSystemIsInstalled() {
+ return systemConfigService.getSystemConfig().getInstalled();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java b/src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java
new file mode 100644
index 0000000..7883dfe
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java
@@ -0,0 +1,72 @@
+package im.zhaojun.zfile.module.link.aspect;
+
+import cn.hutool.extra.servlet.JakartaServletUtil;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache;
+import im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 校验直链访问频率.
+ *
+ * 校验所有标注了 {@link LinkRateLimiter} 的注解
+ *
+ * @author zhaojun
+ */
+@Aspect
+@Component
+@Slf4j
+public class LinkRateLimiterAspect {
+
+ @Resource
+ private HttpServletRequest httpServletRequest;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private LinkRateLimiterCache linkRateLimiterCache;
+
+ /**
+ * 校验直链访问频率.
+ *
+ * @param point
+ * 连接点
+ *
+ * @return 方法运行结果
+ */
+ @Around(value = "@annotation(im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter)")
+ public Object around(ProceedingJoinPoint point) throws Throwable {
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+ Integer linkLimitSecond = systemConfig.getLinkLimitSecond();
+ Integer linkDownloadLimit = systemConfig.getLinkDownloadLimit();
+ // 如果未设置直链限制, 则不进行校验
+ if (linkLimitSecond == null || linkDownloadLimit == null || linkLimitSecond == 0 || linkDownloadLimit == 0) {
+ return point.proceed();
+ }
+
+ String clientIP = JakartaServletUtil.getClientIP(httpServletRequest);
+ if (linkRateLimiterCache.containsKey(clientIP)) {
+ AtomicInteger atomicInteger = linkRateLimiterCache.get(clientIP, false);
+ if (atomicInteger.incrementAndGet() > linkDownloadLimit) {
+ throw new BizException(ErrorCode.BIZ_ACCESS_TOO_FREQUENT);
+ }
+ } else {
+ linkRateLimiterCache.put(clientIP, new AtomicInteger(1), linkLimitSecond * 1000);
+ }
+
+ return point.proceed();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java b/src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java
new file mode 100644
index 0000000..e7e8938
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java
@@ -0,0 +1,129 @@
+package im.zhaojun.zfile.module.link.aspect;
+
+import im.zhaojun.zfile.core.util.CollectionUtils;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum;
+import im.zhaojun.zfile.module.storage.annotation.RefererCheck;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.http.HttpHeaders;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 校验 referer 防盗链.
+ *
+ * 校验所有标注了 {@link RefererCheck} 的注解
+ *
+ * @author zhaojun
+ */
+@Aspect
+@Component
+@Slf4j
+public class RefererCheckAspect {
+
+ @Resource
+ private HttpServletRequest httpServletRequest;
+
+ @Resource
+ private HttpServletResponse httpServletResponse;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+ /**
+ * 校验 referer 防盗链.
+ *
+ * @param point
+ * 连接点
+ *
+ * @return 方法运行结果
+ */
+ @Around(value = "@annotation(im.zhaojun.zfile.module.storage.annotation.RefererCheck)")
+ public Object around(ProceedingJoinPoint point) throws Throwable {
+ // 获取配置的 referer 类型
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+ RefererTypeEnum refererType = systemConfig.getRefererType();
+
+ // 如果未开启 referer 防盗链则跳过.
+ if (refererType == RefererTypeEnum.OFF) {
+ return point.proceed();
+ }
+
+ // 获取当前请求 referer
+ String referer = httpServletRequest.getHeader(HttpHeaders.REFERER);
+ String requestUrl = httpServletRequest.getRequestURI();
+
+ // 获取 Forbidden 页面地址
+ String forbiddenUrl = systemConfigService.getForbiddenUrl();
+
+ // 如果 referer 不允许为空,且当前 referer 为空,则校验
+ Boolean refererAllowEmpty = systemConfig.getRefererAllowEmpty();
+ if (!refererAllowEmpty && StringUtils.isEmpty(referer)) {
+ log.warn("请求路径 {}, referer 不允许为空,当前请求 referer 为空,禁止访问.", requestUrl);
+ httpServletResponse.sendRedirect(forbiddenUrl);
+ return null;
+ } else if (refererAllowEmpty && StringUtils.isEmpty(referer)) { // 如果 referer 允许为空,且当前 referer 为空,则跳过校验
+ return point.proceed();
+ }
+
+ // 获取允许的 referer 地址
+ String refererValue = systemConfig.getRefererValue();
+ List refererValueList = StringUtils.split(refererValue, StringUtils.LF);
+
+ // 如果是白名单模式,则校验当前 referer, 如果未在允许的列表中,则禁止访问.
+ if (refererType == RefererTypeEnum.WHITE_LIST && containsPathMatcher(refererValueList, referer) == null) {
+ log.warn("请求路径 {}, referer 为白名单模式,当前请求 referer {} 未在白名单中,禁止访问.", requestUrl, referer);
+ httpServletResponse.sendRedirect(forbiddenUrl);
+ return null;
+ }
+
+ // 如果是黑名单模式,则校验当前 referer 是否在列表中,则禁止访问.
+ if (refererType == RefererTypeEnum.BLACK_LIST && containsPathMatcher(refererValueList, referer) != null) {
+ log.warn("请求路径 {}, referer 为黑名单模式,当前请求 referer {} 在黑名单中,禁止访问.", requestUrl, referer);
+
+ httpServletResponse.sendRedirect(forbiddenUrl);
+ return null;
+ }
+
+ return point.proceed();
+ }
+
+ /**
+ * 校验 value 是否在 Ant 表达式列表中.
+ *
+ * @param patternList
+ * Ant 表达式列表
+ *
+ * @param value
+ * 要校验的值
+ *
+ * @return 返回匹配到的规则项,如果没有匹配到,则返回 null.
+ */
+ public String containsPathMatcher(Collection patternList, String value) {
+ if (CollectionUtils.isEmpty(patternList)) {
+ return null;
+ }
+
+ for (String pattern : patternList) {
+ if (pathMatcher.match(pattern, value)) {
+ return pattern;
+ }
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java b/src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java
new file mode 100644
index 0000000..8adc2cc
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java
@@ -0,0 +1,55 @@
+package im.zhaojun.zfile.module.link.cache;
+
+import cn.hutool.cache.impl.CacheObj;
+import cn.hutool.cache.impl.TimedCache;
+import im.zhaojun.zfile.module.link.model.dto.CacheInfo;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 直链/短链访问频率限制缓存
+ */
+@Service
+public class LinkRateLimiterCache {
+
+ /**
+ * cache 在 put 时不指定 timeout, 则使用默认的 timeout. (单位: 毫秒)
+ */
+ public static final Integer DEFAULT_TIME_OUT = 60_000;
+
+ private final TimedCache timedCache = new TimedCache<>(DEFAULT_TIME_OUT);
+
+ public boolean containsKey(String key) {
+ return timedCache.containsKey(key);
+ }
+
+ public AtomicInteger get(String key, boolean isUpdateLastAccess) {
+ return timedCache.get(key, isUpdateLastAccess);
+ }
+
+ public void put(String key, AtomicInteger object, long timeout) {
+ timedCache.put(key, object, timeout);
+ }
+
+ public List> getCacheInfo() {
+ List> cacheInfoList = new ArrayList<>();
+
+ Iterator> cacheObjIterator = timedCache.cacheObjIterator();
+ while (cacheObjIterator.hasNext()) {
+ CacheObj next = cacheObjIterator.next();
+ CacheInfo cacheInfo = new CacheInfo<>();
+ cacheInfo.setKey(next.getKey());
+ cacheInfo.setValue(next.getValue());
+ cacheInfo.setTtl(next.getTtl());
+ cacheInfo.setExpiredTime(next.getExpiredTime());
+ cacheInfoList.add(cacheInfo);
+ }
+
+ return cacheInfoList;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java b/src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java
new file mode 100644
index 0000000..849cb62
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java
@@ -0,0 +1,115 @@
+package im.zhaojun.zfile.module.link.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.SpringMvcUtils;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest;
+import im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse;
+import im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService;
+import im.zhaojun.zfile.module.link.service.LinkDownloadService;
+import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;
+import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
+import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
+import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 短链接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "短链")
+@ApiSort(5)
+@Controller
+@Slf4j
+public class DirectLinkController {
+
+ @Resource
+ private LinkDownloadService linkDownloadService;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private DynamicDirectLinkPrefixService dynamicDirectLinkPrefixService;
+
+ public static final String DIRECT_LINK_SUFFIX_PATH = "/{storageKey}/**";
+
+ @EventListener(ApplicationReadyEvent.class)
+ public void init() throws NoSuchMethodException {
+ String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix();
+ Method directLinkMethod = DirectLinkController.class.getMethod("directLink", String.class);
+ RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(directLinkPrefix + DIRECT_LINK_SUFFIX_PATH).build();
+ dynamicDirectLinkPrefixService.registerMappingHandlerMapping(SystemConfig.DIRECT_LINK_PREFIX_NAME, requestMappingInfo, this, directLinkMethod);
+ }
+
+ /**
+ * 路径直链处理方法,会根据 URL 中的存储源 key 和文件路径, 获取到文件,判断文件是否有短链,没有则生成,然后跳转到短链.
+ *
+ * @param storageKey
+ * 存储源 key
+ */
+ public ResponseEntity> directLink(@PathVariable("storageKey") String storageKey) throws IOException {
+ // 获取直链全路径
+ String filePath = SpringMvcUtils.getExtractPathWithinPattern();
+
+ // 如果路径不是以 / 开头, 则补充上
+ if (StringUtils.isNotEmpty(filePath) && filePath.charAt(0) != StringUtils.SLASH_CHAR) {
+ filePath = StringUtils.SLASH + filePath;
+ }
+
+ return linkDownloadService.handlerDirectLink(storageKey, filePath);
+ }
+
+ @PostMapping("/api/path-link/batch/generate")
+ @ResponseBody
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "生成路径直链", description ="对指定存储源的某文件路径生成路径直链")
+ @StoragePermissionCheck(action = FileOperatorTypeEnum.LINK)
+ public AjaxJson> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) {
+ List result = new ArrayList<>();
+
+ // 获取站点域名
+ String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();
+ String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix();
+
+ String storageKey = batchGenerateLinkRequest.getStorageKey();
+
+ AbstractBaseFileService> baseFileService = StorageSourceContext.getByStorageKey(storageKey);
+ if (baseFileService == null) {
+ throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
+ }
+ String currentUserBasePath = baseFileService.getCurrentUserBasePath();
+
+ for (String path : batchGenerateLinkRequest.getPaths()) {
+ // 拼接全路径地址.
+ String fullPath = StringUtils.concat(serverAddress, directLinkPrefix, storageKey, currentUserBasePath, path);
+ result.add(new BatchGenerateLinkResponse(fullPath));
+ }
+ return AjaxJson.getSuccessData(result);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java
new file mode 100644
index 0000000..49cea62
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java
@@ -0,0 +1,90 @@
+package im.zhaojun.zfile.module.link.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.model.entity.ShortLink;
+import im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest;
+import im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse;
+import im.zhaojun.zfile.module.link.service.LinkDownloadService;
+import im.zhaojun.zfile.module.link.service.ShortLinkService;
+import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;
+import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
+import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
+import im.zhaojun.zfile.module.storage.service.StorageSourceService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 短链接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "直短链模块")
+@ApiSort(5)
+@Controller
+@Slf4j
+public class ShortLinkController {
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private ShortLinkService shortLinkService;
+
+ @Resource
+ private StorageSourceService storageSourceService;
+
+ @Resource
+ private LinkDownloadService linkDownloadService;
+
+ @PostMapping("/api/short-link/batch/generate")
+ @ResponseBody
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "生成短链", description ="对指定存储源的某文件路径生成短链")
+ @StoragePermissionCheck(action = FileOperatorTypeEnum.SHORT_LINK)
+ public AjaxJson> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) {
+ List result = new ArrayList<>();
+
+ // 获取站点域名
+ String domain = systemConfigService.getAxiosFromDomainOrSetting();
+ Long expireTime = batchGenerateLinkRequest.getExpireTime();
+ String storageKey = batchGenerateLinkRequest.getStorageKey();
+ Integer storageId = storageSourceService.findIdByKey(storageKey);
+
+ for (String path : batchGenerateLinkRequest.getPaths()) {
+ // 拼接全路径地址.
+ String currentUserBasePath = StorageSourceContext.getByStorageId(storageId).getCurrentUserBasePath();
+ String fullPath = StringUtils.concat(currentUserBasePath, path);
+ ShortLink shortLink = shortLinkService.generatorShortLink(storageId, fullPath, expireTime);
+ String shortUrl = StringUtils.removeDuplicateSlashes(domain + "/s/" + shortLink.getShortKey());
+ result.add(new BatchGenerateLinkResponse(shortUrl));
+ }
+ return AjaxJson.getSuccessData(result);
+ }
+
+
+ @GetMapping("/s/{key}")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "跳转短链", description ="根据短链 key 跳转(302 重定向)到对应的直链.")
+ @Parameter(in = ParameterIn.PATH, name = "key", description = "短链 key", required = true, schema = @Schema(type = "string"))
+ public ResponseEntity> parseShortKey(@PathVariable String key) throws IOException {
+ return linkDownloadService.handlerShortLink(key);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java
new file mode 100644
index 0000000..21390d8
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java
@@ -0,0 +1,202 @@
+package im.zhaojun.zfile.module.link.controller;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.ObjUtil;
+import cn.hutool.poi.excel.ExcelUtil;
+import cn.hutool.poi.excel.ExcelWriter;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.OrderItem;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.annotation.DemoDisable;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache;
+import im.zhaojun.zfile.module.link.convert.ShortLinkConvert;
+import im.zhaojun.zfile.module.link.model.dto.CacheInfo;
+import im.zhaojun.zfile.module.link.model.entity.ShortLink;
+import im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest;
+import im.zhaojun.zfile.module.link.model.request.QueryShortLinkLogRequest;
+import im.zhaojun.zfile.module.link.model.request.ShortLinkResult;
+import im.zhaojun.zfile.module.link.service.ShortLinkService;
+import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
+import im.zhaojun.zfile.module.storage.service.StorageSourceService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * 直链管理接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "直链管理")
+@ApiSort(7)
+@Controller
+@RequestMapping("/admin")
+public class ShortLinkManagerController {
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private ShortLinkService shortLinkService;
+
+ @Resource
+ private StorageSourceService storageSourceService;
+
+ @Resource
+ private ShortLinkConvert shortLinkConvert;
+
+ @Resource
+ private LinkRateLimiterCache linkRateLimiterCache;
+
+
+ @ApiOperationSupport(order = 1)
+ @GetMapping("/link/list")
+ @Operation(summary = "搜索短链")
+ @ResponseBody
+ public AjaxJson> list(QueryShortLinkLogRequest queryObj) {
+ Page resultPage = getShortLinkResultPage(queryObj);
+
+ String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();
+
+ resultPage.getRecords().forEach(shortLinkResult -> {
+ shortLinkResult.setShortLink(StringUtils.concat(serverAddress, "s", shortLinkResult.getShortKey()));
+ });
+
+ return AjaxJson.getPageData(resultPage.getTotal(), resultPage.getRecords());
+ }
+
+ @ApiOperationSupport(order = 2)
+ @DeleteMapping("/link/delete/{id}")
+ @Operation(summary = "删除短链")
+ @Parameter(in = ParameterIn.PATH, name = "id", description = "短链 id", required = true, schema = @Schema(type = "integer"))
+ @ResponseBody
+ @DemoDisable
+ public AjaxJson deleteById(@PathVariable Integer id) {
+ shortLinkService.removeById(id);
+ return AjaxJson.getSuccess();
+ }
+
+
+ @ApiOperationSupport(order = 3)
+ @PostMapping("/link/delete/batch")
+ @ResponseBody
+ @Operation(summary = "批量删除短链")
+ @DemoDisable
+ public AjaxJson batchDelete(@RequestBody BatchDeleteRequest batchDeleteRequest) {
+ shortLinkService.removeBatchByIds(batchDeleteRequest.getIds());
+ return AjaxJson.getSuccess();
+ }
+
+ @ApiOperationSupport(order = 4)
+ @GetMapping("/link/export")
+ @ResponseBody
+ @Operation(summary = "导出短链")
+ public void exportExcel(QueryShortLinkLogRequest queryObj, HttpServletResponse response) throws IOException {
+ Page shortLinkResultPage = getShortLinkResultPage(queryObj);
+
+ ExcelWriter writer = ExcelUtil.getWriter(true);
+ writer.addHeaderAlias("id", "ID");
+ writer.addHeaderAlias("storageName", "存储源名称");
+ writer.addHeaderAlias("storageTypeStr", "存储源类型");
+ writer.addHeaderAlias("shortKey", "短链 key");
+ writer.addHeaderAlias("url", "文件路径");
+ writer.addHeaderAlias("createDate", "创建时间");
+ writer.addHeaderAlias("expireDate", "过期时间");
+ writer.setOnlyAlias(true);
+
+ writer.write(shortLinkResultPage.getRecords(), true);
+ writer.setColumnWidth(0, 8);
+ writer.setColumnWidth(1, 30);
+ writer.setColumnWidth(2, 15);
+ writer.setColumnWidth(3, 15);
+ writer.setColumnWidth(4, 50);
+ writer.setColumnWidth(5, 15);
+ writer.setColumnWidth(6, 15);
+
+ response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+
+ ServletOutputStream out=response.getOutputStream();
+
+ writer.flush(out, true);
+ writer.close();
+ IoUtil.close(out);
+ }
+
+
+ @ApiOperationSupport(order = 5)
+ @GetMapping("/link/limit/info")
+ @ResponseBody
+ @Operation(summary = "获取直链访问限制信息")
+ public AjaxJson>> getLinkLimitInfo() {
+ return AjaxJson.getSuccessData(linkRateLimiterCache.getCacheInfo());
+ }
+
+ @NotNull
+ private Page getShortLinkResultPage(QueryShortLinkLogRequest queryObj) {
+ // 分页和排序
+ boolean asc = Objects.equals(queryObj.getOrderDirection(), "asc");
+ OrderItem orderItem = asc ? OrderItem.asc(queryObj.getOrderBy()) : OrderItem.desc(queryObj.getOrderBy());
+ Page pages = new Page(queryObj.getPage(), queryObj.getLimit())
+ .addOrder(orderItem);
+
+ // 搜索条件
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper()
+ .eq(StringUtils.isNotEmpty(queryObj.getStorageId()), ShortLink::getStorageId, queryObj.getStorageId())
+ .like(StringUtils.isNotEmpty(queryObj.getKey()), ShortLink::getShortKey, queryObj.getKey())
+ .like(StringUtils.isNotEmpty(queryObj.getUrl()), ShortLink::getUrl, queryObj.getUrl())
+ .ge(ObjUtil.isNotEmpty(queryObj.getDateFrom()), ShortLink::getCreateDate, queryObj.getDateFrom())
+ .le(ObjUtil.isNotEmpty(queryObj.getDateTo()), ShortLink::getCreateDate, queryObj.getDateTo());
+
+ // 执行查询
+ Page selectResult = shortLinkService.selectPage(pages, queryWrapper);
+
+ // 转换为结果集
+ Map cache = new HashMap<>();
+ Stream shortLinkResultList = selectResult.getRecords().stream().map(shortLink -> {
+ Integer shortLinkStorageId = shortLink.getStorageId();
+
+ StorageSource storageSource = cache.getOrDefault(shortLinkStorageId, storageSourceService.findById(shortLinkStorageId));
+ cache.put(shortLinkStorageId, storageSource);
+ return shortLinkConvert.entityToResultList(shortLink, storageSource);
+ });
+
+ Page resultPage = new Page<>();
+ resultPage.setTotal(selectResult.getTotal());
+ resultPage.setRecords(shortLinkResultList.collect(Collectors.toList()));
+ return resultPage;
+ }
+
+ @ApiOperationSupport(order = 6)
+ @DeleteMapping("/link/deleteExpireLink")
+ @Operation(summary = "删除过期短链")
+ @ResponseBody
+ @DemoDisable
+ public AjaxJson deleteExpireLink() {
+ int updateRows = shortLinkService.deleteExpireLink();
+ return AjaxJson.getSuccessData(updateRows);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java b/src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java
new file mode 100644
index 0000000..d900b20
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java
@@ -0,0 +1,25 @@
+package im.zhaojun.zfile.module.link.convert;
+
+import im.zhaojun.zfile.module.link.model.entity.ShortLink;
+import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
+import im.zhaojun.zfile.module.link.model.request.ShortLinkResult;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ReportingPolicy;
+import org.springframework.stereotype.Component;
+
+/**
+ * 直链实体类器
+ *
+ * @author zhaojun
+ */
+@Component
+@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
+public interface ShortLinkConvert {
+
+ @Mapping(source = "shortLink.id", target = "id")
+ @Mapping(source = "storageSource.name", target = "storageName")
+ @Mapping(source = "storageSource.type", target = "storageType")
+ ShortLinkResult entityToResultList(ShortLink shortLink, StorageSource storageSource);
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java b/src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java
new file mode 100644
index 0000000..1dbcf5a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java
@@ -0,0 +1,19 @@
+package im.zhaojun.zfile.module.link.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+
+import java.lang.reflect.Method;
+
+@Data
+@AllArgsConstructor
+public class DynamicRegisterMappingHandlerDTO {
+
+ private RequestMappingInfo requestMappingInfo;
+
+ private Object object;
+
+ private Method method;
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java b/src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java
new file mode 100644
index 0000000..1793fde
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java
@@ -0,0 +1,7 @@
+package im.zhaojun.zfile.module.link.event;
+
+import lombok.Data;
+
+@Data
+public class DeleteExpireLinkEvent {
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java b/src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java
new file mode 100644
index 0000000..44b4a91
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java
@@ -0,0 +1,52 @@
+package im.zhaojun.zfile.module.link.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import im.zhaojun.zfile.module.link.model.entity.ShortLink;
+import jakarta.annotation.Nullable;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+
+/**
+ * 短链接配置表 Mapper 接口
+ *
+ * @author zhaojun
+ */
+@Mapper
+public interface ShortLinkMapper extends BaseMapper {
+
+ /**
+ * 根据短链接 key 查询短链接
+ *
+ * @param key
+ * 短链接 key
+ *
+ * @return 短链接信息
+ */
+ ShortLink findByKey(@Param("key")String key);
+
+
+ /**
+ * 根据存储源 ID 删除所有数据
+ *
+ * @param storageId
+ * 存储源 ID
+ */
+ int deleteByStorageId(@Param("storageId") Integer storageId);
+
+ /**
+ * 根据存储源 ID 和 URL 查询短链接
+ */
+ ShortLink findByStorageIdAndUrl(@Param("storageId") Integer storageId,
+ @Param("url") String url,
+ @Nullable @Param("expireDate") Date expireDate);
+
+ /**
+ * 删除过期的短链接
+ *
+ * @return 删除的行数
+ */
+ int deleteExpireLink();
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java b/src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java
new file mode 100644
index 0000000..d8cc911
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java
@@ -0,0 +1,18 @@
+package im.zhaojun.zfile.module.link.model.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class CacheInfo {
+
+ private K key;
+
+ private V value;
+
+ private Date expiredTime;
+
+ private Long ttl;
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java b/src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java
new file mode 100644
index 0000000..0500886
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java
@@ -0,0 +1,66 @@
+package im.zhaojun.zfile.module.link.model.entity;
+
+import cn.hutool.core.date.DateUtil;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 短链信息 entity
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "短链信息")
+@TableName(value = "short_link")
+public class ShortLink implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 永久直链失效时间为 -1
+ */
+ public static final Long PERMANENT_EXPIRE_TIME = -1L;
+
+ /**
+ * 永久直链失效日期为 9999-12-31
+ */
+ public static final Date PERMANENT_EXPIRE_DATE = DateUtil.parseDate("9999-12-31");
+
+ @TableId(value = "id", type = IdType.AUTO)
+ @Schema(title = "ID, 新增无需填写", example = "1")
+ private Integer id;
+
+
+ @TableField(value = "storage_id")
+ @Schema(title = "存储源 ID", example = "1")
+ private Integer storageId;
+
+
+ @TableField(value = "short_key")
+ @Schema(title = "短链 key", example = "voldd3")
+ private String shortKey;
+
+
+ @TableField(value = "url")
+ @Schema(title = "短链 url", example = "/directlink/1/test02.png")
+ private String url;
+
+
+ @TableField(value = "create_date")
+ @Schema(title = "创建时间", example = "2021-11-22 10:05")
+ private Date createDate;
+
+
+ @TableField(value = "expire_date")
+ @Schema(title = "过期时间", example = "2021-11-23 10:05")
+ private Date expireDate;
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java
new file mode 100644
index 0000000..f4b77d0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java
@@ -0,0 +1,36 @@
+package im.zhaojun.zfile.module.link.model.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * Referer 防盗链类型枚举
+ *
+ * @author zhaojun
+ */
+@Getter
+@AllArgsConstructor
+public enum RefererTypeEnum {
+
+ /**
+ * 不启用 Referer 防盗链
+ */
+ OFF("off"),
+
+ /**
+ * 启用白名单模式
+ */
+ WHITE_LIST("white_list"),
+
+ /**
+ * 启用黑名单模式
+ */
+ BLACK_LIST("black_list");
+
+ @EnumValue
+ @JsonValue
+ private final String value;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java
new file mode 100644
index 0000000..0f23dd7
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java
@@ -0,0 +1,15 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author zhaojun
+ */
+@Data
+public class BatchDeleteRequest {
+
+ private List ids;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java
new file mode 100644
index 0000000..4c27af5
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java
@@ -0,0 +1,30 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 批量生成直链请求类
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "批量生成直链请求类")
+public class BatchGenerateLinkRequest {
+
+ @NotBlank(message = "存储源 key 不能为空")
+ private String storageKey;
+
+ @NotEmpty(message = "生成的文件路径不能为空")
+ private List paths;
+
+ /**
+ * 有效期, 单位: 秒
+ */
+ @NotNull(message = "过期时间不能为空")
+ private Long expireTime;
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java
new file mode 100644
index 0000000..9f07089
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java
@@ -0,0 +1,64 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import cn.hutool.core.date.DateUtil;
+import im.zhaojun.zfile.core.model.request.PageQueryRequest;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 查询下载日志请求参数
+ *
+ * @author zhaojun
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class QueryDownloadLogRequest extends PageQueryRequest {
+
+ @Schema(title="文件路径")
+ private String path;
+
+ @Schema(title="存储源 key")
+ private String storageKey;
+
+ @Schema(title="链接类型")
+ private String linkType;
+
+ @Schema(title="短链 key")
+ private String shortKey;
+
+ @Schema(title="访问时间")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private List searchDate;
+
+ @Schema(title="访问 ip")
+ private String ip;
+
+ @Schema(title="访问 user_agent")
+ private String userAgent;
+
+ @Schema(title="访问 referer")
+ private String referer;
+
+ @Schema(title="排序字段")
+ private String orderBy = "create_time";
+
+ public Date getDateFrom() {
+ if (searchDate == null || searchDate.isEmpty()) {
+ return null;
+ }
+ return DateUtil.beginOfDay(searchDate.getFirst());
+ }
+
+ public Date getDateTo() {
+ if (searchDate == null || searchDate.isEmpty()) {
+ return null;
+ }
+ return DateUtil.endOfDay(searchDate.getLast());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java
new file mode 100644
index 0000000..f66e1c4
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java
@@ -0,0 +1,61 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import cn.hutool.core.date.DateUtil;
+import im.zhaojun.zfile.core.model.request.PageQueryRequest;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 查询下载日志请求参数
+ *
+ * @author zhaojun
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class QueryLoginLogRequest extends PageQueryRequest {
+
+ @Schema(title="用户名")
+ private String username;
+
+ @Schema(title="密码")
+ private String password;
+
+ @Schema(title="IP")
+ private String ip;
+
+ @Schema(title="User-Agent")
+ private String userAgent;
+
+ @Schema(title="来源")
+ private String referer;
+
+ @Schema(title="登录结果")
+ private String result;
+
+ @Schema(title="访问时间")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private List searchDate;
+
+ @Schema(title="排序字段")
+ private String orderBy = "create_time";
+
+ public Date getDateFrom() {
+ if (searchDate == null) {
+ return null;
+ }
+ return DateUtil.beginOfDay(searchDate.getFirst());
+ }
+
+ public Date getDateTo() {
+ if (searchDate == null) {
+ return null;
+ }
+ return DateUtil.endOfDay(searchDate.getLast());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java
new file mode 100644
index 0000000..d2ec7fd
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java
@@ -0,0 +1,46 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import cn.hutool.core.date.DateUtil;
+import im.zhaojun.zfile.core.model.request.PageQueryRequest;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * @author zhaojun
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class QueryShortLinkLogRequest extends PageQueryRequest {
+
+ @Schema(title="短链 key")
+ private String key;
+
+ @Schema(title="存储源 id")
+ private String storageId;
+
+ @Schema(title="短链文件路径")
+ private String url;
+
+ @Schema(title="访问时间")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private List searchDate;
+
+ public Date getDateFrom() {
+ if (searchDate == null) {
+ return null;
+ }
+ return DateUtil.beginOfDay(searchDate.getFirst());
+ }
+
+ public Date getDateTo() {
+ if (searchDate == null) {
+ return null;
+ }
+ return DateUtil.endOfDay(searchDate.getLast());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java
new file mode 100644
index 0000000..5fa9b7b
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java
@@ -0,0 +1,49 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 短链结果类
+ *
+ * @author zhaojun
+ */
+@Data
+public class ShortLinkResult {
+
+ @Schema(title = "短链 id", example = "1")
+ private Integer id;
+
+ @Schema(title = "存储源名称", example = "我的本地存储")
+ private String storageName;
+
+ @Schema(title = "存储源类型")
+ private StorageTypeEnum storageType;
+
+ @JsonIgnore
+ private String storageTypeStr;
+
+ public String getStorageTypeStr() {
+ return storageType.getDescription();
+ }
+
+ @Schema(title = "短链 key", example = "voldd3")
+ private String shortKey;
+
+ @Schema(title = "文件 url", example = "/directlink/1/test02.png")
+ private String url;
+
+ @Schema(title = "创建时间", example = "2021-11-22 10:05")
+ private Date createDate;
+
+ @Schema(title = "过期时间", example = "2021-11-23 10:05")
+ private Date expireDate;
+
+ @Schema(title="短链地址")
+ private String shortLink;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java
new file mode 100644
index 0000000..a7ffd96
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java
@@ -0,0 +1,45 @@
+package im.zhaojun.zfile.module.link.model.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 短链接搜索请求参数
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "搜索存储源中文件请求类")
+public class ShortLinkSearchRequest {
+
+ @Schema(title = "存储源 id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotBlank(message = "存储源 id 不能为空")
+ private Integer storageId;
+
+ @Schema(title = "存储源 key", example = "local")
+ private String key;
+
+ @Schema(title = "文件 url/路径", example = "/a")
+ private String url;
+
+ @Schema(title = "开始时间", example = "2022-01-01 00:00:00")
+ private String dateFrom;
+
+ @Schema(title = "结束时间", example = "2022-12-31 23:59:59")
+ private String dateTo;
+
+ @Schema(title = "页码", example = "1")
+ private Integer page;
+
+ @Schema(title = "每页数量", example = "10")
+ private Integer limit;
+
+ @Schema(title = "排序字段", example = "id")
+ private String orderBy;
+
+ @Schema(title = "排序方式", example = "desc")
+ private String orderDirection;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java b/src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java
new file mode 100644
index 0000000..0dd7248
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java
@@ -0,0 +1,17 @@
+package im.zhaojun.zfile.module.link.model.result;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+/**
+ * @author zhaojun
+ */
+@Data
+@Schema(description = "批量生成直链结果类")
+@AllArgsConstructor
+public class BatchGenerateLinkResponse {
+
+ private String address;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java b/src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java
new file mode 100644
index 0000000..69d619a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java
@@ -0,0 +1,45 @@
+package im.zhaojun.zfile.module.link.service;
+
+import im.zhaojun.zfile.module.link.dto.DynamicRegisterMappingHandlerDTO;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 动态请求映射服务,用于在线注册、修改、注销 @RequestMapping 注解的方法.
+ *
+ * @author zhaojun
+ */
+@Slf4j
+@Service
+public class DynamicDirectLinkPrefixService {
+
+ @Resource
+ private RequestMappingHandlerMapping requestMappingHandlerMapping;
+
+ public static final Map REGISTER_MAPPING = new ConcurrentHashMap<>();
+
+ public void registerMappingHandlerMapping(String key, RequestMappingInfo requestMappingInfo, Object controllerObj, Method directLinkMethod) {
+ requestMappingHandlerMapping.registerMapping(requestMappingInfo, controllerObj, directLinkMethod);
+ REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, controllerObj, directLinkMethod));
+
+ }
+
+ public void updateRegisterMappingHandler(String key, RequestMappingInfo requestMappingInfo) {
+ synchronized (key.intern()) {
+ DynamicRegisterMappingHandlerDTO dynamicRegisterMappingHandlerDTO = REGISTER_MAPPING.get(key);
+ if (dynamicRegisterMappingHandlerDTO != null) {
+ requestMappingHandlerMapping.unregisterMapping(dynamicRegisterMappingHandlerDTO.getRequestMappingInfo());
+ requestMappingHandlerMapping.registerMapping(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod());
+ REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod()));
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java b/src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java
new file mode 100644
index 0000000..2e423c9
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java
@@ -0,0 +1,206 @@
+package im.zhaojun.zfile.module.link.service;
+
+import cn.hutool.core.date.DateTime;
+import cn.hutool.core.date.DateUtil;
+import cn.hutool.core.io.FileUtil;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;
+import im.zhaojun.zfile.core.exception.core.ErrorPageBizException;
+import im.zhaojun.zfile.core.exception.status.ForbiddenAccessException;
+import im.zhaojun.zfile.core.exception.status.NotFoundAccessException;
+import im.zhaojun.zfile.core.util.FileUtils;
+import im.zhaojun.zfile.core.util.HttpUtil;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.filter.service.FilterConfigService;
+import im.zhaojun.zfile.module.link.model.entity.ShortLink;
+import im.zhaojun.zfile.module.log.model.entity.DownloadLog;
+import im.zhaojun.zfile.module.log.service.DownloadLogService;
+import im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter;
+import im.zhaojun.zfile.module.storage.annotation.RefererCheck;
+import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
+import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
+import im.zhaojun.zfile.module.storage.service.StorageSourceService;
+import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.BooleanUtils;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * @author zhaojun
+ */
+@Slf4j
+@Service
+public class LinkDownloadService {
+
+ @Resource
+ private StorageSourceService storageSourceService;
+
+ @Resource
+ private DownloadLogService downloadLogService;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private ShortLinkService shortLinkService;
+
+ @Resource
+ private FilterConfigService filterConfigService;
+
+ private final Set expireKeySet = new HashSet<>();
+
+ @RefererCheck
+ @LinkRateLimiter
+ public ResponseEntity> handlerDirectLink(String storageKey, String filePath) {
+ // 检查系统是否允许直链
+ SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();
+ if (BooleanUtils.isNotTrue(systemConfigDTO.getShowPathLink())) {
+ throw new ForbiddenAccessException(ErrorCode.BIZ_DIRECT_LINK_NOT_ALLOWED);
+ }
+ return handlerDownloadGetUrl(storageKey, filePath, null, DownloadLog.DOWNLOAD_TYPE_DIRECT_LINK);
+ }
+
+ @RefererCheck
+ @LinkRateLimiter
+ public ResponseEntity> handlerShortLink(String shortKey) throws IOException {
+ // 从缓存中判断是否短链是否过期
+ if (expireKeySet.contains(shortKey)) {
+ throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_EXPIRED);
+ }
+
+ // 判断是否允许生成短链.
+ SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig();
+ if (BooleanUtils.isNotTrue(systemConfigDTO.getShowShortLink())) {
+ throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_NOT_ALLOWED);
+ }
+
+ // 判断短链是否存在
+ ShortLink shortLink = shortLinkService.findByKey(shortKey);
+ if (shortLink == null) {
+ throw new NotFoundAccessException(ErrorCode.BIZ_SHORT_LINK_NOT_FOUNT);
+ }
+
+ // 判断短链是否过期
+ if (shortLink.getExpireDate() != null) {
+ DateTime now = DateUtil.date();
+ boolean isExpire = now.isAfter(shortLink.getExpireDate());
+ if (isExpire) {
+ expireKeySet.add(shortKey);
+ throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_EXPIRED);
+ }
+ }
+
+ // 获取实际文件路径,下载并记录日志
+ Integer storageId = shortLink.getStorageId();
+ String storageKey = storageSourceService.findStorageKeyById(storageId);
+ String filePath = shortLink.getUrl();
+ return handlerDownloadGetUrl(storageKey, filePath, shortKey, DownloadLog.DOWNLOAD_TYPE_SHORT_LINK);
+ }
+
+ /**
+ * 处理指定存储源的下载请求
+ *
+ * @param storageKey
+ * 存储源 key
+ *
+ * @param filePath
+ * 文件路径
+ *
+ * @param shortKey
+ * 短链接 key
+ *
+ * @param downloadType
+ * 下载类型, 直链下载(directLink)或短链下载(shortLink)
+ */
+ private ResponseEntity> handlerDownloadGetUrl(String storageKey, String filePath, String shortKey, String downloadType) {
+ String fileAlias = StringUtils.equals(downloadType, DownloadLog.DOWNLOAD_TYPE_DIRECT_LINK) ? filePath : shortKey;
+
+ // 获取存储源 Service
+ AbstractBaseFileService> fileService;
+ try {
+ fileService = StorageSourceContext.getByStorageKey(storageKey);
+ } catch (InvalidStorageSourceBizException e) {
+ throw new ErrorPageBizException("无效的或初始化失败的存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载.", e);
+ }
+
+ if (fileService == null) {
+ throw new ErrorPageBizException("未找到存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载.");
+ }
+
+ StorageSource storageSource = storageSourceService.findByStorageKey(storageKey);
+ Boolean enable = storageSource.getEnable();
+ if (!enable) {
+ throw new ErrorPageBizException("未启用的存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载.");
+ }
+
+ // 检查是否访问了禁止下载的目录
+ if (filterConfigService.checkFileIsDisableDownload(storageSource.getId(), filePath)) {
+ // 获取 Forbidden 页面地址
+ return ResponseEntity.status(302)
+ .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate, private")
+ .header(HttpHeaders.PRAGMA, "no-cache")
+ .header(HttpHeaders.EXPIRES, "0")
+ .header(HttpHeaders.LOCATION, systemConfigService.getForbiddenUrl())
+ .build();
+ }
+
+ // 获取文件下载链接
+ String downloadUrl;
+ try {
+ downloadUrl = fileService.getDownloadUrl(filePath);
+ } catch (NotFoundAccessException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ErrorPageBizException("获取存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载.", e);
+ }
+
+ // 判断下载链接是否为空
+ if (StringUtils.isEmpty(downloadUrl)) {
+ throw new ErrorPageBizException("获取存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接为空, 无法下载.");
+ }
+
+ // 记录下载日志.
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+ Boolean recordDownloadLog = systemConfig.getRecordDownloadLog();
+ if (BooleanUtils.isTrue(recordDownloadLog)) {
+ DownloadLog downloadLog = new DownloadLog(downloadType, filePath, storageKey, shortKey);
+ downloadLogService.save(downloadLog);
+ }
+
+ // 判断下载链接是否为 m3u8 格式, 如果是则返回 m3u8 内容.
+ if (StringUtils.equalsIgnoreCase(FileUtil.extName(filePath), "m3u8")) {
+ String textContent = HttpUtil.getTextContent(downloadUrl);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.parseMediaType("application/vnd.apple.mpegurl;charset=utf-8"));
+ ContentDisposition contentDisposition = ContentDisposition
+ .builder("attachment")
+ .filename(FileUtils.getName(filePath), StandardCharsets.UTF_8)
+ .build();
+ headers.setContentDisposition(contentDisposition);
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(textContent);
+ }
+
+ return ResponseEntity.status(302)
+ .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate, private")
+ .header(HttpHeaders.PRAGMA, "no-cache")
+ .header(HttpHeaders.EXPIRES, "0")
+ .header(HttpHeaders.LOCATION, downloadUrl)
+ .build();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java b/src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java
new file mode 100644
index 0000000..70442a1
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java
@@ -0,0 +1,200 @@
+package im.zhaojun.zfile.module.link.service;
+
+import cn.hutool.core.util.RandomUtil;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.event.DeleteExpireLinkEvent;
+import im.zhaojun.zfile.module.link.mapper.ShortLinkMapper;
+import im.zhaojun.zfile.module.link.model.entity.ShortLink;
+import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.aop.framework.AopContext;
+import org.springframework.cache.annotation.CacheConfig;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.event.EventListener;
+import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 短链 Service
+ *
+ * @author zhaojun
+ */
+@Service
+@Slf4j
+@CacheConfig(cacheNames = "shortLink")
+public class ShortLinkService {
+
+ @Resource
+ private ShortLinkMapper shortLinkMapper;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private ApplicationEventPublisher applicationEventPublisher;
+
+ /**
+ * 根据短链接 key 查询短链接
+ *
+ * @param key
+ * 短链接 key
+ *
+ * @return 短链接信息
+ */
+ @Cacheable(key = "#key", unless = "#result == null", condition = "#key != null")
+ public ShortLink findByKey(String key) {
+ return shortLinkMapper.findByKey(key);
+ }
+
+ /**
+ * 根据存储源 ID 和 URL 查询短链接
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param url
+ * 文件路径
+ *
+ * @return 短链接信息
+ */
+ public @Nullable ShortLink findByStorageIdAndUrl(Integer storageId, String url, @Nullable Date expireDate) {
+ return shortLinkMapper.findByStorageIdAndUrl(storageId, url, expireDate);
+ }
+
+
+ /**
+ * 为存储源指定路径生成短链接, 保证生成的短连接 key 是不同的 (如果是永久链接, 则不会重新生成)
+ *
+ * @param storageId
+ * 存储源 id
+ *
+ * @param fullPath
+ * 存储源路径
+ *
+ * @return 生成后的短链接信息
+ */
+ public ShortLink generatorShortLink(Integer storageId, String fullPath, Long expireTime) {
+ boolean validate = checkExpireDateIsValidate(expireTime);
+ if (!validate) {
+ throw new BizException(ErrorCode.BIZ_EXPIRE_TIME_ILLEGAL);
+ }
+
+ // 永久链接不在重复生成
+ if (Objects.equals(expireTime, ShortLink.PERMANENT_EXPIRE_TIME)) {
+ ShortLink shortLink = findByStorageIdAndUrl(storageId, fullPath, ShortLink.PERMANENT_EXPIRE_DATE);
+ if (shortLink != null) {
+ return shortLink;
+ }
+ }
+
+ ShortLink shortLink;
+ String randomKey;
+ int generateCount = 0;
+ do {
+ // 获取短链
+ randomKey = RandomUtil.randomString(6);
+ shortLink = ((ShortLinkService) AopContext.currentProxy()).findByKey(randomKey);
+ generateCount++;
+ } while (shortLink != null);
+
+ shortLink = new ShortLink();
+ shortLink.setStorageId(storageId);
+ shortLink.setUrl(fullPath);
+ shortLink.setCreateDate(new Date());
+ shortLink.setShortKey(randomKey);
+
+ if (expireTime == -1) {
+ shortLink.setExpireDate(ShortLink.PERMANENT_EXPIRE_DATE);
+ } else {
+ shortLink.setExpireDate(new Date(System.currentTimeMillis() + expireTime * 1000L));
+ }
+
+
+ if (log.isDebugEnabled()) {
+ log.debug("生成直/短链: 存储源 ID: {}, 文件路径: {}, 短链 key {}, 随机生成直链冲突次数: {}",
+ shortLink.getStorageId(), shortLink.getUrl(), shortLink.getShortKey(), generateCount);
+ }
+
+ shortLinkMapper.insert(shortLink);
+ return shortLink;
+ }
+
+ @CacheEvict(allEntries = true)
+ public int deleteExpireLink() {
+ applicationEventPublisher.publishEvent(new DeleteExpireLinkEvent());
+ int deleteSize = shortLinkMapper.deleteExpireLink();
+ log.info("删除过期直/短链 {} 条", deleteSize);
+ return deleteSize;
+ }
+
+ @CacheEvict(allEntries = true)
+ public void removeById(Integer id) {
+ log.info("删除 id 为 {} 的直/短链", id);
+ shortLinkMapper.deleteById(id);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @CacheEvict(allEntries = true)
+ public void removeBatchByIds(List ids) {
+ log.info("批量删除直/短链,id 集合为 {}", ids);
+ shortLinkMapper.deleteBatchIds(ids);
+ }
+
+ @CacheEvict(allEntries = true)
+ public int deleteByStorageId(Integer storageId) {
+ int deleteSize = shortLinkMapper.deleteByStorageId(storageId);
+ log.info("删除存储源 ID 为 {} 的短链 {} 条", storageId, deleteSize);
+ return deleteSize;
+ }
+
+ /**
+ * 监听存储源删除事件,根据存储源 id 删除相关的短链
+ *
+ * @param storageSourceDeleteEvent
+ * 存储源删除事件
+ */
+ @EventListener
+ public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {
+ Integer storageId = storageSourceDeleteEvent.getId();
+ int updateRows = ((ShortLinkService) AopContext.currentProxy()).deleteByStorageId(storageId);
+ if (log.isDebugEnabled()) {
+ log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源短链 {} 条",
+ storageId,
+ storageSourceDeleteEvent.getName(),
+ storageSourceDeleteEvent.getType().getDescription(),
+ updateRows);
+ }
+ }
+
+ public Page selectPage(Page pages, Wrapper queryWrapper) {
+ return shortLinkMapper.selectPage(pages, queryWrapper);
+ }
+
+ private boolean checkExpireDateIsValidate(Long expires) {
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+
+ List linkExpireTimeList = systemConfig.getLinkExpireTimes();
+
+ for (LinkExpireDTO linkExpireDTO : linkExpireTimeList) {
+ if (linkExpireDTO.getSeconds().equals(expires)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java b/src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java
new file mode 100644
index 0000000..c50599b
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java
@@ -0,0 +1,153 @@
+package im.zhaojun.zfile.module.log.controller;
+
+import cn.hutool.core.util.ObjUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.OrderItem;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.annotation.DemoDisable;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest;
+import im.zhaojun.zfile.module.link.model.request.QueryDownloadLogRequest;
+import im.zhaojun.zfile.module.log.convert.DownloadLogConvert;
+import im.zhaojun.zfile.module.log.model.entity.DownloadLog;
+import im.zhaojun.zfile.module.log.model.result.DownloadLogResult;
+import im.zhaojun.zfile.module.log.service.DownloadLogService;
+import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
+import im.zhaojun.zfile.module.storage.service.StorageSourceService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/**
+ * 直链下载日志接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "直链日志管理")
+@ApiSort(7)
+@Controller
+@RequestMapping("/admin/download/log")
+public class DownloadLogManagerController {
+
+ @Resource
+ private StorageSourceService storageSourceService;
+
+ @Resource
+ private DownloadLogConvert downloadLogConvert;
+
+ @Resource
+ private DownloadLogService downloadLogService;
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @ApiOperationSupport(order = 1)
+ @GetMapping("/list")
+ @Operation(summary = "直链下载日志")
+ @ResponseBody
+ public AjaxJson> list(QueryDownloadLogRequest queryDownloadLogRequest) {
+ // 分页和排序
+ boolean asc = Objects.equals(queryDownloadLogRequest.getOrderDirection(), "asc");
+ OrderItem orderItem = asc ? OrderItem.asc(queryDownloadLogRequest.getOrderBy()) : OrderItem.desc(queryDownloadLogRequest.getOrderBy());
+ Page pages = new Page(queryDownloadLogRequest.getPage(), queryDownloadLogRequest.getLimit())
+ .addOrder(orderItem);
+
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper()
+ .eq(StringUtils.isNotEmpty(queryDownloadLogRequest.getStorageKey()), DownloadLog::getStorageKey, queryDownloadLogRequest.getStorageKey())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getPath()), DownloadLog::getPath, queryDownloadLogRequest.getPath())
+ .isNotNull("shortLink".equals(queryDownloadLogRequest.getLinkType()), DownloadLog::getShortKey)
+ .isNull("directLink".equals(queryDownloadLogRequest.getLinkType()), DownloadLog::getShortKey)
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getShortKey()), DownloadLog::getShortKey, queryDownloadLogRequest.getShortKey())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getIp()), DownloadLog::getIp, queryDownloadLogRequest.getIp())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getReferer()), DownloadLog::getReferer, queryDownloadLogRequest.getReferer())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getUserAgent()), DownloadLog::getUserAgent, queryDownloadLogRequest.getUserAgent())
+ .ge(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateFrom()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateFrom())
+ .le(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateTo()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateTo());
+
+ Page selectResult = downloadLogService.selectPage(pages, queryWrapper);
+
+ Map cache = new HashMap<>();
+
+ String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+ String directLinkPrefix = systemConfig.getDirectLinkPrefix();
+
+ Stream shortLinkResultList = selectResult.getRecords().stream().map(model -> {
+ String storageKey = model.getStorageKey();
+
+ StorageSource storageSource = cache.computeIfAbsent(storageKey, (key) -> storageSourceService.findByStorageKey(key));
+ DownloadLogResult downloadLogResult = downloadLogConvert.entityToResultList(model, storageSource);
+
+ if (StringUtils.isNotBlank(downloadLogResult.getShortKey())) {
+ downloadLogResult.setShortLink(StringUtils.concat(serverAddress, "s", downloadLogResult.getShortKey()));
+ } else {
+ downloadLogResult.setPathLink(StringUtils.concat(serverAddress, directLinkPrefix, downloadLogResult.getStorageKey(), downloadLogResult.getPath()));
+ }
+
+ return downloadLogResult;
+ });
+ return AjaxJson.getPageData(selectResult.getTotal(), shortLinkResultList);
+ }
+
+
+ @ApiOperationSupport(order = 2)
+ @DeleteMapping("/delete/{id}")
+ @Operation(summary = "删除直链")
+ @Parameter(in = ParameterIn.PATH, name = "id", description = "直链 id", required = true, schema = @Schema(type = "integer"))
+ @ResponseBody
+ @DemoDisable
+ public AjaxJson deleteById(@PathVariable Integer id) {
+ downloadLogService.removeById(id);
+ return AjaxJson.getSuccess();
+ }
+
+
+ @ApiOperationSupport(order = 3)
+ @PostMapping("/delete/batch")
+ @ResponseBody
+ @Operation(summary = "批量删除直链")
+ @DemoDisable
+ public AjaxJson batchDelete(@RequestBody BatchDeleteRequest batchDeleteRequest) {
+ List ids = batchDeleteRequest.getIds();
+ downloadLogService.removeBatchByIds(ids);
+ return AjaxJson.getSuccess();
+ }
+
+ @ApiOperationSupport(order = 4)
+ @PostMapping("/delete/batch/query")
+ @ResponseBody
+ @Operation(summary = "根据查询条件批量删除直链")
+ @DemoDisable
+ public AjaxJson batchDeleteBySearchParams(@RequestBody QueryDownloadLogRequest queryDownloadLogRequest) {
+
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper()
+ .eq(StringUtils.isNotEmpty(queryDownloadLogRequest.getStorageKey()), DownloadLog::getStorageKey, queryDownloadLogRequest.getStorageKey())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getPath()), DownloadLog::getPath, queryDownloadLogRequest.getPath())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getShortKey()), DownloadLog::getShortKey, queryDownloadLogRequest.getShortKey())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getIp()), DownloadLog::getIp, queryDownloadLogRequest.getIp())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getReferer()), DownloadLog::getReferer, queryDownloadLogRequest.getReferer())
+ .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getUserAgent()), DownloadLog::getUserAgent, queryDownloadLogRequest.getUserAgent())
+ .ge(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateFrom()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateFrom())
+ .le(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateTo()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateTo());
+
+ downloadLogService.deleteByQueryWrapper(queryWrapper);
+ return AjaxJson.getSuccess();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java b/src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java
new file mode 100644
index 0000000..6010019
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java
@@ -0,0 +1,64 @@
+package im.zhaojun.zfile.module.log.controller;
+
+import cn.hutool.core.util.ObjUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.OrderItem;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.link.model.request.QueryLoginLogRequest;
+import im.zhaojun.zfile.module.log.model.entity.LoginLog;
+import im.zhaojun.zfile.module.log.service.LoginLogService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 用户登录日志接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "登录日志管理")
+@ApiSort(7)
+@Controller
+@RequestMapping("/admin/login/log")
+public class LoginLogController {
+
+ @Resource
+ private LoginLogService loginLogService;
+
+ @ApiOperationSupport(order = 1)
+ @GetMapping("/list")
+ @Operation(summary = "登录日志列表")
+ @ResponseBody
+ public AjaxJson> list(QueryLoginLogRequest queryLoginLogRequest) {
+ // 分页和排序
+ boolean asc = Objects.equals(queryLoginLogRequest.getOrderDirection(), "asc");
+ OrderItem orderItem = asc ? OrderItem.asc(queryLoginLogRequest.getOrderBy()) : OrderItem.desc(queryLoginLogRequest.getOrderBy());
+ Page pages = new Page(queryLoginLogRequest.getPage(), queryLoginLogRequest.getLimit())
+ .addOrder(orderItem);
+
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper()
+ .like(StringUtils.isNotEmpty(queryLoginLogRequest.getUsername()), LoginLog::getUsername, queryLoginLogRequest.getUsername())
+ .like(StringUtils.isNotEmpty(queryLoginLogRequest.getPassword()), LoginLog::getPassword, queryLoginLogRequest.getPassword())
+ .like(StringUtils.isNotEmpty(queryLoginLogRequest.getIp()), LoginLog::getIp, queryLoginLogRequest.getIp())
+ .like(StringUtils.isNotEmpty(queryLoginLogRequest.getUserAgent()), LoginLog::getUserAgent, queryLoginLogRequest.getUserAgent())
+ .like(StringUtils.isNotEmpty(queryLoginLogRequest.getReferer()), LoginLog::getReferer, queryLoginLogRequest.getReferer())
+ .like(StringUtils.isNotEmpty(queryLoginLogRequest.getResult()), LoginLog::getResult, queryLoginLogRequest.getResult())
+ .ge(ObjUtil.isNotEmpty(queryLoginLogRequest.getDateFrom()), LoginLog::getCreateTime, queryLoginLogRequest.getDateFrom())
+ .le(ObjUtil.isNotEmpty(queryLoginLogRequest.getDateTo()), LoginLog::getCreateTime, queryLoginLogRequest.getDateTo());
+
+ Page selectResult = loginLogService.selectPage(pages, queryWrapper);
+ return AjaxJson.getPageData(selectResult.getTotal(), selectResult.getRecords());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java b/src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java
new file mode 100644
index 0000000..ed2e181
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java
@@ -0,0 +1,24 @@
+package im.zhaojun.zfile.module.log.convert;
+
+import im.zhaojun.zfile.module.log.model.entity.DownloadLog;
+import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
+import im.zhaojun.zfile.module.log.model.result.DownloadLogResult;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.springframework.stereotype.Component;
+
+/**
+ * 下载日志实体类转换器
+ *
+ * @author zhaojun
+ */
+@Component
+@Mapper(componentModel = "spring")
+public interface DownloadLogConvert {
+
+ @Mapping(source = "downloadLog.id", target = "id")
+ @Mapping(source = "storageSource.name", target = "storageName")
+ @Mapping(source = "storageSource.type", target = "storageType")
+ DownloadLogResult entityToResultList(DownloadLog downloadLog, StorageSource storageSource);
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java b/src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java
new file mode 100644
index 0000000..54c61b0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java
@@ -0,0 +1,31 @@
+package im.zhaojun.zfile.module.log.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import im.zhaojun.zfile.module.log.model.entity.DownloadLog;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 下载日志 Mapper 接口
+ *
+ * @author zhaojun
+ */
+@Mapper
+public interface DownloadLogMapper extends BaseMapper {
+
+ /**
+ * 根据存储源 KEY 删除所有数据
+ *
+ * @param storageKey
+ * 存储源 KEY
+ */
+ int deleteByStorageKey(String storageKey);
+
+
+ /**
+ * 删除过期的短链下载日志
+ *
+ * @return 删除的行数
+ */
+ int deleteExpireShortLinkLog();
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java b/src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java
new file mode 100644
index 0000000..fbfcba0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java
@@ -0,0 +1,9 @@
+package im.zhaojun.zfile.module.log.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import im.zhaojun.zfile.module.log.model.entity.LoginLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface LoginLogMapper extends BaseMapper {
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java b/src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java
new file mode 100644
index 0000000..f90adf4
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java
@@ -0,0 +1,92 @@
+package im.zhaojun.zfile.module.log.model.entity;
+
+import cn.hutool.extra.servlet.JakartaServletUtil;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import im.zhaojun.zfile.core.util.RequestHolder;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.http.HttpHeaders;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 文件下载日志 entity
+ *
+ * @author zhaojun
+ */
+@Data
+@Tag(name ="文件下载日志")
+@TableName(value = "`download_log`")
+@NoArgsConstructor
+public class DownloadLog implements Serializable {
+
+ public static final String DOWNLOAD_TYPE_DIRECT_LINK = "directLink";
+
+ public static final String DOWNLOAD_TYPE_SHORT_LINK = "shortLink";
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.INPUT)
+ @Schema(title = "ID, 新增无需填写", example = "1")
+ private Integer id;
+
+
+ @TableField(value = "`download_type`")
+ @Schema(title="下载类型", example = "directLink", allowableValues = "directLink, shortLink")
+ private String downloadType;
+
+
+ @TableField(value = "`path`")
+ @Schema(title="文件路径")
+ private String path;
+
+
+ @TableField(value = "`storage_key`")
+ @Schema(title="存储源 key")
+ private String storageKey;
+
+
+ @TableField(value = "`create_time`")
+ @Schema(title="访问时间")
+ private Date createTime;
+
+
+ @TableField(value = "`ip`")
+ @Schema(title="访问 ip")
+ private String ip;
+
+
+ @TableField(value = "short_key")
+ @Schema(title = "短链 key", example = "voldd3")
+ private String shortKey;
+
+
+ @TableField(value = "`user_agent`")
+ @Schema(title="访问 user_agent")
+ private String userAgent;
+
+
+ @TableField(value = "`referer`")
+ @Schema(title="访问 referer")
+ private String referer;
+
+ public DownloadLog(String downloadType, String path, String storageKey, String shortKey) {
+ this.downloadType = downloadType;
+ this.path = path;
+ this.storageKey = storageKey;
+ this.shortKey = shortKey;
+ this.createTime = new Date();
+ HttpServletRequest request = RequestHolder.getRequest();
+ this.ip = JakartaServletUtil.getClientIP(request);
+ this.referer = request.getHeader(HttpHeaders.REFERER);
+ this.userAgent = request.getHeader(HttpHeaders.USER_AGENT);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java b/src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java
new file mode 100644
index 0000000..a25aeb8
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java
@@ -0,0 +1,39 @@
+package im.zhaojun.zfile.module.log.model.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName(value = "login_log")
+public class LoginLog implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.INPUT)
+ private Integer id;
+
+ @TableField(value = "username")
+ private String username;
+
+ @TableField(value = "`password`")
+ private String password;
+
+ @TableField(value = "create_time", fill = FieldFill.INSERT)
+ private Date createTime;
+
+ @TableField(value = "ip")
+ private String ip;
+
+ @TableField(value = "user_agent")
+ private String userAgent;
+
+ @TableField(value = "referer")
+ private String referer;
+
+ @TableField(value = "`result`")
+ private String result;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java b/src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java
new file mode 100644
index 0000000..b8061b0
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java
@@ -0,0 +1,53 @@
+package im.zhaojun.zfile.module.log.model.result;
+
+import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 下载日志结果类
+ *
+ * @author zhaojun
+ */
+@Data
+public class DownloadLogResult {
+
+ @Schema(title="")
+ private Integer id;
+
+ @Schema(title="文件路径")
+ private String path;
+
+ @Schema(title = "存储源类型")
+ private StorageTypeEnum storageType;
+
+ @Schema(title = "存储源名称", example = "我的本地存储")
+ private String storageName;
+
+ @Schema(title = "存储源Key", example = "local")
+ private String storageKey;
+
+ @Schema(title="访问时间")
+ private Date createTime;
+
+ @Schema(title="访问 ip")
+ private String ip;
+
+ @Schema(title = "短链 Key")
+ private String shortKey;
+
+ @Schema(title="访问 user_agent")
+ private String userAgent;
+
+ @Schema(title="访问 referer")
+ private String referer;
+
+ @Schema(title="短链地址")
+ private String shortLink;
+
+ @Schema(title="直链地址")
+ private String pathLink;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java b/src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java
new file mode 100644
index 0000000..581d9cb
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java
@@ -0,0 +1,92 @@
+package im.zhaojun.zfile.module.log.service;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import im.zhaojun.zfile.module.link.event.DeleteExpireLinkEvent;
+import im.zhaojun.zfile.module.log.mapper.DownloadLogMapper;
+import im.zhaojun.zfile.module.log.model.entity.DownloadLog;
+import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.aop.framework.AopContext;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 下载日志 Service
+ *
+ * @author zhaojun
+ */
+@Slf4j
+@Service
+public class DownloadLogService {
+
+ @Resource
+ private DownloadLogMapper downloadLogMapper;
+
+ public void save(DownloadLog downloadLog) {
+ downloadLogMapper.insert(downloadLog);
+ }
+
+ public Page selectPage(Page pages, Wrapper queryWrapper) {
+ return downloadLogMapper.selectPage(pages, queryWrapper);
+ }
+
+ public void removeById(Integer id) {
+ downloadLogMapper.deleteById(id);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void removeBatchByIds(List ids) {
+ downloadLogMapper.deleteBatchIds(ids);
+ }
+
+ public void deleteByQueryWrapper(Wrapper queryWrapper) {
+ downloadLogMapper.delete(queryWrapper);
+ }
+
+ public int deleteByStorageKey(String storageKey) {
+ int deleteSize = downloadLogMapper.deleteByStorageKey(storageKey);
+ log.info("删除存储源 ID 为 {} 的直/短链下载日志 {} 条", storageKey, deleteSize);
+ return deleteSize;
+ }
+
+ /**
+ * 监听存储源删除事件,根据存储源 id 删除相关的下载日志
+ *
+ * @param storageSourceDeleteEvent
+ * 存储源删除事件
+ */
+ @EventListener
+ public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {
+ String storageKey = storageSourceDeleteEvent.getKey();
+ int updateRows = ((DownloadLogService) AopContext.currentProxy()).deleteByStorageKey(storageKey);
+ if (log.isDebugEnabled()) {
+ log.debug("删除存储源 [id {}, key: {}, name: {}, type: {}] 时,关联删除存储源直/短链下载日志 {} 条",
+ storageSourceDeleteEvent.getId(),
+ storageKey,
+ storageSourceDeleteEvent.getName(),
+ storageSourceDeleteEvent.getType().getDescription(),
+ updateRows);
+ }
+ }
+
+ /**
+ * 删除过期下载日志
+ *
+ * @return 删除的条数
+ */
+ public int deleteExpireShortLinkLog() {
+ return downloadLogMapper.deleteExpireShortLinkLog();
+ }
+
+ @EventListener(classes = DeleteExpireLinkEvent.class)
+ public void deleteExpireShortLinkLog(DeleteExpireLinkEvent event) {
+ int updateRows = deleteExpireShortLinkLog();
+ log.info("删除过期短链关联删除日志 {} 条", updateRows);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java b/src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java
new file mode 100644
index 0000000..e9db67e
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java
@@ -0,0 +1,24 @@
+package im.zhaojun.zfile.module.log.service;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import im.zhaojun.zfile.module.log.mapper.LoginLogMapper;
+import im.zhaojun.zfile.module.log.model.entity.LoginLog;
+import org.springframework.stereotype.Service;
+
+import jakarta.annotation.Resource;
+
+@Service
+public class LoginLogService {
+
+ @Resource
+ private LoginLogMapper loginLogMapper;
+
+ public void save(LoginLog loginLog) {
+ loginLogMapper.insert(loginLog);
+ }
+
+ public Page selectPage(Page pages, Wrapper queryWrapper) {
+ return loginLogMapper.selectPage(pages, queryWrapper);
+ }
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java
new file mode 100644
index 0000000..5f6bb94
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java
@@ -0,0 +1,369 @@
+package im.zhaojun.zfile.module.onlyoffice.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.jwt.JWT;
+import cn.hutool.jwt.JWTUtil;
+import com.alibaba.fastjson2.JSONObject;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;
+import im.zhaojun.zfile.core.exception.core.BizException;
+import im.zhaojun.zfile.core.util.*;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeCallback;
+import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
+import im.zhaojun.zfile.module.onlyoffice.service.OnlyOfficeConfigService;
+import im.zhaojun.zfile.module.storage.annotation.CheckPassword;
+import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
+import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
+import im.zhaojun.zfile.module.storage.model.request.base.FileItemRequest;
+import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
+import im.zhaojun.zfile.module.storage.service.StorageSourceService;
+import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
+import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;
+import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.beans.Beans;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.locks.ReentrantLock;
+
+@Slf4j
+@Tag(name = "OnlyOffice 相关接口")
+@RestController
+@RequestMapping("/onlyOffice")
+public class OnlyOfficeController {
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ @Resource
+ private StorageSourceService storageSourceService;
+
+ @Resource
+ private UserStorageSourceService userStorageSourceService;
+
+ @Resource
+ private OnlyOfficeConfigService onlyOfficeConfigService;
+
+ private static final String CALLBACK_ERROR_MSG = "{\"error\":1}";
+
+ private static final String CALLBACK_SUCCESS_MSG = "{\"error\":0}";
+
+ /**
+ * OnlyOffice 回调所有需要处理的状态(保存 + 错误), 用于清理 key 缓存.
+ */
+ public static final List SUPPORTED_STATUS = List.of(2, 3, 6, 7);
+
+ /**
+ * OnlyOffice 回调真正需要保存写入存储的状态. 错误状态(3, 7)只清缓存, 不写文件.
+ */
+ private static final List SAVE_STATUS = List.of(2, 6);
+
+ private static final int ONLY_OFFICE_DOWNLOAD_CONNECT_TIMEOUT_MILLIS = 5_000;
+
+ private static final int ONLY_OFFICE_DOWNLOAD_READ_TIMEOUT_MILLIS = 30_000;
+
+ private static final int ONLY_OFFICE_DOWNLOAD_MAX_REDIRECTS = 5;
+
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "OnlyOffice 预览文件", description = "根据传入的文件信息, 生成 OnlyOffice 预览所需的 JSON 数据.")
+ @PostMapping("/config/token")
+ @CheckPassword(storageKeyFieldExpression = "[0].storageKey",
+ pathFieldExpression = "[0].path",
+ pathIsDirectory = false,
+ passwordFieldExpression = "[0].password")
+ public AjaxJson getPreviewFileJSONInfo(@Valid @RequestBody FileItemRequest fileItemRequest) {
+ String storageKey = fileItemRequest.getStorageKey();
+ Integer storageId = storageSourceService.findIdByKey(storageKey);
+ if (storageId == null) {
+ throw new InvalidStorageSourceBizException(storageKey);
+ }
+
+ fileItemRequest.handleDefaultValue();
+
+ AbstractBaseFileService> fileService = StorageSourceContext.getByStorageId(storageId);
+ // 原始(用户视角)路径, 不含用户根目录, 回调时用它直接调 uploadFile, 避免重复拼接.
+ String originalPath = fileItemRequest.getPath();
+
+ try {
+ FileItemResult fileInfo = fileService.getFileItem(originalPath);
+ if (fileInfo == null) {
+ throw new BizException("文件不存在");
+ }
+
+ // 拼接出完整路径作为缓存键, 让不同用户根目录下的同名文件互不冲突,
+ // 同时同一物理文件的多人预览能命中同一个 OnlyOffice 协同编辑 key.
+ String fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), originalPath);
+
+ boolean hasUploadPermission = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.UPLOAD);
+
+ Integer currentUserId = ZFileAuthUtil.getCurrentUserId();
+ // 把发起预览的用户 ID 一并写入缓存, 回调时据此校验权限, 避免信任回调中的 users 字段.
+ OnlyOfficeFile onlyOfficeFile = new OnlyOfficeFile(storageKey, fullPath, originalPath, currentUserId, false);
+
+ JSONObject onlyOfficePayload = onlyOfficeConfigService.createConfig(fileInfo, onlyOfficeFile, hasUploadPermission);
+ return AjaxJson.getSuccessData(onlyOfficePayload);
+ } catch (Exception e) {
+ throw new BizException("获取文件信息失败: " + e.getMessage());
+ }
+ }
+
+
+ @RequestMapping("/callback")
+ public String callBack(@RequestBody OnlyOfficeCallback onlyOfficeCallback) {
+ if (log.isDebugEnabled()) {
+ log.debug("OnlyOffice 回调信息: {}, {}", onlyOfficeCallback.getStatus(), onlyOfficeCallback);
+ }
+
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+ String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();
+ // 未配置 secret 时, callback 无法验签, 直接拒绝, 避免被未授权请求触发存储写入.
+ if (StrUtil.isBlank(onlyOfficeSecret)) {
+ log.warn("OnlyOffice 回调被拒绝: 未配置 Secret, key={}, status={}",
+ onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
+ return CALLBACK_ERROR_MSG;
+ }
+
+ if (StrUtil.isBlank(onlyOfficeCallback.getToken())) {
+ log.error("OnlyOffice 回调 Token 为空: key={}, status={}",
+ onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
+ return CALLBACK_ERROR_MSG;
+ }
+
+ if (!JWTUtil.verify(onlyOfficeCallback.getToken(), StrUtil.bytes(onlyOfficeSecret, StandardCharsets.UTF_8))) {
+ log.error("OnlyOffice 回调 Token 验证失败: key={}, status={}",
+ onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
+ return CALLBACK_ERROR_MSG;
+ }
+
+ // 仅在真正需要保存写入存储时再执行 URL 下载与上传; 错误状态(3, 7)仍然清掉 key 缓存.
+ if (!SUPPORTED_STATUS.contains(onlyOfficeCallback.getStatus())) {
+ return CALLBACK_SUCCESS_MSG;
+ }
+
+ String key = onlyOfficeCallback.getKey();
+ OnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByKey(key);
+ // 文件不存在或者存储策略不存在, 直接返回错误信息.
+ if (onlyOfficeFile == null) {
+ return CALLBACK_ERROR_MSG;
+ }
+
+ ReentrantLock lock = OnlyOfficeKeyCacheUtils.getLock(onlyOfficeFile);
+ lock.lock();
+ if (log.isDebugEnabled()) {
+ log.debug("开始处理 OnlyOffice 文件: {}, 加锁", key);
+ }
+ try {
+ // 错误状态仅清缓存, 不写入存储.
+ if (!SAVE_STATUS.contains(onlyOfficeCallback.getStatus())) {
+ return CALLBACK_SUCCESS_MSG;
+ }
+
+ // 校验回调 body 中的 key 是否与 token payload 中的 key 一致, 防止用其他文档的 token 写本文档.
+ if (!isCallbackTokenKeyMatched(onlyOfficeCallback)) {
+ log.warn("OnlyOffice 回调 Token 与 key 不匹配: key={}, status={}",
+ key, onlyOfficeCallback.getStatus());
+ return CALLBACK_ERROR_MSG;
+ }
+
+ // 预览发放时不允许编辑, 即使签名有效也拒绝写入.
+ if (!onlyOfficeFile.isAllowEdit()) {
+ log.warn("OnlyOffice 回调保存被拒绝: 预览配置不允许编辑, key={}, storageKey={}, path={}",
+ key, onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
+ return CALLBACK_ERROR_MSG;
+ }
+
+ AbstractBaseFileService> storageServiceByKey = StorageSourceContext.getByStorageKey(onlyOfficeFile.getStorageKey());
+ if (storageServiceByKey == null) {
+ return CALLBACK_ERROR_MSG;
+ }
+
+ Integer cachedUserId = onlyOfficeFile.getUserId();
+ Integer storageId = storageSourceService.findIdByKey(onlyOfficeFile.getStorageKey());
+ // 使用预览时缓存的用户身份重新校验权限, 而非信任 callback 中的 users.
+ if (storageId == null
+ || !userStorageSourceService.hasUserStorageOperatorPermission(cachedUserId, storageId, FileOperatorTypeEnum.UPLOAD)) {
+ log.warn("OnlyOffice 回调保存被拒绝: 缓存用户无上传权限, key={}, storageId={}, userId={}",
+ key, storageId, cachedUserId);
+ return CALLBACK_ERROR_MSG;
+ }
+
+ // 切换为预览时记录的用户身份, 让下游 service 的 getCurrentUserBasePath / 权限判断生效.
+ if (cachedUserId != null) {
+ StpUtil.login(cachedUserId);
+ }
+
+ if (log.isDebugEnabled()) {
+ log.debug("开始保存 OnlyOffice 文件: {}, {}", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
+ }
+
+ if (Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) {
+ AbstractProxyTransferService> proxyUploadService = (AbstractProxyTransferService>) storageServiceByKey;
+ try {
+ URLConnection connection = openOnlyOfficeDownloadConnection(onlyOfficeCallback.getUrl(), systemConfig.getOnlyOfficeUrl());
+ long contentLength = connection.getContentLengthLong();
+ try (InputStream inputStream = connection.getInputStream()) {
+ // 使用原始相对路径(不含用户根目录), 由 uploadFile 内部按当前登录身份重新拼接 getCurrentUserBasePath,
+ // 避免缓存中的完整路径再叠加一次根目录, 把文件写到 /根目录/根目录/文件名 这样的错误位置.
+ String originalPath = onlyOfficeFile.getOriginalPath();
+ proxyUploadService.uploadFile(originalPath, inputStream, contentLength);
+ }
+ } catch (IOException | URISyntaxException e) {
+ log.warn("OnlyOffice 回调下载地址拒绝或访问失败: key={}, urlHost={}, reason={}",
+ key, safeHost(onlyOfficeCallback.getUrl()), e.getMessage());
+ return CALLBACK_ERROR_MSG;
+ } catch (Exception e) {
+ log.error("回调保存 OnlyOffice 文件失败: key={}, storageKey={}",
+ key, onlyOfficeFile.getStorageKey(), e);
+ return CALLBACK_ERROR_MSG;
+ }
+ }
+
+ if (log.isDebugEnabled()) {
+ log.debug("完成保存 OnlyOffice 文件: {}, {}", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
+ }
+ } finally {
+ if (log.isDebugEnabled()) {
+ log.debug("完成处理 OnlyOffice 文件: {}, 解锁", key);
+ }
+ lock.unlock();
+ }
+ return CALLBACK_SUCCESS_MSG;
+ }
+
+ /**
+ * 校验 callback body 中的 key 与 JWT payload 中的 key 一致.
+ * 防止: 攻击者拿到文档 A 的 token, 在 body 中改 key 指向文档 B.
+ */
+ private boolean isCallbackTokenKeyMatched(OnlyOfficeCallback onlyOfficeCallback) {
+ try {
+ JWT jwt = JWTUtil.parseToken(onlyOfficeCallback.getToken());
+ Object callbackKey = jwt.getPayload("key");
+ if (callbackKey != null && Objects.equals(onlyOfficeCallback.getKey(), callbackKey.toString())) {
+ return true;
+ }
+
+ // 预览生成时, key 嵌套在 document.key 中.
+ Object document = jwt.getPayload("document");
+ if (document instanceof java.util.Map) {
+ Object documentKey = ((java.util.Map, ?>) document).get("key");
+ if (documentKey != null && Objects.equals(onlyOfficeCallback.getKey(), documentKey.toString())) {
+ return true;
+ }
+ }
+ return false;
+ } catch (Exception e) {
+ log.warn("OnlyOffice 回调 Token 解析失败: key={}, status={}",
+ onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
+ return false;
+ }
+ }
+
+ /**
+ * 严格校验 OnlyOffice 回调中的下载地址必须与系统配置中的 OnlyOffice Document Server 同源,
+ * 同时限制重定向次数与超时, 避免 SSRF 触达内网或任意域名.
+ */
+ private URLConnection openOnlyOfficeDownloadConnection(String callbackUrl, String onlyOfficeUrl) throws IOException, URISyntaxException {
+ URI currentUri = parseHttpUri(callbackUrl);
+ URI onlyOfficeUri = parseHttpUri(onlyOfficeUrl);
+
+ for (int i = 0; i <= ONLY_OFFICE_DOWNLOAD_MAX_REDIRECTS; i++) {
+ validateOnlyOfficeUrl(currentUri, onlyOfficeUri);
+ URLConnection connection = currentUri.toURL().openConnection();
+ connection.setConnectTimeout(ONLY_OFFICE_DOWNLOAD_CONNECT_TIMEOUT_MILLIS);
+ connection.setReadTimeout(ONLY_OFFICE_DOWNLOAD_READ_TIMEOUT_MILLIS);
+
+ if (connection instanceof HttpURLConnection httpConnection) {
+ // 关闭自动重定向, 手动校验每一跳目标依然命中白名单.
+ httpConnection.setInstanceFollowRedirects(false);
+ int responseCode = httpConnection.getResponseCode();
+ if (isRedirect(responseCode)) {
+ String location = httpConnection.getHeaderField("Location");
+ httpConnection.disconnect();
+ if (StrUtil.isBlank(location)) {
+ throw new IOException("OnlyOffice 下载地址重定向缺少 Location");
+ }
+ currentUri = currentUri.resolve(location);
+ continue;
+ }
+ }
+
+ return connection;
+ }
+
+ throw new IOException("OnlyOffice 下载地址重定向次数超过限制");
+ }
+
+ private URI parseHttpUri(String rawUrl) throws URISyntaxException {
+ if (StrUtil.isBlank(rawUrl)) {
+ throw new URISyntaxException("", "OnlyOffice URL 不能为空");
+ }
+ URI uri = new URI(rawUrl).normalize();
+ String scheme = uri.getScheme();
+ if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
+ throw new URISyntaxException(rawUrl, "OnlyOffice URL 仅支持 http/https");
+ }
+ if (StrUtil.isBlank(uri.getHost())) {
+ throw new URISyntaxException(rawUrl, "OnlyOffice URL host 不能为空");
+ }
+ return uri;
+ }
+
+ private void validateOnlyOfficeUrl(URI callbackUri, URI onlyOfficeUri) throws IOException {
+ if (!normalizeScheme(callbackUri).equals(normalizeScheme(onlyOfficeUri))
+ || !normalizeHost(callbackUri).equals(normalizeHost(onlyOfficeUri))
+ || normalizePort(callbackUri) != normalizePort(onlyOfficeUri)) {
+ throw new IOException("OnlyOffice 下载地址与配置的 Document Server 不匹配");
+ }
+ }
+
+ private String normalizeScheme(URI uri) {
+ return uri.getScheme().toLowerCase(Locale.ROOT);
+ }
+
+ private String normalizeHost(URI uri) {
+ return uri.getHost().toLowerCase(Locale.ROOT);
+ }
+
+ private int normalizePort(URI uri) {
+ if (uri.getPort() != -1) {
+ return uri.getPort();
+ }
+ return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
+ }
+
+ private boolean isRedirect(int responseCode) {
+ return responseCode == HttpURLConnection.HTTP_MOVED_PERM
+ || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
+ || responseCode == HttpURLConnection.HTTP_SEE_OTHER
+ || responseCode == 307
+ || responseCode == 308;
+ }
+
+ private String safeHost(String rawUrl) {
+ try {
+ return new URI(rawUrl).getHost();
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java
new file mode 100644
index 0000000..aa93e67
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java
@@ -0,0 +1,92 @@
+package im.zhaojun.zfile.module.onlyoffice.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class OnlyOfficeCallback {
+
+ /**
+ * 定义编辑的文档标识符。
+ */
+ private String key;
+
+ /**
+ * 定义文档的状态。 可以有以下值:
+ * 1 - 正在编辑文档,
+ * 2 - 文档已准备好保存,
+ * 3 - 发生文档保存错误,
+ * 4 - 文档已关闭,没有任何更改,
+ * 6 - 正在编辑文档,但保存了当前文档状态,
+ * 7 - 强制保存文档时发生错误。
+ */
+ private int status;
+
+ /**
+ * 定义已编辑的要由文档存储服务保存的文档的链接。 仅当 status 值等于 2, 3, 6 或 7 时,链接才存在。
+ */
+ private String url;
+
+ /**
+ * 定义有文档更改历史的对象。 仅当 status 值等于 2 或 3 时,对象才存在。
+ * 它包含对象 changes 和 serverVersion,它们必须作为对象的属性 changes 和 serverVersion 以参数形式发送给 refreshHistory 方法。
+ */
+ private Map history;
+
+ /**
+ * 定义打开文档进行编辑的用户的标识符列表;
+ * 当文档被更改时,用户将返回最后编辑文档的用户的标识符(对于 status 2 和 status 6 的应答)。
+ */
+ private List users;
+
+ /**
+ * 定义当用户对文档执行操作时接收到的对象。type 字段值可以具有以下值:
+ * 0 - 用户断开与文档共同编辑的连接,
+ * 1 - 新用户连接到文档共同编辑,
+ * 2 - 用户单击 强制保存按钮。
+ * userid 字段值是用户标识符。
+ */
+ private List actions;
+
+ /**
+ * 定义文档的最后保存日期和时间。
+ */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX")
+ private Date lastsave;
+
+ /**
+ * 文档是否未被修改。
+ */
+ private boolean notmodified;
+
+ /**
+ * JWT 令牌,用于验证用户。
+ */
+ private String token;
+
+ /**
+ * 定义从 url 参数指定的链接下载文档的扩展名。 文件类型默认为 OOXML,但如果启用了 assemblyFormatAsOrigin 服务器设置,则文件将以原始格式保存。
+ */
+ private String filetype;
+
+ // Inner class for actions
+ @Data
+ public static class Action {
+
+ /**
+ * 定义当用户对文档执行操作时接收到的对象。type 字段值可以具有以下值:
+ * 0 - 用户断开与文档共同编辑的连接,
+ * 1 - 新用户连接到文档共同编辑,
+ * 2 - 用户单击 强制保存按钮。
+ * userid 字段值是用户标识符。
+ */
+ private int type;
+
+ private String userid;
+
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java
new file mode 100644
index 0000000..b881b8c
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java
@@ -0,0 +1,59 @@
+package im.zhaojun.zfile.module.onlyoffice.model;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+/**
+ * OnlyOffice 缓存文件信息.
+ *
+ * 缓存的相等性仅以 {@code storageKey + pathAndName} 为准, 保证 {@link im.zhaojun.zfile.core.util.OnlyOfficeKeyCacheUtils}
+ * 通过文件路径就能命中之前发放预览时存储的上下文(包含发起预览的用户 ID 与编辑权限).
+ */
+@Data
+@NoArgsConstructor
+@EqualsAndHashCode(of = {"storageKey", "pathAndName"})
+public class OnlyOfficeFile {
+
+ private String storageKey;
+
+ /**
+ * 完整路径(已拼接用户根目录 / 分享根目录), 作为缓存键, 保证不同用户根目录下同名文件互不冲突,
+ * 同时让同一物理文件的多人预览能命中同一个 OnlyOffice 协同编辑 key.
+ */
+ private String pathAndName;
+
+ /**
+ * 用户视角的原始相对路径(不含用户根目录). 回调写回时使用该路径调用 {@code uploadFile},
+ * 让下游存储实现内部再拼接 {@code getCurrentUserBasePath()}, 避免重复拼接导致文件落到错误位置.
+ */
+ private String originalPath;
+
+ /**
+ * 发起预览的用户 ID. 用于 OnlyOffice 回调时的权限再校验与上下文切换,
+ * 防止回调请求伪造 {@code users} 字段越权写入.
+ */
+ private Integer userId;
+
+ /**
+ * 生成预览配置时是否允许编辑保存. 回调写入前会再次校验此字段, 拒绝当时不允许编辑的请求.
+ */
+ private boolean allowEdit;
+
+ /**
+ * 仅用于通过 {@code storageKey + pathAndName} 查询/失效缓存的轻量构造器.
+ */
+ public OnlyOfficeFile(String storageKey, String pathAndName) {
+ this.storageKey = storageKey;
+ this.pathAndName = pathAndName;
+ }
+
+ public OnlyOfficeFile(String storageKey, String pathAndName, String originalPath, Integer userId, boolean allowEdit) {
+ this.storageKey = storageKey;
+ this.pathAndName = pathAndName;
+ this.originalPath = originalPath;
+ this.userId = userId;
+ this.allowEdit = allowEdit;
+ }
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/service/OnlyOfficeConfigService.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/service/OnlyOfficeConfigService.java
new file mode 100644
index 0000000..a6ee1db
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/service/OnlyOfficeConfigService.java
@@ -0,0 +1,89 @@
+package im.zhaojun.zfile.module.onlyoffice.service;
+
+import cn.hutool.jwt.JWTUtil;
+import com.alibaba.fastjson2.JSONObject;
+import im.zhaojun.zfile.core.util.FileUtils;
+import im.zhaojun.zfile.core.util.OnlyOfficeKeyCacheUtils;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.core.util.ZFileAuthUtil;
+import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
+import im.zhaojun.zfile.module.config.service.SystemConfigService;
+import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
+import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
+import im.zhaojun.zfile.module.user.model.entity.User;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * OnlyOffice 预览配置生成服务. 同时被普通预览与分享预览复用,
+ * 统一处理 Key 缓存、JWT 签名以及编辑权限判定, 避免两个入口对预览配置的处理逻辑出现偏差.
+ *
+ * @author zhaojun
+ */
+@Service
+public class OnlyOfficeConfigService {
+
+ @Resource
+ private SystemConfigService systemConfigService;
+
+ /**
+ * 生成 OnlyOffice 预览配置.
+ *
+ * 同时将发起预览的用户 ID、是否允许编辑写入 {@link OnlyOfficeFile} 缓存, 供回调时校验.
+ *
+ * @param fileItemResult 文件信息
+ * @param onlyOfficeFile 文件缓存信息(包含 storageKey/fullPath/userId)
+ * @param allowEdit 预览发起时的编辑权限
+ * @return OnlyOffice 预览配置 JSON
+ */
+ public JSONObject createConfig(FileItemResult fileItemResult,
+ OnlyOfficeFile onlyOfficeFile,
+ boolean allowEdit) {
+ SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
+ String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();
+ // 未配置 Secret 时回调无法验签, 直接降级为只读, 防止生成虚假的编辑配置.
+ boolean effectiveAllowEdit = allowEdit && StringUtils.isNotEmpty(onlyOfficeSecret);
+ onlyOfficeFile.setAllowEdit(effectiveAllowEdit);
+ String key = OnlyOfficeKeyCacheUtils.getKeyOrPutNew(onlyOfficeFile, 3000);
+ return buildPayload(fileItemResult, key, effectiveAllowEdit, systemConfig);
+ }
+
+ private JSONObject buildPayload(FileItemResult fileItemResult, String key, boolean allowEdit, SystemConfigDTO systemConfig) {
+ JSONObject jsonObject = new JSONObject();
+ jsonObject.put("width", "100%");
+ jsonObject.put("height", "100%");
+
+ jsonObject.put("document", new JSONObject()
+ .fluentPut("fileType", FileUtils.getExtension(fileItemResult.getName()))
+ .fluentPut("key", key)
+ .fluentPut("permissions", new JSONObject()
+ .fluentPut("edit", allowEdit))
+ .fluentPut("title", fileItemResult.getName())
+ .fluentPut("url", fileItemResult.getUrl())
+ .fluentPut("lang", "zh-CN"));
+
+ User currentUser = ZFileAuthUtil.getCurrentUser();
+ String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();
+
+ JSONObject userJson = new JSONObject();
+ if (currentUser != null) {
+ userJson.fluentPut("id", currentUser.getId())
+ .fluentPut("name", StringUtils.firstNonNull(currentUser.getNickname(), currentUser.getUsername()));
+ }
+
+ jsonObject.put("editorConfig", new JSONObject()
+ .fluentPut("callbackUrl", StringUtils.concat(systemConfigService.getAxiosFromDomainOrSetting(), "/onlyOffice/callback"))
+ .fluentPut("lang", "zh-CN")
+ .fluentPut("user", userJson));
+
+ if (StringUtils.isNotEmpty(onlyOfficeSecret)) {
+ String token = JWTUtil.createToken(jsonObject, onlyOfficeSecret.getBytes(StandardCharsets.UTF_8));
+ jsonObject.put("token", token);
+ }
+
+ return jsonObject;
+ }
+
+}
diff --git a/src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java b/src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java
new file mode 100644
index 0000000..ad4c1de
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java
@@ -0,0 +1,52 @@
+package im.zhaojun.zfile.module.password.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.annotation.DemoDisable;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.module.password.model.entity.PasswordConfig;
+import im.zhaojun.zfile.module.password.service.PasswordConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 存储源密码维护接口
+ *
+ * @author zhaojun
+ */
+@Tag(name = "存储源模块-密码文件夹")
+@ApiSort(6)
+@RestController
+@RequestMapping("/admin")
+public class StorageSourcePasswordController {
+
+ @Resource
+ private PasswordConfigService passwordConfigService;
+
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "获取存储源密码文件夹列表", description ="根据存储源 ID 获取存储源设置的密码文件夹列表")
+ @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer"))
+ @GetMapping("/storage/{storageId}/password")
+ public AjaxJson> getPasswordList(@PathVariable Integer storageId) {
+ return AjaxJson.getSuccessData(passwordConfigService.findByStorageId(storageId));
+ }
+
+
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "保存存储源密码文件夹列表", description ="保存指定存储源 ID 设置的密码文件夹列表")
+ @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer"))
+ @PostMapping("/storage/{storageId}/password")
+ @DemoDisable
+ public AjaxJson savePasswordList(@PathVariable Integer storageId, @RequestBody List password) {
+ passwordConfigService.batchSave(storageId, password);
+ return AjaxJson.getSuccess();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java
new file mode 100644
index 0000000..f1b4d5b
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java
@@ -0,0 +1,39 @@
+package im.zhaojun.zfile.module.password.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import im.zhaojun.zfile.module.password.model.entity.PasswordConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 存储源密码配置表 Mapper 接口
+ *
+ * @author zhaojun
+ */
+@Mapper
+public interface PasswordConfigMapper extends BaseMapper {
+
+ /**
+ * 根据存储源 ID 获取密码规则配置
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @return 存储源密码规则配置列表
+ */
+ List findByStorageId(@Param("storageId") Integer storageId);
+
+
+ /**
+ * 根据存储源 ID 删除要密码规则配置
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @return 删除记录数
+ */
+ int deleteByStorageId(@Param("storageId") Integer storageId);
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java b/src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java
new file mode 100644
index 0000000..c1d6471
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java
@@ -0,0 +1,51 @@
+package im.zhaojun.zfile.module.password.model.dto;
+
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import lombok.Data;
+
+/**
+ * 用于表示校验结果的类
+ *
+ * @author zhaojun
+ */
+@Data
+public class VerifyResultDTO {
+
+ /**
+ * 是否成功
+ */
+ private boolean passed;
+
+ /**
+ * 表达式
+ */
+ private String pattern;
+
+ /**
+ * 错误消息
+ */
+ private ErrorCode errorCode;
+
+ public static VerifyResultDTO success() {
+ VerifyResultDTO verifyResultDTO = new VerifyResultDTO();
+ verifyResultDTO.setPassed(true);
+ return verifyResultDTO;
+ }
+
+
+ public static VerifyResultDTO success(String pattern) {
+ VerifyResultDTO verifyResultDTO = new VerifyResultDTO();
+ verifyResultDTO.setPassed(true);
+ verifyResultDTO.setPattern(pattern);
+ return verifyResultDTO;
+ }
+
+
+ public static VerifyResultDTO fail(ErrorCode errorCode) {
+ VerifyResultDTO verifyResultDTO = new VerifyResultDTO();
+ verifyResultDTO.setPassed(false);
+ verifyResultDTO.setErrorCode(errorCode);
+ return verifyResultDTO;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java b/src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java
new file mode 100644
index 0000000..8f67620
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java
@@ -0,0 +1,49 @@
+package im.zhaojun.zfile.module.password.model.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 密码设置 entity
+ *
+ * @author zhaojun
+ */
+@Data
+@Schema(title="密码设置")
+@TableName(value = "password_config")
+public class PasswordConfig implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.INPUT)
+ @Schema(title = "ID, 新增无需填写", example = "1")
+ private Integer id;
+
+
+ @TableField(value = "storage_id")
+ @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer storageId;
+
+
+ @TableField(value = "expression")
+ @Schema(title = "密码文件夹表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png")
+ private String expression;
+
+
+ @TableField(value = "password")
+ @Schema(title = "密码值", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
+ private String password;
+
+
+ @TableField(value = "description")
+ @Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
+ private String description;
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java b/src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java
new file mode 100644
index 0000000..50a4a2a
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java
@@ -0,0 +1,268 @@
+package im.zhaojun.zfile.module.password.service;
+
+import cn.hutool.core.util.ObjectUtil;
+import im.zhaojun.zfile.core.exception.ErrorCode;
+import im.zhaojun.zfile.core.util.CollectionUtils;
+import im.zhaojun.zfile.core.util.PatternMatcherUtils;
+import im.zhaojun.zfile.core.util.StringUtils;
+import im.zhaojun.zfile.module.password.mapper.PasswordConfigMapper;
+import im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;
+import im.zhaojun.zfile.module.password.model.entity.PasswordConfig;
+import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;
+import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;
+import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
+import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.aop.framework.AopContext;
+import org.springframework.beans.BeanUtils;
+import org.springframework.cache.annotation.CacheConfig;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 存储源密码配置 Service
+ *
+ * @author zhaojun
+ */
+@Service
+@Slf4j
+@CacheConfig(cacheNames = "passwordConfig")
+public class PasswordConfigService {
+
+ @Resource
+ private PasswordConfigMapper passwordConfigMapper;
+
+ @Resource
+ private UserStorageSourceService userStorageSourceService;
+
+
+ /**
+ * 根据存储源 ID 查询密码规则列表
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @return 密码规则列表
+ */
+ @Cacheable(key = "#storageId",
+ condition = "#storageId != null")
+ public List findByStorageId(Integer storageId) {
+ return passwordConfigMapper.findByStorageId(storageId);
+ }
+
+
+ /**
+ * 批量保存指定存储源 ID 的密码规则列表
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param passwordConfigList
+ * 存储源类别
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void batchSave(Integer storageId, List passwordConfigList) {
+ ((PasswordConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);
+ log.info("更新存储源 ID 为 {} 的过滤规则 {} 条", storageId, passwordConfigList.size());
+
+ passwordConfigList.forEach(passwordConfig -> {
+ passwordConfig.setId(null);
+ passwordConfig.setStorageId(storageId);
+ passwordConfigMapper.insert(passwordConfig);
+
+ if (log.isDebugEnabled()) {
+ log.debug("新增过滤规则, 存储源 ID: {}, 表达式: {}, 描述: {}, 密码: {}",
+ passwordConfig.getStorageId(), passwordConfig.getExpression(),
+ passwordConfig.getDescription(), passwordConfig.getPassword());
+ }
+ });
+ }
+
+
+ /**
+ * 根据存储源 id 删除所有密码规则
+ *
+ * @param storageId
+ * 存储源 ID
+ */
+ @CacheEvict(key = "#storageId")
+ public int deleteByStorageId(Integer storageId) {
+ int deleteSize = passwordConfigMapper.deleteByStorageId(storageId);
+ log.info("删除存储源 ID 为 {} 的密码规则 {} 条", storageId, deleteSize);
+ return deleteSize;
+ }
+
+ /**
+ * 监听存储源删除事件,根据存储源 id 删除相关的密码设置
+ *
+ * @param storageSourceDeleteEvent
+ * 存储源删除事件
+ */
+ @EventListener
+ public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) {
+ Integer storageId = storageSourceDeleteEvent.getId();
+ int updateRows = ((PasswordConfigService) AopContext.currentProxy()).deleteByStorageId(storageId);
+ if (log.isDebugEnabled()) {
+ log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源密码设置 {} 条",
+ storageId,
+ storageSourceDeleteEvent.getName(),
+ storageSourceDeleteEvent.getType().getDescription(),
+ updateRows);
+ }
+ }
+
+ /**
+ * 校验密码
+ *
+ * @param storageId
+ * 存储源 ID
+ *
+ * @param path
+ * 请求路径(全路径,包含用户目录)
+ *
+ * @param inputPassword
+ * 用户输入的密码
+ *
+ * @return 是否校验通过
+ */
+ public VerifyResultDTO verifyPassword(Integer storageId, String path, String inputPassword) {
+ // 判断是否需要忽略密码校验
+ boolean isIgnorePassword = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.IGNORE_PASSWORD);
+ if (isIgnorePassword) {
+ if (log.isDebugEnabled()) {
+ log.debug("权限配置忽略密码校验, 请求路径: {}, 存储源 ID: {}, 输入密码: {}", path, storageId, inputPassword);
+ }
+ return VerifyResultDTO.success();
+ }
+
+ List passwordConfigList = ((PasswordConfigService) AopContext.currentProxy()).findByStorageId(storageId);
+
+ // 如果规则列表为空, 则表示不需要过滤, 直接返回 false
+ if (CollectionUtils.isEmpty(passwordConfigList)) {
+ if (log.isDebugEnabled()) {
+ log.debug("密码规则列表为空, 请求路径: {}, 存储源 ID: {}, 输入密码: {}", path, storageId, inputPassword);
+ }
+ return VerifyResultDTO.success();
+ }
+
+ // 校验密码
+ for (PasswordConfig passwordConfig : passwordConfigList) {
+ String expression = passwordConfig.getExpression();
+ String expectPassword = passwordConfig.getPassword();
+
+ // 规则为空跳过
+ if (StringUtils.isEmpty(expression)) {
+ if (log.isDebugEnabled()) {
+ log.debug("密码规则测试表达式: {}, 请求路径: {}, 表达式为空,跳过该规则比对", expression, path);
+ }
+ continue;
+ }
+
+ try {
+ // 判断当前请求路径是否和规则路径表达式匹配
+ boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, path);
+
+ if (log.isDebugEnabled()) {
+ log.debug("密码规则测试表达式: {}, 请求路径: {}, 匹配结果: {}, 预期密码: {}, 输入密码; {}", expression, path, match, expectPassword, inputPassword);
+ }
+
+ // 如果匹配且输入了密码则校验
+ if (match) {
+ if (StringUtils.isEmpty(inputPassword)) {
+ if (log.isDebugEnabled()) {
+ log.debug("密码规则匹配, 但未输入密码;" +
+ "表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}",
+ expression, path, storageId, expectPassword, inputPassword);
+ }
+ return VerifyResultDTO.fail(ErrorCode.BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_REQUIRED);
+ }
+
+ if (matchPassword(expectPassword, inputPassword)) {
+ if (log.isDebugEnabled()) {
+ log.debug("密码规则匹配, 密码校验通过;" +
+ "表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}",
+ expression, path, storageId, expectPassword, inputPassword);
+ }
+ return VerifyResultDTO.success(expression);
+ }
+
+ if (log.isDebugEnabled()) {
+ log.debug("密码规则匹配, 但输入密码与预期密码不同;" +
+ "表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}",
+ expression, path, storageId, expectPassword, inputPassword);
+ }
+ return VerifyResultDTO.fail(ErrorCode.BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_ERROR);
+ }
+ } catch (Exception e) {
+ log.error("密码规则匹配出现异常,表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}, 解析错误, 跳过此规则.",
+ expression, path, storageId, expectPassword, inputPassword, e);
+ }
+ }
+
+ if (log.isDebugEnabled()) {
+ log.debug("校验文件夹密码 path: {}, 没有匹配的表达式, 不进行密码校验.", path);
+ }
+
+ return VerifyResultDTO.success();
+ }
+
+
+ /**
+ * 校验两个密码是否相同, 忽略空白字符
+ *
+ * @param expectedPasswordContent
+ * 预期密码
+ *
+ * @param password
+ * 实际输入密码
+ *
+ * @return 是否匹配
+ */
+ private boolean matchPassword(String expectedPasswordContent, String password) {
+ if (Objects.equals(expectedPasswordContent, password)) {
+ return true;
+ }
+
+ // 如果预期密码或输入密码为空, 则不匹配
+ if (ObjectUtil.hasNull(expectedPasswordContent, password)) {
+ return false;
+ }
+
+ expectedPasswordContent = StringUtils.removeAllLineBreaksAndTrim(expectedPasswordContent);
+ password = StringUtils.removeAllLineBreaksAndTrim(password);
+ return Objects.equals(expectedPasswordContent, password);
+ }
+
+
+ /**
+ * 监听存储源复制事件, 复制存储源时, 复制存储源密码设置
+ *
+ * @param storageSourceCopyEvent
+ * 存储源复制事件
+ */
+ @EventListener
+ public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) {
+ Integer fromId = storageSourceCopyEvent.getFromId();
+ Integer newId = storageSourceCopyEvent.getNewId();
+
+ List passwordConfigList = ((PasswordConfigService) AopContext.currentProxy()).findByStorageId(fromId);
+
+ passwordConfigList.forEach(passwordConfig -> {
+ PasswordConfig newPasswordConfig = new PasswordConfig();
+ BeanUtils.copyProperties(passwordConfig, newPasswordConfig);
+ newPasswordConfig.setId(null);
+ newPasswordConfig.setStorageId(newId);
+ passwordConfigMapper.insert(newPasswordConfig);
+ });
+
+ log.info("复制存储源 ID 为 {} 的存储源密码设置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, passwordConfigList.size());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java b/src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java
new file mode 100644
index 0000000..5f12198
--- /dev/null
+++ b/src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java
@@ -0,0 +1,37 @@
+package im.zhaojun.zfile.module.permission.controller;
+
+import com.github.xiaoymin.knife4j.annotations.ApiSort;
+import im.zhaojun.zfile.core.util.AjaxJson;
+import im.zhaojun.zfile.module.permission.model.result.PermissionInfoResult;
+import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@Tag(name = "权限模块")
+@ApiSort(6)
+@RestController
+@RequestMapping("/admin/permission")
+public class PermissionController {
+
+ @GetMapping("list")
+ public AjaxJson