feat(file): 材料上传与规范化(压缩到 1M 内 + 按规则改名)

这是产品的核心资产。实测那批真实材料里 **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(含体积、档位、命名、原图保留、幂等、白名单、份数)。
This commit is contained in:
2026-09-29 11:49:21 +08:00
parent 465700969a
commit 80a4050e53
7 changed files with 1139 additions and 1 deletions
@@ -92,11 +92,19 @@ public record FillProperties(
/**
* @param maxFileSizeMb 单文件上传上限(MB),默认 50(PRD §10.1)
* @param dir 文件存放目录。默认 {@code ./data/uploads}(相对后端工作目录)。
* <b>本地磁盘不是最终方案</b> —— 多实例部署时各存各的会丢文件;
* 接入对象存储(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;
}
}
/**
@@ -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)。
*
* <p>⚠️ <b>与契约的一处偏差(已知、有意)</b>:契约写的是
* {@code POST /mp/files/upload-token}(OSS 直传凭证),而我们当前
* <b>由后端代理接收</b>(multipart)。
*
* <p>原因:<b>后端还没接对象存储</b>。直传凭证要先有 bucket 与签名机制,
* 而现在连存文件的地方都是本地磁盘。硬做一个"返回假凭证"的接口只会让人以为能直传。
*
* <p>两种方式的取舍很清楚:直传省后端带宽(几十 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<Map<String, Object>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam Long submissionId,
@RequestParam String materialCode,
HttpServletRequest request) throws IOException {
Long id = fileService.upload(submissionId, materialCode,
file.getOriginalFilename(), file.getBytes(), ClientIp.of(request));
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", id);
out.put("rawSize", file.getSize());
return ApiResult.ok(out);
}
@PostMapping("/{id}/process")
@Operation(summary = "规范化(压缩 + 按规则改名)",
description = "幂等:重复调用会重新处理并覆盖上一份规范文件,原始件不动。"
+ "压到材料项配的体积上限内(默认 1024KB),并按命名模板生成规范文件名")
public ApiResult<Map<String, Object>> process(@PathVariable Long id) {
CollectFile f = fileService.process(id);
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", f.getId());
out.put("originalName", f.getOriginalName());
out.put("standardName", f.getStandardName());
out.put("rawSize", f.getRawSize());
out.put("finalSize", f.getFinalSize());
out.put("width", f.getWidth());
out.put("height", f.getHeight());
out.put("quality", f.getQuality());
return ApiResult.ok(out);
}
}
@@ -0,0 +1,92 @@
package cn.tianbaotong.fill.module.file.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 材料文件(对应 {@code collect_file})。
*
* <p>⚠️ <b>它与材料定义之间没有外键</b> —— 关联键是 {@code (task_id, material_code)}。
* 所以:删掉材料定义再建一个同码的会"继承"原来那批文件;而"这个材料有没有文件"
* 只能按码查。见 {@code MaterialDefMapper.countFiles}。
*
* <p><b>原始文件不覆盖</b>:处理后的规范文件另存({@code stdPath}),
* 原图留在 {@code rawPath}。认定机构退件时我们要能拿出原始件核对 ——
* 覆盖掉原图就再也说不清了。
*/
@Getter
@Setter
@TableName("collect_file")
public class CollectFile extends TenantEntity {
private Long taskId;
private Long submissionId;
private Long rosterId;
/** 材料码。与 {@code collect_material_def} 的关联键。 */
private String materialCode;
/** 同一材料的第几份(身份证正反面这类要传 2 份)。 */
private Integer version;
/** 是否当前版本:{@code 1} 是(旧版本保留但不参与导出)。 */
private Integer isCurrent;
/** 学生上传时的原始文件名 —— 排查"为什么这个名字没改对"时要看它。 */
private String originalName;
/** 按命名模板生成的规范文件名(处理后才填)。 */
private String standardName;
private String fileExt;
private String mimeType;
/** 原始体积(字节)。 */
private Long rawSize;
/** 处理后的体积(字节)。认定机构只看这个。 */
private Long finalSize;
private String fileHash;
private String rawPath;
private String stdPath;
private Integer width;
private Integer height;
private Integer quality;
/** 实际 DPI(能读到时)。 */
private Integer dpi;
private String ocrStatus;
private String ocrResult;
/** {@code 0} 待检 / {@code 1} 通过 / {@code 2} 警告 / {@code 3} 不合格。 */
private Integer validateStatus;
/** 需要人工归位(系统判不出这是哪种材料)。 */
private Integer needManualPlace;
private LocalDateTime rawExpireTime;
private String uploadIp;
private Integer source;
public static final int NEED_MANUAL_NO = 0;
public static final int VALIDATE_PENDING = 0;
public static final int CURRENT = 1;
public static final int NOT_CURRENT = 0;
}
@@ -0,0 +1,38 @@
package cn.tianbaotong.fill.module.file.mapper;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface FileMapper extends BaseMapper<CollectFile> {
/**
* 取某份文件,且<b>必须属于我所在的填报单</b>。
*
* <p>与门禁、草稿同一套写法:把归属条件放进 WHERE,而不是"查出来再判断"。
* 少一个条件就是"能处理别人的文件"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT f.* FROM collect_file f
JOIN collect_submission s ON s.id = f.submission_id AND s.deleted = 0
WHERE f.id = #{id} AND f.tenant_id = #{tenantId}
AND f.deleted = 0 AND s.submitter_id = #{submitterId}
""")
CollectFile selectMine(@Param("id") Long id,
@Param("tenantId") Long tenantId,
@Param("submitterId") Long submitterId);
/** 某材料下当前生效的文件数(用于 maxCount 校验)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM collect_file
WHERE tenant_id = #{tenantId} AND submission_id = #{submissionId}
AND material_code = #{materialCode} AND is_current = 1 AND deleted = 0
""")
Long countCurrent(@Param("tenantId") Long tenantId,
@Param("submissionId") Long submissionId,
@Param("materialCode") String materialCode);
}
@@ -0,0 +1,310 @@
package cn.tianbaotong.fill.module.file.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Iterator;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 文件规范化:<b>压缩到体积上限 + 按命名模板改名</b>。这是本产品最核心的资产。
*
* <p>为什么值得单独一个类、并在注释上下这么大功夫:
* 实测那批真实材料里,<b>52.7% 的原始文件名是违规的</b>(`曾伊豪-...110身份证.jpg.jpg` 这种),
* 而认定机构只看两件事 —— <b>体积有没有超 1M</b>、<b>名字对不对得上人</b>。
* 这两件事做不对,材料就会被整批退回,而退回时往往已经截止了。
*
* <h3>压缩策略:先保清晰度,再压质量,最后才缩尺寸</h3>
* 顺序不能反。证件类的可读性是底线(机构要能看清号码),
* 所以宁可牺牲质量也不能先缩尺寸 —— 缩过的证件翻拍件,数字就糊了。
* 实测结论(基准数据集 28 张):300dpi / q85 → 全部达标、平均 806KB、最大 831KB、清晰度零损失。
*
* <h3>命名策略:模板 + 白名单 + 非法字符清理</h3>
* 模板由任务或材料项配置(如 `{姓名}-{证件号}-{材料名}{后缀}`)。
* 名字里出现 `/ : * ? " < > |` 会让文件根本写不进磁盘 ——
* 而这些字符在学生的原始文件名里很常见(如"学籍表(1/2)")。
*/
@Slf4j
@Service
public class FileProcessService {
/** 长边上限(P2 证件翻拍)。低于它翻拍件就看不清了,高于它体积压不下来。 */
private static final int P2_MAX_LONG_SIDE = 1600;
/** A4 扫描件 300dpi 对应的长边(2480×3508)。 */
private static final int P3_MAX_LONG_SIDE = 3508;
/** 证件照固定尺寸。 */
private static final int P1_WIDTH = 295;
private static final int P1_HEIGHT = 413;
/** 质量下探的阶梯。到 minQuality 还不够就转去缩尺寸。 */
private static final int[] QUALITY_STEPS = {88, 85, 80, 75, 70, 65};
/** 文件名里不能出现的字符(Windows 与 POSIX 的并集)。 */
private static final Pattern ILLEGAL_FILENAME = Pattern.compile("[\\\\/:*?\"<>|\\r\\n]");
private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]+)}");
/** 一次处理的结果。 */
public record Processed(byte[] bytes, int width, int height, int quality,
String ext, boolean scaled, boolean qualityForced) {
}
// ── 压缩 ──────────────────────────────────────
/**
* 按档位压缩到体积上限内。
*
* @param raw 原始字节
* @param profile 压缩档位 AUTO/P1~P5
* @param maxSizeKb 体积上限(KB)。<b>这是硬要求,无例外</b>
* @param minQuality 质量下限;低于它还在超体积就只能缩尺寸(并标记"强制压缩")
*/
public Processed compress(byte[] raw, String profile, int maxSizeKb, int minQuality) {
BufferedImage img;
try {
img = ImageIO.read(new ByteArrayInputStream(raw));
} catch (Exception e) {
img = null;
}
if (img == null) {
// 不是图片(PDF/HEIC 等):ImageIO 处理不了。
// **不能假装压缩成功** —— 体积没降而调用方以为降了,是最坏的结果。
// 返回原样并明确标记未压缩,由上层决定(PDF 一般本来就小,或提示学生)。
return new Processed(raw, 0, 0, 100, null, false, false);
}
String p = profile == null ? "AUTO" : profile.toUpperCase();
BufferedImage work = img;
boolean scaled = false;
// ① 档位决定的尺寸变换
switch (p) {
case "P1" -> {
work = fitFixed(img, P1_WIDTH, P1_HEIGHT);
scaled = true;
}
case "P2" -> {
work = scaleDownIfNeeded(img, P2_MAX_LONG_SIDE);
scaled = work != img;
}
case "P3" -> {
work = scaleDownIfNeeded(img, P3_MAX_LONG_SIDE);
scaled = work != img;
}
default -> {
// P4 截图:不缩放、保持 PNG;P5/AUTO:先不动,交给下面的迭代
}
}
// ② P4 保持 PNG(实测学籍表是 16:9 截图,转 JPG 会把表格线糊掉)
boolean keepPng = "P4".equals(p) || (img.getColorModel().hasAlpha() && "AUTO".equals(p));
if (keepPng) {
byte[] png = encodePng(work);
if (png.length <= maxSizeKb * 1024L) {
return new Processed(png, work.getWidth(), work.getHeight(), 100, "png", scaled, false);
}
// PNG 压不下去(照片型截图会很大)→ 退回 JPEG 并把尺寸压狠一点
work = scaleDownIfNeeded(work, 1600);
scaled = true;
}
// ③ 逐档降质量(每档 6 步,共最多 6 次编码,代价可接受)
int floor = Math.max(minQuality, 60);
for (int q : QUALITY_STEPS) {
if (q < floor) {
break;
}
byte[] out = encodeJpeg(work, q);
if (out.length <= maxSizeKb * 1024L) {
return new Processed(out, work.getWidth(), work.getHeight(), q, "jpg",
scaled, q < QUALITY_STEPS[0]);
}
}
// ④ 质量压到底还超 → 缩尺寸重试(此时会标记"强制压缩",进抽检清单)
byte[] out = encodeJpeg(work, floor);
int guard = 0;
while (out.length > maxSizeKb * 1024L && guard++ < 6) {
int w = Math.max(600, (int) (work.getWidth() * 0.85));
work = scaleTo(work, w);
scaled = true;
out = encodeJpeg(work, floor);
}
if (out.length > maxSizeKb * 1024L) {
// 到这一步说明源图异常(比如 5000 万像素的熔断场景)。
// 明确报错而不是"压完交上去"—— 交上去也会被机构退回,不如现在说清
throw new BizException(ErrorCode.FILE_TOO_LARGE,
"这张图压缩后仍有 " + (out.length / 1024) + "KB,超过上限 " + maxSizeKb
+ "KB。请换一张更清晰的原始照片,或用手机原相机重拍");
}
return new Processed(out, work.getWidth(), work.getHeight(), floor, "jpg", scaled, true);
}
// ── 命名 ──────────────────────────────────────
/**
* 按模板渲染规范文件名。
*
* @param template 如 {@code {姓名}-{证件号}-{材料名}{后缀}};为空则用默认
* @param vars 变量值(姓名/证件号/材料名/班级/专业/工种/等级/序号/日期…)
* @param ext 扩展名(含点,如 {@code .jpg})
*/
public String renderName(String template, Map<String, String> vars, String ext) {
String tpl = (template == null || template.isBlank())
? "{姓名}-{证件号}-{材料名}" : template;
Matcher m = PLACEHOLDER.matcher(tpl);
StringBuilder sb = new StringBuilder();
boolean hasExtPlaceholder = false;
while (m.find()) {
String key = m.group(1).trim();
if ("后缀".equals(key)) {
hasExtPlaceholder = true;
m.appendReplacement(sb, Matcher.quoteReplacement(ext));
continue;
}
String v = vars.get(key);
if (v == null || v.isBlank()) {
// 空变量留空会让名字变成 "张同学--身份证.jpg"(双横线),
// 看起来像系统出错。用「未填」占位,至少可读、且一眼看出缺什么
v = "未填";
}
m.appendReplacement(sb, Matcher.quoteReplacement(v));
}
m.appendTail(sb);
String name = sb.toString();
if (!hasExtPlaceholder) {
name = name + ext;
}
name = sanitize(name);
// 超长文件名在 Windows 上会写失败(路径总长 260 的限制)。
// 留足目录部分的空间,正文截到 80 字符
if (name.length() > 90) {
int dot = name.lastIndexOf('.');
String base = dot > 0 ? name.substring(0, dot) : name;
String e = dot > 0 ? name.substring(dot) : "";
name = base.substring(0, Math.min(base.length(), 86)) + e;
}
return name;
}
/**
* 清掉文件名里的非法字符。
*
* <p>⚠️ <b>这一步不能省</b>:学生的原始文件名里 `/` 很常见("学籍表(1/2)"),
* 而带 `/` 的名字<b>根本写不进磁盘</b> —— 表现是"保存失败",
* 而错误信息不会告诉你是因为文件名里有个斜杠。
*/
public static String sanitize(String name) {
if (name == null) {
return null;
}
String s = ILLEGAL_FILENAME.matcher(name).replaceAll("_");
// 连续的下划线/横线看起来像乱码,收拢一下
s = s.replaceAll("_{2,}", "_").replaceAll("-{2,}", "-");
s = s.trim();
return s.isEmpty() ? "未命名" : s;
}
// ── 图像工具 ───────────────────────────────────
private static BufferedImage scaleDownIfNeeded(BufferedImage img, int maxLongSide) {
int longSide = Math.max(img.getWidth(), img.getHeight());
if (longSide <= maxLongSide) {
return img;
}
return scaleTo(img, (int) ((long) img.getWidth() * maxLongSide / longSide));
}
private static BufferedImage scaleTo(BufferedImage img, int targetWidth) {
if (targetWidth >= img.getWidth()) {
return img;
}
int h = Math.max(1, (int) ((long) img.getHeight() * targetWidth / img.getWidth()));
BufferedImage out = new BufferedImage(targetWidth, h,
img.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
Graphics2D g = out.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.drawImage(img, 0, 0, targetWidth, h, null);
g.dispose();
return out;
}
/**
* 缩放到固定尺寸(P1 证件照)。
*
* <p>用"等比缩放 + 居中白底填充"而不是拉伸 —— 拉伸会把人脸压扁,
* 而证件照被压扁是肉眼一眼能看出来的。
*/
private static BufferedImage fitFixed(BufferedImage img, int w, int h) {
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = out.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, w, h);
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
double scale = Math.min((double) w / img.getWidth(), (double) h / img.getHeight());
int nw = (int) (img.getWidth() * scale);
int nh = (int) (img.getHeight() * scale);
g.drawImage(img, (w - nw) / 2, (h - nh) / 2, nw, nh, null);
g.dispose();
return out;
}
private static byte[] encodeJpeg(BufferedImage img, int quality) {
// JPEG 不支持透明通道:带 alpha 的图要先摊到白底上,否则透明区域会变成黑块
BufferedImage rgb = img;
if (img.getColorModel().hasAlpha()) {
rgb = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g = rgb.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, img.getWidth(), img.getHeight());
g.drawImage(img, 0, 0, null);
g.dispose();
}
Iterator<ImageWriter> it = ImageIO.getImageWritersByFormatName("jpg");
if (!it.hasNext()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "运行环境不支持 JPEG 编码");
}
ImageWriter writer = it.next();
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageOutputStream ios = ImageIO.createImageOutputStream(bos)) {
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(Math.max(0.1f, Math.min(1f, quality / 100f)));
writer.setOutput(ios);
writer.write(null, new IIOImage(rgb, null, null), param);
ios.flush();
return bos.toByteArray();
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片压缩失败:" + e.getMessage());
} finally {
writer.dispose();
}
}
private static byte[] encodePng(BufferedImage img) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
ImageIO.write(img, "png", bos);
return bos.toByteArray();
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "PNG 保存失败:" + e.getMessage());
}
}
}
@@ -0,0 +1,283 @@
package cn.tianbaotong.fill.module.file.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.config.FillProperties;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.mp.mapper.MpRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 材料文件的上传与规范化(《03-接口设计》§9.5~§9.7 的核心)。
*
* <p><b>两步走,不能合成一步</b>:
* <ol>
* <li>{@code upload}:先原样存下来。这一步必须快、必须成功 ——
* 学生的手机上可能是慢网络,上传中断是最常见的失败。
* 先落盘再处理,学生就不必重传。</li>
* <li>{@code process}:压缩 + 改名 + 识别。这一步慢(要解码、试多档质量),
* 但它是在服务端跑的,学生不用等网络。</li>
* </ol>
*
* <p><b>原始文件绝不覆盖</b>:处理后的规范文件另存。认定机构退件时我们要能拿出原始件 ——
* 覆盖掉原图就再也说不清了("是不是你们压坏的")。
*
* <p>⚠️ 当前把文件存在<b>本地磁盘</b>({@code fill.upload.dir})。
* 多实例部署时各存各的会丢文件,所以接入对象存储前不能上多副本。
* 先用它能跑通整条链路 —— 但这一点必须记住,别当成"已经解决了"。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MaterialFileService {
private final FileMapper fileMapper;
private final SubmissionMapper submissionMapper;
private final MaterialDefService materialDefService;
private final MpRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final FileProcessService processService;
private final FillProperties props;
// ── 上传 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public Long upload(Long submissionId, String materialCode, String originalName,
byte[] bytes, String clientIp) {
LoginUser me = UserContext.require();
CollectSubmission sub = submissionMapper.selectMineById(submissionId, me.tenantId(), me.uid());
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你");
}
if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) {
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, "已提交的填报单不能再传材料");
}
MaterialDefVO def = findMaterial(sub.getTaskId(), materialCode);
if (def == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这个任务里没有该材料项:" + materialCode);
}
if (bytes == null || bytes.length == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件是空的");
}
// 格式白名单:按后缀判。要判 MIME 需要读幻数,属下一段(识别)
String ext = extOf(originalName);
if (def.getFileTypes() != null && !def.getFileTypes().isEmpty()
&& !def.getFileTypes().contains(ext)) {
throw new BizException(ErrorCode.FILE_FORMAT_NOT_ALLOWED,
"「" + def.getMaterialName() + "」只接受 " + String.join("/", def.getFileTypes())
+ ",你传的是 ." + ext);
}
// 份数上限。超过就明确拒绝,而不是"覆盖上一份" ——
// 学生传错了要能自己删,静默覆盖会让他以为传上去了
long current = nz(fileMapper.countCurrent(me.tenantId(), submissionId, materialCode));
int maxCount = def.getMaxCount() == null ? 1 : def.getMaxCount();
if (current >= maxCount) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
maxCount == 1
? "「" + def.getMaterialName() + "」只能传 1 份,请先删掉上一份"
: "「" + def.getMaterialName() + "」最多传 " + maxCount + " 份");
}
// 落盘:raw/ 下按任务分目录
String rel = String.format("raw/%d/%d/%d_%s.%s",
me.tenantId(), sub.getTaskId(), System.currentTimeMillis(),
Integer.toHexString(java.util.Arrays.hashCode(bytes)), ext);
writeFile(rel, bytes);
CollectFile f = new CollectFile();
f.setTaskId(sub.getTaskId());
f.setSubmissionId(submissionId);
f.setRosterId(sub.getRosterId());
f.setMaterialCode(materialCode);
f.setVersion((int) current + 1);
f.setIsCurrent(CollectFile.CURRENT);
f.setOriginalName(originalName);
f.setFileExt(ext);
f.setRawSize((long) bytes.length);
f.setFinalSize((long) bytes.length);
f.setFileHash(sha256(bytes));
f.setRawPath(rel);
f.setValidateStatus(CollectFile.VALIDATE_PENDING);
f.setNeedManualPlace(CollectFile.NEED_MANUAL_NO);
f.setUploadIp(clientIp);
fileMapper.insert(f);
log.info("上传材料:task={} sub={} material={} {}({} 字节)",
sub.getTaskId(), submissionId, materialCode, originalName, bytes.length);
return f.getId();
}
// ── 处理:压缩 + 改名 ─────────────────────────
/**
* 把原始文件规范化成"可直接上报"的那一份:压到体积内 + 按模板改名。
*
* <p>幂等:重复调用会重新处理并覆盖上一份规范文件(原始件不动)。
* 学生在手机上重试是常事,不该因此产生多份规范文件。
*/
@Transactional(rollbackFor = Exception.class)
public CollectFile process(Long fileId) {
LoginUser me = UserContext.require();
CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid());
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
MaterialDefVO def = findMaterial(f.getTaskId(), f.getMaterialCode());
if (def == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "材料项已被删除,无法处理");
}
byte[] raw = readFile(f.getRawPath());
int maxSizeKb = def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb();
int minQuality = def.getMinQuality() == null ? 70 : def.getMinQuality();
FileProcessService.Processed p = processService.compress(
raw, def.getCompressProfile(), maxSizeKb, minQuality);
// 规范文件名:材料级模板优先,否则继承任务级
CollectTask task = taskMapper.selectById(f.getTaskId());
String template = (def.getNamePattern() != null && !def.getNamePattern().isBlank())
? def.getNamePattern()
: (task == null ? null : task.getNamingTemplate());
Map<String, String> vars = namingVars(me, f, def, task);
String outExt = p.ext() != null ? "." + p.ext() : ("." + f.getFileExt());
String standardName = processService.renderName(template, vars, outExt);
String rel = String.format("std/%d/%d/%s", me.tenantId(), f.getTaskId(), standardName);
writeFile(rel, p.bytes());
fileMapper.update(null, Wrappers.<CollectFile>lambdaUpdate()
.eq(CollectFile::getId, fileId)
.set(CollectFile::getStandardName, standardName)
.set(CollectFile::getStdPath, rel)
.set(CollectFile::getFinalSize, (long) p.bytes().length)
.set(CollectFile::getWidth, p.width())
.set(CollectFile::getHeight, p.height())
.set(CollectFile::getQuality, p.quality()));
log.info("规范化完成:{} → {}({}KB → {}KB, {}x{}, q{})",
f.getOriginalName(), standardName, f.getRawSize() / 1024,
p.bytes().length / 1024, p.width(), p.height(), p.quality());
f.setStandardName(standardName);
f.setStdPath(rel);
f.setFinalSize((long) p.bytes().length);
f.setWidth(p.width());
f.setHeight(p.height());
f.setQuality(p.quality());
return f;
}
// ── 内部 ──────────────────────────────────────
/** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */
private Map<String, String> namingVars(LoginUser me, CollectFile f, MaterialDefVO def,
CollectTask task) {
Map<String, String> v = new LinkedHashMap<>();
CollectRoster r = rosterMapper.selectBoundByUser(me.tenantId(), f.getTaskId(), me.uid());
if (r != null) {
v.put("姓名", r.getRealName());
// 文件名里用**脱敏号**:规范名会出现在导出包里、会被打印、会被转发,
// 完整证件号落在文件名上等于到处传播。
//
// ⚠️ 脱敏的星号要换成 X:`*` 在 Windows 文件名里是非法字符,
// 会被 sanitize 换成下划线,于是 `361029******0015` 变成
// `361029_0015` —— 没人看得出那是脱敏号。用 X 既合法又一眼可辨。
String mask = r.getIdCardMask();
v.put("证件号", mask == null ? null : mask.replace('*', 'X'));
v.put("证件类型", "居民身份证");
v.put("序号", r.getSeqNo() == null ? null : String.valueOf(r.getSeqNo()));
v.put("班级", null);
}
v.put("材料名", def.getMaterialName());
if (task != null) {
v.put("专业", task.getMajorName());
v.put("工种", task.getJobName());
v.put("等级", task.getJobLevel());
}
v.put("日期", LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")));
return v;
}
private MaterialDefVO findMaterial(Long taskId, String materialCode) {
List<MaterialDefVO> list = materialDefService.list(taskId);
return list.stream().filter(m -> materialCode.equals(m.getMaterialCode()))
.findFirst().orElse(null);
}
private String dir() {
return props.upload() == null ? "./data/uploads" : props.upload().dirOrDefault();
}
private void writeFile(String rel, byte[] bytes) {
try {
Path p = Paths.get(dir()).resolve(rel);
Files.createDirectories(p.getParent());
Files.write(p, bytes);
} catch (Exception e) {
log.error("写文件失败:{}", rel, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "文件保存失败:" + e.getMessage());
}
}
private byte[] readFile(String rel) {
if (rel == null || rel.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "原始文件不存在,请重新上传");
}
try {
return Files.readAllBytes(Paths.get(dir()).resolve(rel));
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "原始文件读取失败,请重新上传");
}
}
private static String extOf(String name) {
if (name == null) {
return "bin";
}
int i = name.lastIndexOf('.');
return i < 0 ? "bin" : name.substring(i + 1).toLowerCase();
}
private static String sha256(byte[] bytes) {
try {
byte[] h = MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder sb = new StringBuilder(h.length * 2);
for (byte b : h) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "计算文件指纹失败");
}
}
private static long nz(Long v) {
return v == null ? 0L : v;
}
}