diff --git a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java
index 8e007d8..93e1260 100644
--- a/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java
+++ b/src/main/java/com/gxwebsoft/common/core/storage/FileStorageService.java
@@ -88,8 +88,16 @@ public class FileStorageService {
if (!isRemote()) {
File out = FileServerUtil.upload(file, getUploadDir().getAbsolutePath() + "/", uuidName);
+ String contentType = FileServerUtil.getContentType(out);
+ try {
+ UploadRules.check(originalName, contentType);
+ } catch (RuntimeException e) {
+ // 已经落盘才判定失败,把刚写下的文件删掉,不留孤儿
+ FileUtil.del(out);
+ throw e;
+ }
String path = "/" + dateDir + "/" + out.getName();
- return new StoredFile(path, out.length(), FileServerUtil.getContentType(out),
+ return new StoredFile(path, out.length(), contentType,
StrUtil.isBlank(originalName) ? out.getName() : originalName);
}
@@ -108,6 +116,8 @@ public class FileStorageService {
file.transferTo(tmp);
try {
String contentType = FileServerUtil.getContentType(tmp);
+ // 临时文件由 finally 清理,失败直接抛出即可
+ UploadRules.check(originalName, contentType);
putObject(KEY_PREFIX + normalize(path), tmp, contentType);
return new StoredFile(path, tmp.length(), contentType,
StrUtil.isBlank(originalName) ? tmp.getName() : originalName);
diff --git a/src/main/java/com/gxwebsoft/common/core/storage/UploadRejectedException.java b/src/main/java/com/gxwebsoft/common/core/storage/UploadRejectedException.java
new file mode 100644
index 0000000..4e4db90
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/core/storage/UploadRejectedException.java
@@ -0,0 +1,18 @@
+package com.gxwebsoft.common.core.storage;
+
+/**
+ * 上传被准入规则拒绝。
+ *
+ * 单独的异常类型是为了让控制层能把它和真正的系统异常区分开:
+ * 前者把原因原样回给用户("不支持的文件格式"),后者一律回"上传失败"。
+ *
+ * @author WebSoft
+ */
+public class UploadRejectedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public UploadRejectedException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/common/core/storage/UploadRules.java b/src/main/java/com/gxwebsoft/common/core/storage/UploadRules.java
new file mode 100644
index 0000000..8c5f26c
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/core/storage/UploadRules.java
@@ -0,0 +1,124 @@
+package com.gxwebsoft.common.core.storage;
+
+import cn.hutool.core.util.StrUtil;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 上传文件的格式准入规则(后端唯一防线)。
+ *
+ * 规则分两层,故意不对"扩展名"做严格的一一绑定:
+ *
+ * - 探测出的 MIME 是 {@code image/*} 一律放行——头像、图标、编辑器插图的历史格式很多,
+ * 用后缀白名单会误伤;
+ * - 其余按扩展名白名单放行(文档 / 音频 / 视频),
+ * 再用一份"危险 MIME"黑名单兜住"把 exe 改名成 .pdf"这类情况。
+ *
+ * 之所以不用"扩展名必须等于探测类型"的严格校验:OOXML(docx/xlsx/pptx)经 Tika 探测
+ * 可能得到 {@code application/zip},md/csv 会得到 {@code text/plain},严格比对会把
+ * 正常文件判死。黑名单 + 后缀白名单的组合能挡住可执行文件,又不会误杀办公文档。
+ *
+ * 前端 {@code src/utils/media.ts} 的 {@code MEDIA_ACCEPT} 是这份规则的镜像,两处要一起改。
+ *
+ * @author WebSoft
+ */
+public final class UploadRules {
+
+ public static final String IMAGE = "image";
+ public static final String VIDEO = "video";
+ public static final String AUDIO = "audio";
+ public static final String DOC = "doc";
+
+ /**
+ * 扩展名 -> 媒体大类,只登记非图片类型(图片走 MIME 判断)
+ */
+ private static final Map SUFFIX_CATEGORY;
+
+ /**
+ * 探测失败(octet-stream)时按后缀放行的图片格式
+ */
+ private static final Set IMAGE_SUFFIXES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+ "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico", "tif", "tiff", "heic", "avif"
+ )));
+
+ /**
+ * 危险 MIME 前缀:可执行文件、脚本、快捷方式,改名换后缀也不放行
+ */
+ private static final List DENIED_MIME_PREFIXES = Arrays.asList(
+ "application/x-msdownload", "application/x-dosexec", "application/x-executable",
+ "application/x-sharedlib", "application/x-sh", "application/x-msi",
+ "application/x-ms-shortcut", "application/x-bat", "application/x-perl",
+ "application/x-python", "application/x-httpd-php", "application/x-javascript",
+ "text/x-php", "text/x-shellscript", "text/x-python"
+ );
+
+ static {
+ Map m = new HashMap<>();
+ for (String suffix : Arrays.asList("mp4", "mov", "webm")) {
+ m.put(suffix, VIDEO);
+ }
+ for (String suffix : Arrays.asList("mp3", "wav", "m4a", "aac")) {
+ m.put(suffix, AUDIO);
+ }
+ for (String suffix : Arrays.asList("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv")) {
+ m.put(suffix, DOC);
+ }
+ SUFFIX_CATEGORY = Collections.unmodifiableMap(m);
+ }
+
+ private UploadRules() {
+ }
+
+ /**
+ * 校验一个待入库的文件,不通过时抛出 {@link UploadRejectedException}
+ *
+ * @param fileName 原始文件名,用于取后缀
+ * @param contentType 服务端探测出的 MIME,可为空
+ */
+ public static void check(String fileName, String contentType) {
+ String mime = StrUtil.nullToEmpty(contentType).toLowerCase(Locale.ROOT);
+ String suffix = suffixOf(fileName);
+
+ if (mime.startsWith("image/")) {
+ return;
+ }
+ for (String denied : DENIED_MIME_PREFIXES) {
+ if (mime.startsWith(denied)) {
+ throw new UploadRejectedException("不支持的文件格式" + describe(suffix) + ",仅支持图片、文档、音频、视频");
+ }
+ }
+ if (IMAGE_SUFFIXES.contains(suffix) && (mime.isEmpty() || "application/octet-stream".equals(mime))) {
+ // 探测不出来但后缀是图片,按图片放行
+ return;
+ }
+ if (SUFFIX_CATEGORY.containsKey(suffix)) {
+ return;
+ }
+ throw new UploadRejectedException("不支持的文件格式" + describe(suffix) + ",仅支持图片、文档、音频、视频");
+ }
+
+ /**
+ * 文件名对应的媒体大类,用于服务端需要分类时,未知返回 null
+ */
+ public static String categoryOf(String fileName) {
+ return SUFFIX_CATEGORY.get(suffixOf(fileName));
+ }
+
+ private static String suffixOf(String fileName) {
+ if (StrUtil.isBlank(fileName) || fileName.lastIndexOf('.') < 0) {
+ return "";
+ }
+ return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
+ }
+
+ private static String describe(String suffix) {
+ return StrUtil.isBlank(suffix) ? "" : ":" + suffix;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/controller/FileController.java b/src/main/java/com/gxwebsoft/common/system/controller/FileController.java
index 6995edc..45660e1 100644
--- a/src/main/java/com/gxwebsoft/common/system/controller/FileController.java
+++ b/src/main/java/com/gxwebsoft/common/system/controller/FileController.java
@@ -5,6 +5,7 @@ import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.storage.FileStorageService;
import com.gxwebsoft.common.core.storage.StoredFile;
+import com.gxwebsoft.common.core.storage.UploadRejectedException;
import com.gxwebsoft.common.core.utils.FileServerUtil;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
@@ -56,6 +57,9 @@ public class FileController extends BaseController {
result = buildRecord(stored);
fileRecordService.save(result);
return success(result);
+ } catch (UploadRejectedException e) {
+ // 格式准入失败:把原因原样回给前端,不要吞成"上传失败"
+ return fail(e.getMessage(), result);
} catch (Exception e) {
e.printStackTrace();
return fail("上传失败", result).setError(e.toString());
diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml
index 468c19b..a4ad0a4 100644
--- a/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml
+++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml
@@ -50,6 +50,17 @@
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
+
+
+ AND
+
+ a.content_type LIKE 'image/%'
+ a.content_type LIKE 'video/%'
+ a.content_type LIKE 'audio/%'
+ (a.content_type LIKE 'application/%' OR a.content_type LIKE 'text/%')
+
+
AND a.merchant_code LIKE CONCAT('%', #{param.merchantCode}, '%')
diff --git a/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java b/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java
index 97dc845..6f833e0 100644
--- a/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java
+++ b/src/main/java/com/gxwebsoft/common/system/param/FileRecordParam.java
@@ -48,6 +48,15 @@ public class FileRecordParam extends BaseParam {
@ApiModelProperty("文件类型")
private String contentType;
+ /**
+ * 媒体大类:image / video / audio / doc。
+ *
+ * 区别于 contentType 的 MIME 字符串——"文档"这个大类在 MIME 里没有可用的前缀
+ * (pdf、docx、zip 都是 application/…),只能由 SQL 侧展开成具体类型匹配。
+ */
+ @ApiModelProperty("媒体大类, image/video/audio/doc")
+ private String mediaCategory;
+
@ApiModelProperty("是否删除, 0否, 1是")
@TableLogic
private Integer deleted;