From 80a4050e5325b54996b1ab16e2db0b7b07c7913f Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Tue, 29 Sep 2026 11:49:21 +0800
Subject: [PATCH] =?UTF-8?q?feat(file):=20=E6=9D=90=E6=96=99=E4=B8=8A?=
=?UTF-8?q?=E4=BC=A0=E4=B8=8E=E8=A7=84=E8=8C=83=E5=8C=96=EF=BC=88=E5=8E=8B?=
=?UTF-8?q?=E7=BC=A9=E5=88=B0=201M=20=E5=86=85=20+=20=E6=8C=89=E8=A7=84?=
=?UTF-8?q?=E5=88=99=E6=94=B9=E5=90=8D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
这是产品的核心资产。实测那批真实材料里 **52.7% 的原始文件名是违规的**
(`曾伊豪-...110身份证.jpg.jpg` 这种),而认定机构只看两件事:
**体积有没有超 1M**、**名字对不对得上人**。这两件事做不对,
材料会被整批退回 —— 而退回时往往已经截止了。
两步走,不能合成一步:
① upload:先原样存下来。这一步必须快、必须成功 —— 学生的手机可能是慢网络,
上传中断是最常见的失败。先落盘,学生就不必重传。
② process:压缩 + 改名。慢(要解码、试多档质量),但在服务端跑,学生不用等网络。
压缩策略:**先保清晰度,再压质量,最后才缩尺寸**。顺序不能反 ——
证件类的可读性是底线(机构要能看清号码),缩过的证件翻拍件数字就糊了。
实测:11.6MB 的 2400x3200 原图 → **712KB**,1020x1360(P2 长边限 1600),q70。
命名策略:模板 + 白名单 + 非法字符清理。
⚠️ 非法字符清理不能省:学生的原始文件名里 `/` 很常见("学籍表(1/2)"),
而带 `/` 的名字**根本写不进磁盘** —— 表现是"保存失败",
而错误信息不会告诉你是因为文件名里有个斜杠。
原始文件**绝不覆盖**(另存 std/):认定机构退件时我们要能拿出原始件,
覆盖掉原图就再也说不清了("是不是你们压坏的")。E2E 断言了原图一字不差。
⚠️ 与契约的一处偏差(已知、有意):契约写的是 POST /mp/files/upload-token
(OSS 直传凭证),当前**后端代理接收**(multipart)。原因:后端还没接对象存储,
直传凭证要先有 bucket 与签名机制。硬做一个"返回假凭证"的接口只会让人以为能直传。
接入对象存储后可以并存:小文件走代理、大文件走直传。
⚠️ 当前把文件存在**本地磁盘**(fill.upload.dir)。多实例部署时各存各的会丢文件,
所以接入对象存储前**不能上多副本**。这一点必须记住,别当成"已经解决了"。
E2E 里修了一个夹具陷阱(值得记):用**裸 SQL** 插材料项会绕过 MaterialCatalog
的默认值(DDL 列默认是 AUTO / 1 份),于是"档位没生效""份数不对" ——
而这两条都会被误当成代码 bug。实测就踩了:IDCARD 期望 P2 + 2 份,
裸 SQL 插出来是 AUTO + 1 份,AUTO 的行为(不缩放、先降质量)被当成了"P2 没生效"。
细节修正:文件名里的脱敏号用 X 而不是下划线。`*` 在 Windows 文件名里非法,
被 sanitize 换成下划线后 `361029******0015` 变成 `361029_0015` ——
没人看得出那是脱敏号。现在是 `361029XXXXXX0015`。
验收:e2e-m3-file.py 29/29(含体积、档位、命名、原图保留、幂等、白名单、份数)。
---
.../fill/config/FillProperties.java | 10 +-
.../controller/MaterialFileController.java | 80 +++++
.../fill/module/file/entity/CollectFile.java | 92 +++++
.../fill/module/file/mapper/FileMapper.java | 38 ++
.../file/service/FileProcessService.java | 310 +++++++++++++++++
.../file/service/MaterialFileService.java | 283 +++++++++++++++
scripts/e2e-m3-file.py | 327 ++++++++++++++++++
7 files changed, 1139 insertions(+), 1 deletion(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/entity/CollectFile.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/FileProcessService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java
create mode 100644 scripts/e2e-m3-file.py
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java
index 4b691c8..b7442dc 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java
@@ -92,11 +92,19 @@ public record FillProperties(
/**
* @param maxFileSizeMb 单文件上传上限(MB),默认 50(PRD §10.1)
+ * @param dir 文件存放目录。默认 {@code ./data/uploads}(相对后端工作目录)。
+ * 本地磁盘不是最终方案 —— 多实例部署时各存各的会丢文件;
+ * 接入对象存储(MinIO/OSS)后这里换成 bucket 配置。
+ * 但现在先用它能跑通整条链路,不阻塞。
*/
- public record Upload(Integer maxFileSizeMb) {
+ public record Upload(Integer maxFileSizeMb, String dir) {
public int maxFileSizeMbOrDefault() {
return maxFileSizeMb == null ? 50 : maxFileSizeMb;
}
+
+ public String dirOrDefault() {
+ return dir == null || dir.isBlank() ? "./data/uploads" : dir;
+ }
}
/**
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java
new file mode 100644
index 0000000..e65ad0d
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java
@@ -0,0 +1,80 @@
+package cn.tianbaotong.fill.module.file.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.module.file.entity.CollectFile;
+import cn.tianbaotong.fill.module.file.service.MaterialFileService;
+import cn.tianbaotong.fill.support.ClientIp;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 材料上传与规范化(《03-接口设计》§9.5~§9.7)。
+ *
+ *
⚠️ 与契约的一处偏差(已知、有意):契约写的是
+ * {@code POST /mp/files/upload-token}(OSS 直传凭证),而我们当前
+ * 由后端代理接收(multipart)。
+ *
+ *
原因:后端还没接对象存储。直传凭证要先有 bucket 与签名机制,
+ * 而现在连存文件的地方都是本地磁盘。硬做一个"返回假凭证"的接口只会让人以为能直传。
+ *
+ *
两种方式的取舍很清楚:直传省后端带宽(几十 MB 的翻拍件不经过服务器),
+ * 但代理接收能在服务端做**格式白名单、份数校验、体积预检** ——
+ * 而这些是我们现在就要的。接入对象存储后可以两者并存:
+ * 小文件走代理、大文件走直传。
+ */
+@Tag(name = "小程序/H5 · 材料上传")
+@RestController
+@RequestMapping("/api/v1/mp/files")
+@RequiredArgsConstructor
+public class MaterialFileController {
+
+ private final MaterialFileService fileService;
+
+ @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "上传材料(后端代理)",
+ description = "先原样存下来再处理 —— 上传中断是最常见的失败,先落盘学生就不必重传。"
+ + "格式白名单与份数上限在这里校验")
+ public ApiResult