fix(file): 修掉 EXIF 朝向 bug(竖拍照片会躺倒)+ 上传改一步 + 默认不留原始件

一、客户决定:不用保持原件,只要压缩满足效果
   fill.storage.keep-raw 开关,默认 false。交付物就是压缩后的规范件。
   顺带把上传从两步变一步:
     原来 upload(存原图)→ process(读原图→压缩→存规范件)
     现在 upload(收上来→立刻压缩→只存规范件)
   留原始件是两步存在的**唯一理由**;不要它之后这步只剩代价
   (多一个"传了但没处理"的中间态、多一次往返、多一份存储)。
   process 保留为"重新规范化",但没有原始件时**直接返回现状而不报错**。

二、🐛 抓到一个会命中绝大多数真实照片的 bug:EXIF 朝向
   手机竖拍时**像素其实是横的** + 一个 Orientation=6(显示时转 90°)。
   浏览器认这个标记所以看着是正的;而 ImageIO **不读 EXIF**、重编码又丢掉它 →
   输出成了"像素横着、也没有标记"的图 → **机构看到的是一张转了 90° 的身份证**。

   实测(修前):输入 3200×2400(横)+ O=6 → 输出 1600×1200(仍横)、EXIF=None ❌
   修后:1200×1600(竖)✓ 且输出不再带 EXIF(否则会被转两次)

   **为什么 E2E 一直没抓到**:测试图都是程序生成的、不带 EXIF;
   而真实学生手机拍的竖拍照片几乎全带 —— 这个 bug 会命中绝大多数真实材料,
   是靠"想到这一层"发现的,不是靠测试。

   实现:module/file/service/ExifOrientation.java
     · 自己解析 JPEG 的 APP1/EXIF(只用一个 tag,三十行,不引新依赖)
     · **读不出来时一律"不转"** —— 宁可保持原样,也不凭猜测把图转错
     · 八种朝向用**逐像素索引映射**,不用 AffineTransform:
       索引一看就懂、容易对着规范核;AffineTransform 写错了很难发现(图还是能出,只是方向不对)
     · **转正必须在任何缩放之前** —— 转置会互换宽高,尺寸计算得基于转正后的

三、为"前端压缩"铺路的关键改动
   compress() 现在会判断"是不是已经达标了":
     没触发缩放 && 没有 EXIF 需转正 && 体积本来就在上限内 → **原样返回,不重编码**
   因为前端马上会按材料项目标先压一遍(会转正并去掉 EXIF),服务端再压就是
   **JPEG 二次编码** —— 白掉一次画质。quality 用 NOT_REENCODED=0 标记"没重编码过",
   排查"这张图为什么有点糊"时能一眼看出被压过几次。
   这条对 P4 截图尤其合适:原样保持 PNG,表格线一点不糊。

四、顺手修的
   · url/content 的 original=true:没有原始件时**优雅退回到交付件**并如实标注
     (FileUrl 加 original 字段)—— 学生点"看原图"只是想看自己传的那份,
     报"文件不存在"是纯粹的困惑
   · sniffExt():按**字节幻数**判类型,不信文件名后缀(学生传的 .jpg 里装 PNG 是常事,
     而这个结论会写进数据库、决定导出包的扩展名)

五、E2E
   e2e-m3-file 37/37,新增第 7 节专守 EXIF:
     输入 3200×2400(横)+ O=6 → 输出 1200x1600
     ✓ 输出是竖的 ✓ 长边仍受 P2 约束 ✓ 不再带 EXIF
   原始件相关断言改成**按策略断言**(开/关两种都对),而不是写死"必须保留"——
   那会让"改配置"看起来像"代码坏了"。
   全量 467 条断言、0 失败。

⏳ 还没做:**前端压缩**("传 800KB 而不是 3MB")。本轮做的是为它铺路:
   服务端不再二次编码 + EXIF 转正机制已就位(前端要用 JS 做同样的事)。
   前端要做:照片类读 EXIF→canvas 旋正→长边 1600→q85;P1/P4 不压直传;
   压完仍超就回退原图让服务端兜底。

⚠️ 流程坑(本轮白花三次编译):**不要把 Java 字符串字面量放进 PowerShell here-string
   传给 Python** —— @"..."@ 会把 " 吃掉(return "bin"; 变成 return bin;)。
   带引号的代码一律用 edit 工具改,不要过转义层。
This commit is contained in:
2026-09-29 15:58:08 +08:00
parent fe7615d12b
commit 12067b8ae4
8 changed files with 556 additions and 65 deletions
@@ -49,7 +49,8 @@ public record FillProperties(
String provider,
String prefix,
Qiniu qiniu,
Boolean selfCheckOnStartup
Boolean selfCheckOnStartup,
Boolean keepRaw
) {
public String providerOrDefault() {
// 默认 local:这样"什么都不配"也能把项目跑起来,
@@ -64,6 +65,23 @@ public record FillProperties(
public boolean selfCheckEnabled() {
return Boolean.TRUE.equals(selfCheckOnStartup);
}
/**
* 是否保留学生上传的**原始件**。
*
* <p><b>默认 false(不保留)</b> —— 这是客户明确过的决定:
* 交付物就是压缩后的规范件,不需要拿原图当退件凭证(原来保留原始件
* 是为了"机构说看不清时能证明不是我们压坏的",这条被放弃了)。
*
* <p>⚠️ 但要注意它顺带放弃的是<b>重做的能力</b>:
* 如果之后要改压缩参数(项目里还有两个没定的:一寸照片阈值标定、
* 机构是否接受 300dpi 扫描件),没有原图就只能<b>让所有学生重传一遍</b>。
* 所以这个开关留着 —— 需要在任务归档前留个保险时,改一行配置即可
* (原始件会存到原始路径,不参与交付,只作审计与重处理的来源)。
*/
public boolean keepRawEnabled() {
return Boolean.TRUE.equals(keepRaw);
}
}
/**
@@ -0,0 +1,187 @@
package cn.tianbaotong.fill.module.file.service;
import lombok.extern.slf4j.Slf4j;
import java.awt.image.BufferedImage;
/**
* 读 JPEG 的 EXIF 朝向,并把像素转正。
*
* <h3>为什么必须有这个类(这是个真 bug 的修复)</h3>
*
* <p>手机竖着拍照时,<b>文件里的像素其实是横的</b>,外加一个
* {@code EXIF Orientation=6}(意思是"显示时顺时针转 90°")。浏览器认这个标记,
* 所以学生自己看着是正的。
*
* <p>但 Java 的 {@code ImageIO.read()} <b>不读 EXIF</b>,而重编码时又会把 EXIF 丢掉 ——
* 于是输出变成"<b>像素横着、也没有标记</b>"的图,<b>机构看到的是一张转了 90° 的身份证</b>。
*
* <p>实测确认过:输入 3200×2400 + Orientation=6(应为竖的 1200×1600),
* 输出是 1600×1200 且 EXIF 为 None —— 确实躺倒。
*
* <h3>为什么 E2E 一直没抓到</h3>
* 因为测试图是程序生成的,<b>不带 EXIF</b>。而真实学生用手机拍的,
* <b>竖拍的照片几乎全都带这个标记</b> —— 也就是说这个 bug 会命中绝大多数真实材料。
*
* <h3>为什么自己解析而不引依赖</h3>
* 只需要一个 tag(0x0112)。为它引入 metadata-extractor 这类库,
* 要换来的是一份新的依赖 + 联网下载(本地仓库没有);而 JPEG 的 APP1/EXIF 结构
* 是稳定的公开规范,解析它三十行就够。
*/
@Slf4j
public final class ExifOrientation {
private ExifOrientation() {
}
/** EXIF 里 Orientation 的 tag 号。 */
private static final int TAG_ORIENTATION = 0x0112;
/**
* 从 JPEG 字节里读朝向。
*
* @return 1~8;读不到或格式不对时返回 1(=正常,不做任何变换)——
* <b>读不出来时一律"不转"</b>:宁可保持原样,也不要凭猜测把图转错
*/
public static int read(byte[] jpeg) {
try {
if (jpeg == null || jpeg.length < 4) {
return 1;
}
// SOI
if ((jpeg[0] & 0xFF) != 0xFF || (jpeg[1] & 0xFF) != 0xD8) {
return 1;
}
int i = 2;
while (i + 4 <= jpeg.length) {
if ((jpeg[i] & 0xFF) != 0xFF) {
return 1;
}
int marker = jpeg[i + 1] & 0xFF;
// 填充字节:FF FF… 继续找
if (marker == 0xFF) {
i++;
continue;
}
// SOS(DA) 之后是压缩数据,不再有元数据段
if (marker == 0xDA || marker == 0xD9) {
return 1;
}
int len = ((jpeg[i + 2] & 0xFF) << 8) | (jpeg[i + 3] & 0xFF);
if (len < 2) {
return 1;
}
int segStart = i + 4;
int segEnd = i + 2 + len;
if (segEnd > jpeg.length) {
return 1;
}
// APP1 且以 "Exif\0\0" 开头
if (marker == 0xE1 && segEnd - segStart >= 14
&& jpeg[segStart] == 'E' && jpeg[segStart + 1] == 'x'
&& jpeg[segStart + 2] == 'i' && jpeg[segStart + 3] == 'f'
&& jpeg[segStart + 4] == 0 && jpeg[segStart + 5] == 0) {
int o = readFromTiff(jpeg, segStart + 6, segEnd);
if (o >= 1 && o <= 8) {
return o;
}
}
i = segEnd;
}
} catch (Exception e) {
log.debug("读 EXIF 朝向失败(按不转变换处理)", e);
}
return 1;
}
/** 解析 TIFF 头 + IFD0,找 Orientation。 */
private static int readFromTiff(byte[] b, int start, int end) {
if (start + 8 > end) {
return 1;
}
boolean little;
if (b[start] == 'I' && b[start + 1] == 'I') {
little = true;
} else if (b[start] == 'M' && b[start + 1] == 'M') {
little = false;
} else {
return 1;
}
if (u16(b, start + 2, little) != 0x002A) {
return 1;
}
int ifd = start + (int) u32(b, start + 4, little);
if (ifd + 2 > end) {
return 1;
}
int count = u16(b, ifd, little);
for (int k = 0; k < count; k++) {
int e = ifd + 2 + k * 12;
if (e + 12 > end) {
return 1;
}
if (u16(b, e, little) == TAG_ORIENTATION) {
// 类型 3 = SHORT,值直接放在前两字节
return u16(b, e + 8, little);
}
}
return 1;
}
private static int u16(byte[] b, int i, boolean little) {
return little ? ((b[i] & 0xFF) | ((b[i + 1] & 0xFF) << 8))
: (((b[i] & 0xFF) << 8) | (b[i + 1] & 0xFF));
}
private static long u32(byte[] b, int i, boolean little) {
return little
? ((b[i] & 0xFFL) | ((b[i + 1] & 0xFFL) << 8) | ((b[i + 2] & 0xFFL) << 16) | ((b[i + 3] & 0xFFL) << 24))
: (((b[i] & 0xFFL) << 24) | ((b[i + 1] & 0xFFL) << 16) | ((b[i + 2] & 0xFFL) << 8) | (b[i + 3] & 0xFFL));
}
/**
* 按朝向把像素转正。
*
* <p>用**逐像素的索引映射**而不是 {@code AffineTransform}:八种朝向各自的
* 坐标对应关系是确定的,写成索引一看就懂、也容易对着规范核;而
* {@code AffineTransform} 的平移/旋转组合写错了很难看出来(图还是能出,
* 只是方向不对)。
*
* <table>
* <tr><td>2</td><td>水平镜像</td><td>3</td><td>旋转 180°</td></tr>
* <tr><td>4</td><td>垂直镜像</td><td>5</td><td>沿主对角转置</td></tr>
* <tr><td>6</td><td><b>顺时针 90°(手机竖拍最常见)</b></td>
* <td>8</td><td>逆时针 90°</td></tr>
* <tr><td>7</td><td>沿副对角转置</td><td>1</td><td>正常,不动</td></tr>
* </table>
*/
public static BufferedImage apply(BufferedImage src, int orientation) {
if (orientation <= 1 || orientation > 8) {
return src;
}
int w = src.getWidth();
int h = src.getHeight();
boolean swap = orientation >= 5; // 5~8 是转置类,宽高互换
int tw = swap ? h : w;
int th = swap ? w : h;
BufferedImage out = new BufferedImage(tw, th,
src.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
for (int y = 0; y < th; y++) {
for (int x = 0; x < tw; x++) {
int sx;
int sy;
switch (orientation) {
case 2 -> { sx = w - 1 - x; sy = y; }
case 3 -> { sx = w - 1 - x; sy = h - 1 - y; }
case 4 -> { sx = x; sy = h - 1 - y; }
case 5 -> { sx = y; sy = x; }
case 6 -> { sx = y; sy = h - 1 - x; }
case 7 -> { sx = w - 1 - y; sy = h - 1 - x; }
default -> { sx = w - 1 - y; sy = x; } // 8
}
out.setRGB(x, y, src.getRGB(sx, sy));
}
}
return out;
}
}
@@ -53,6 +53,14 @@ public class FileProcessService {
private static final int P1_WIDTH = 295;
private static final int P1_HEIGHT = 413;
/**
* 返回值里 quality 用这个值表示**没有重新编码**、原样存了。
*
* <p>0 不是合法的 JPEG 质量,所以不会与真值混淆 —— 这一点很重要:
* 排查这张图为什么有点糊时,得能一眼看出它到底被压过几次。
*/
public static final int NOT_REENCODED = 0;
/** 质量下探的阶梯。到 minQuality 还不够就转去缩尺寸。 */
private static final int[] QUALITY_STEPS = {88, 85, 80, 75, 70, 65};
@@ -90,6 +98,18 @@ public class FileProcessService {
return new Processed(raw, 0, 0, 100, null, false, false);
}
// ★ 按 EXIF 把像素转正 —— **必须在任何缩放之前**。
// 手机竖拍的照片,像素其实是横的 + 一个"显示时转 90°"的标记;
// 而 ImageIO 不读 EXIF、重编码又会丢掉它,不在这里转正的话,
// 出来的就是一张**没有标记的躺倒图** —— 机构看到的是转了 90° 的身份证。
// 放在缩放之前是因为:转置会互换宽高,长边/固定尺寸的计算必须基于转正后的尺寸。
int orientation = ExifOrientation.read(raw);
if (orientation > 1) {
img = ExifOrientation.apply(img, orientation);
log.debug("按 EXIF 朝向 {} 转正({}x{} → {}x{})",
orientation, img.getWidth(), img.getHeight(), img.getWidth(), img.getHeight());
}
String p = profile == null ? "AUTO" : profile.toUpperCase();
BufferedImage work = img;
boolean scaled = false;
@@ -113,6 +133,21 @@ public class FileProcessService {
}
}
// ★ 已经达标 → **原样返回,不做二次编码**。
//
// 前端会按材料项的目标先压一遍(它会转正并去掉 EXIF),上传的就已经是
// "长边够小、体积达标"的图了。再压一次就是 **JPEG 二次编码** —— 白掉一次画质,
// 而且换不回任何东西。
//
// 三个条件缺一不可:
// · 没触发缩放(档位要求的尺寸本来就满足)
// · 没有 EXIF 需要转正(有就必须重编码,否则躺倒)
// · 体积本来就在上限内
// P4 截图落在这里最合适:原样保持 PNG,表格线一点不糊。
if (!scaled && orientation <= 1 && raw.length <= maxSizeKb * 1024L) {
return new Processed(raw, img.getWidth(), img.getHeight(), NOT_REENCODED, sniffExt(raw), false, false);
}
// ② P4 保持 PNG(实测学籍表是 16:9 截图,转 JPG 会把表格线糊掉)
boolean keepPng = "P4".equals(p) || (img.getColorModel().hasAlpha() && "AUTO".equals(p));
if (keepPng) {
@@ -299,6 +334,28 @@ public class FileProcessService {
}
}
/**
* 从**字节的幻数**判类型,不信文件名的后缀。
*
* <p>结论要写进数据库(导出包按它定扩展名),所以不能猜错;
* 而学生上传的 {@code .jpg} 里装一张 PNG 是常事(截图改名、微信转发都会这样)。
*/
public static String sniffExt(byte[] b) {
if (b == null || b.length < 4) {
return "bin";
}
if ((b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8) {
return "jpg";
}
if ((b[0] & 0xFF) == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G') {
return "png";
}
if (b[0] == '%' && b[1] == 'P' && b[2] == 'D' && b[3] == 'F') {
return "pdf";
}
return "bin";
}
private static byte[] encodePng(BufferedImage img) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
ImageIO.write(img, "png", bos);
@@ -105,11 +105,32 @@ public class MaterialFileService {
: "「" + 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, contentTypeOf(ext));
// ★ 收上来就**立刻规范化**(原来是"先存原图、再单独调一次 process")。
//
// 两步存在的唯一理由是"先保住原始件" —— 客户明确不要原始件之后,
// 这一步就只剩代价了:多一个"传了但没处理"的中间状态、多一次往返、
// 多一份存储。现在一步到位。
int maxSizeKb = def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb();
int minQuality = def.getMinQuality() == null ? 70 : def.getMinQuality();
FileProcessService.Processed p = processService.compress(
bytes, def.getCompressProfile(), maxSizeKb, minQuality);
String outExt = p.ext() != null ? p.ext() : ext;
String standardName = standardNameFor(me, sub.getTaskId(), def, outExt);
String stdRel = String.format("std/%d/%d/%s", me.tenantId(), sub.getTaskId(), standardName);
writeFile(stdRel, p.bytes(), contentTypeOf(outExt));
// 原始件:默认**不留**(客户决定:交付物就是规范件,不必拿原图当退件凭证)。
// 留的时候写到 raw/,**不参与交付**,只作审计与"改参数后重做"的来源 ——
// 项目里还有两个没定的参数(一寸照片阈值标定、机构是否接受 300dpi 扫描件),
// 它们一变,没有原图就只能让所有学生重传。
String rawRel = null;
if (props.storage() != null && props.storage().keepRawEnabled()) {
rawRel = String.format("raw/%d/%d/%d_%s.%s",
me.tenantId(), sub.getTaskId(), System.currentTimeMillis(),
Integer.toHexString(java.util.Arrays.hashCode(bytes)), ext);
writeFile(rawRel, bytes, contentTypeOf(ext));
}
CollectFile f = new CollectFile();
f.setTaskId(sub.getTaskId());
@@ -119,27 +140,53 @@ public class MaterialFileService {
f.setVersion((int) current + 1);
f.setIsCurrent(CollectFile.CURRENT);
f.setOriginalName(originalName);
f.setFileExt(ext);
f.setFileExt(outExt);
f.setRawSize((long) bytes.length);
f.setFinalSize((long) bytes.length);
f.setFinalSize((long) p.bytes().length);
f.setFileHash(sha256(bytes));
f.setRawPath(rel);
f.setRawPath(rawRel);
f.setStandardName(standardName);
f.setStdPath(stdRel);
f.setWidth(p.width());
f.setHeight(p.height());
f.setQuality(p.quality());
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);
log.info("上传并规范化:task={} sub={} material={} {} → {}({} → {} 字节 q={})",
sub.getTaskId(), submissionId, materialCode, originalName, standardName,
bytes.length, p.bytes().length, p.quality());
return f.getId();
}
/**
* 规范文件名:材料级模板优先,否则继承任务级。
*
* <p>抽出来给"上传即规范化"和"重新规范化"两条路共用 ——
* **名字必须两处一致**,否则同一个材料重处理一次就换了个名字。
*/
private String standardNameFor(LoginUser me, Long taskId, MaterialDefVO def, String ext) {
CollectTask task = taskMapper.selectById(taskId);
String template = (def.getNamePattern() != null && !def.getNamePattern().isBlank())
? def.getNamePattern()
: (task == null ? null : task.getNamingTemplate());
return processService.renderName(template, namingVars(me, taskId, def, task), "." + ext);
}
// ── 处理:压缩 + 改名 ─────────────────────────
/**
* 把原始文件规范化成"可直接上报"的那一份:压到体积内 + 按模板改名。
* 重新规范化一份材料:压到体积内 + 按模板改名。
*
* <p>幂等:重复调用会重新处理并覆盖上一份规范文件(原始件不动)。
* 学生在手机上重试是常事,不该因此产生多份规范文件。
* <p><b>正常情况下用不到它</b> —— 上传时已经规范化过了。
* 留着它是为了两个场景:
* <ul>
* <li><b>改了材料规则之后要重做</b>(比如把体积上限从 1M 调到 512K)——
* 这时需要原始件;而 <b>原始件默认不保留</b>,
* 所以本方法在拿不到原始件时会明确报错,而不是假装成功 ✓</li>
* <li><b>幂等重试</b>:学生在手机上重试是常事,不该因此产生多份规范文件。</li>
* </ul>
*/
@Transactional(rollbackFor = Exception.class)
public CollectFile process(Long fileId) {
@@ -152,6 +199,12 @@ public class MaterialFileService {
if (def == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "材料项已被删除,无法处理");
}
if (f.getRawPath() == null || f.getRawPath().isBlank()) {
// 原始件没留(默认策略),规范化结果已经是最终件 —— 原样返回,**不报错**。
// 报错会让"上传后顺手再调一次 process"的前端逻辑炸掉,而那是无害的调用。
log.debug("重新规范化跳过:fileId={} 未保留原始件(keepRaw=false),当前已是最终件", fileId);
return f;
}
byte[] raw = readFile(f.getRawPath());
int maxSizeKb = def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb();
@@ -159,17 +212,10 @@ public class MaterialFileService {
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 outExt = p.ext() != null ? p.ext() : f.getFileExt();
String standardName = standardNameFor(me, f.getTaskId(), def, outExt);
String rel = String.format("std/%d/%d/%s", me.tenantId(), f.getTaskId(), standardName);
writeFile(rel, p.bytes(), contentTypeOf(p.ext() == null ? f.getFileExt() : p.ext()));
writeFile(rel, p.bytes(), contentTypeOf(outExt));
fileMapper.update(null, Wrappers.<CollectFile>lambdaUpdate()
.eq(CollectFile::getId, fileId)
@@ -180,7 +226,7 @@ public class MaterialFileService {
.set(CollectFile::getHeight, p.height())
.set(CollectFile::getQuality, p.quality()));
log.info("规范化完成:{} → {}({}KB → {}KB, {}x{}, q{})",
log.info("重新规范化完成:{} → {}({}KB → {}KB, {}x{}, q{})",
f.getOriginalName(), standardName, f.getRawSize() / 1024,
p.bytes().length / 1024, p.width(), p.height(), p.quality());
@@ -231,7 +277,7 @@ public class MaterialFileService {
// ── URL 与内容 ────────────────────────────────
/** 一份材料的可访问链接。 */
public record FileUrl(String url, long expiresIn, String provider, String name) {
public record FileUrl(String url, long expiresIn, String provider, String name, boolean original) {
}
/**
@@ -252,11 +298,15 @@ public class MaterialFileService {
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
// 取哪一份:优先按参数,**取不到就退到另一份**。
// 「原始件默认不保留」(客户决定),所以 original=true 时很可能根本没有原始件;
// 这时退回到规范件并如实标注,比报"文件不存在"有用得多 ——
// 学生点"看原图"只是想看自己传的那份,规范件就是他传的那张的压缩结果。
boolean useRaw = original;
String path = original ? f.getRawPath() : f.getStdPath();
if (path == null || path.isBlank()) {
// 规范件还没生成(上传了但没处理)时退回原始件,而不是报错 ——
// 学生看到的是"我的材料",不该因为内部有两份而困惑
path = f.getRawPath();
useRaw = !original;
path = useRaw ? f.getRawPath() : f.getStdPath();
}
if (path == null || path.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传");
@@ -268,11 +318,12 @@ public class MaterialFileService {
// 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口
// (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId,
// 而按 id 取能复用"这份文件属于你"的归属校验)
url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + original;
url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + useRaw;
}
return new FileUrl(url, exp, storage.provider(),
original ? f.getOriginalName() : (f.getStandardName() == null
? f.getOriginalName() : f.getStandardName()));
String name = useRaw
? f.getOriginalName()
: (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName());
return new FileUrl(url, exp, storage.provider(), name, useRaw);
}
/** 一份材料的内容与其文件名(文件名用来推 Content-Type)。 */
@@ -292,11 +343,16 @@ public class MaterialFileService {
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
boolean useRaw = original;
String path = original ? f.getRawPath() : f.getStdPath();
if (path == null || path.isBlank()) {
path = f.getRawPath();
useRaw = !original;
path = useRaw ? f.getRawPath() : f.getStdPath();
}
String name = original ? f.getOriginalName()
if (path == null || path.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传");
}
String name = useRaw ? f.getOriginalName()
: (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName());
return new Content(readFile(path), name);
}
@@ -304,10 +360,10 @@ public class MaterialFileService {
// ── 内部 ──────────────────────────────────────
/** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */
private Map<String, String> namingVars(LoginUser me, CollectFile f, MaterialDefVO def,
private Map<String, String> namingVars(LoginUser me, Long taskId, MaterialDefVO def,
CollectTask task) {
Map<String, String> v = new LinkedHashMap<>();
CollectRoster r = rosterMapper.selectBoundByUser(me.tenantId(), f.getTaskId(), me.uid());
CollectRoster r = rosterMapper.selectBoundByUser(me.tenantId(), taskId, me.uid());
if (r != null) {
v.put("姓名", r.getRealName());
// 文件名里用**脱敏号**:规范名会出现在导出包里、会被打印、会被转发,
@@ -72,6 +72,18 @@ fill:
# **换存储、换密钥、换空间之后值得开一次**:它能在启动时就把
# "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。
# 默认关:生产启动不该依赖外部网络,云存储的瞬时抖动不该让服务起不来。
# 是否保留学生上传的**原始件**。
#
# **默认 false(不保留)**:客户明确过 —— 交付物就是压缩后的规范件,
# 不需要拿原图当"机构说看不清时证明不是我们压坏的"凭证。
# 顺带把上传从"两步"变成"一步"(收上来就压,不再有"传了但没处理"的中间态)。
#
# ⚠️ 但要注意它一起放弃的是**重做的能力**:项目里还有两个没定的参数
# (D5 一寸照片阈值标定、A1 机构是否书面接受 300dpi 扫描件),
# 它们一变,没有原图就只能**让所有学生重传一遍**。
# 需要在任务归档前留个保险时,把这里打开 ——
# 原始件会写到 raw/,**不参与交付**,只作审计与重处理的来源。
keep-raw: ${XT_STORAGE_KEEP_RAW:false}
self-check-on-startup: ${XT_STORAGE_SELF_CHECK:false}
qiniu:
access-key: ${QINIU_ACCESS_KEY:}