feat(file): 上传预检/秒传/批量处理/预览 + 缩略图(P2-7)与 H5 独立 dev server(P2-9)
缩略图:StorageService.thumbUrl(path, expire, maxLongSide),七牛在**签名之前**拼 imageView2/2/w/h(拼在签名之后必 401),本地存储回退原图,让调用方不必判断当前是什么存储。 实测 1454KB → 36KB(2.4%)、1600×1200 → 400×300。 F-73:秒传原来排在份数检查之后 → 网络重传得到「你超了」而不是「你已经传过了」。 H5 dev server:vite 加 /api 代理 + .env.development 把 VITE_API_BASE 设空串, 改一行不用再 build:h5;三端行为不同(dev→代理 / 构建产物→location.origin / 小程序→写死)。 验证:新增 e2e-m9-thumb.py 12/12(打到真实七牛)、e2e-m8-orgfile 54/54; H5 与小程序两端构建都验过。
This commit is contained in:
+48
@@ -24,6 +24,7 @@ import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
/**
|
||||
* 材料上传与规范化(《03-接口设计》§9.5~§9.7)。
|
||||
@@ -46,6 +47,8 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class MaterialFileController {
|
||||
|
||||
private final cn.tianbaotong.fill.module.file.service.MaterialFileViewService viewService;
|
||||
|
||||
private final MaterialFileService fileService;
|
||||
|
||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -140,4 +143,49 @@ public class MaterialFileController {
|
||||
.header("Content-Disposition", "inline")
|
||||
.body(c.bytes());
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════ §9.5 直传凭证 ══════════════════
|
||||
|
||||
@PostMapping("/upload-token")
|
||||
@Operation(summary = "获取上传凭证(§9.5)",
|
||||
description = "**预检 + 秒传 + 压缩提示**:这三件事都能在「不传一个字节」的情况下完成,"
|
||||
+ "而那正是它要解决的问题(学生传了 8MB 才发现格式不对/重复了)。"
|
||||
+ "`existed=true` 时**不给 uploadUrl**,前端据此跳过上传。"
|
||||
+ "⚠️ 一期 `direct=false`:uploadUrl 是本项目的接收地址"
|
||||
+ "(七牛直传要先把「上传完成回调」打通,见 F-71)")
|
||||
public ApiResult<cn.tianbaotong.fill.module.file.dto.MaterialDtos.UploadTokenVO> uploadToken(
|
||||
@RequestBody cn.tianbaotong.fill.module.file.dto.MaterialDtos.UploadTokenReq req) {
|
||||
return ApiResult.ok(viewService.uploadToken(req));
|
||||
}
|
||||
|
||||
// ══════════════════ §9.7 批量处理 ══════════════════
|
||||
|
||||
@PostMapping("/batch-process")
|
||||
@Operation(summary = "批量处理多材料(§9.7)",
|
||||
description = "把每个 fileId 走一遍规范化(与单份 `/process` 同一个方法)。"
|
||||
+ "⚠️ 契约 FR-614a:识别冲突**只提示、不自动改位置** —— "
|
||||
+ "识别是猜的(契约自己的示例里置信度 0.71),"
|
||||
+ "把学生放的身份证自动挪到工作证明,猜错了他会在审核时被驳回,"
|
||||
+ "而他完全不知道系统动过他的东西。一期 M4 未接,"
|
||||
+ "所以 `detectMaterialCode` 恒为空、`conflict` 恒为 false —— "
|
||||
+ "不是「识别后没问题」,而是「还没识别」")
|
||||
public ApiResult<cn.tianbaotong.fill.module.file.dto.MaterialDtos.BatchProcessVO> batchProcess(
|
||||
@RequestBody cn.tianbaotong.fill.module.file.dto.MaterialDtos.BatchProcessReq req) {
|
||||
return ApiResult.ok(viewService.batchProcess(req));
|
||||
}
|
||||
|
||||
// ══════════════════ §9.8 预览 ══════════════════
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "取材料的签名 URL 与元信息(§9.8)",
|
||||
description = "`prefer=preview`(默认)/ `prefer=original`,返回 30 分钟的签名 URL。"
|
||||
+ "⚠️ `actualKind` 字段**不是冗余**:请求 original 时原始件可能不存在"
|
||||
+ "(客户决定默认不保留),此时会退到规范件 —— "
|
||||
+ "不告诉前端的话,他以为看的是原图(而他正在纠结「这张怎么糊了」)")
|
||||
public ApiResult<cn.tianbaotong.fill.module.file.dto.MaterialDtos.FileViewVO> view(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "preview") String prefer) {
|
||||
return ApiResult.ok(viewService.view(id, prefer));
|
||||
}
|
||||
}
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package cn.tianbaotong.fill.module.file.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 文件相关的请求与响应(《03-接口设计》§9.5 直传凭证 / §9.7 批量处理 / §9.8 预览)。
|
||||
*/
|
||||
public final class MaterialDtos {
|
||||
|
||||
private MaterialDtos() {
|
||||
}
|
||||
|
||||
// ── §9.8 预览 ─────────────────────────────────
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class FileViewVO {
|
||||
|
||||
private Long fileId;
|
||||
private Long submissionId;
|
||||
private String materialCode;
|
||||
private String standardName;
|
||||
private String originalName;
|
||||
/** 签名 URL(30 分钟,过期要重新取)。 */
|
||||
private String previewUrl;
|
||||
private String thumbUrl;
|
||||
private Long finalSizeKb;
|
||||
private Long rawSizeKb;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
private Integer dpi;
|
||||
/** {@code 0} 待检 / {@code 1} 通过 / {@code 2} 警告 / {@code 3} 不合格。 */
|
||||
private Integer validateStatus;
|
||||
/** {@code 1} 本人 / {@code 2} 班主任代传 / {@code 3} 管理员代传 / {@code 4} 整理器导入。 */
|
||||
private Integer source;
|
||||
/** 是不是"别人替他传的"(界面要显示「由 X 老师代传」)。 */
|
||||
private Boolean proxied;
|
||||
private String proxyReason;
|
||||
private Integer version;
|
||||
private Boolean isCurrent;
|
||||
/**
|
||||
* 实际给的是哪一份:{@code ORIGINAL} / {@code STANDARD}。
|
||||
*
|
||||
* <p>⚠️ 这个字段是必要的,不是冗余:请求 {@code prefer=original} 时
|
||||
* **原始件可能不存在**(客户决定默认不保留),此时会退到规范件 ——
|
||||
* 不告诉前端的话,他以为看的是原图(而他正在纠结"这张怎么糊了")。
|
||||
*/
|
||||
private String actualKind;
|
||||
private Long contentLength;
|
||||
private Long expireSeconds;
|
||||
}
|
||||
|
||||
// ── §9.5 直传凭证 ──────────────────────────────
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "获取上传凭证")
|
||||
public static class UploadTokenReq {
|
||||
private Long taskId;
|
||||
private String materialCode;
|
||||
/** 原始文件名(可能是乱的,正常)。 */
|
||||
private String fileName;
|
||||
private Long fileSize;
|
||||
private String contentType;
|
||||
/** `1` 聊天记录 / `2` 相册 / `3` 手机文件 —— 与 `collect_file.source` 同口径。 */
|
||||
private Integer source;
|
||||
/** 客户端 SHA-256(用于秒传与去重)。 */
|
||||
private String fileHash;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class UploadTokenVO {
|
||||
|
||||
/** 预检是否通过。为 false 时看 {@code rejectReason}。 */
|
||||
private Boolean ok;
|
||||
|
||||
/** 预检不过的原因(**能照着做**:说清上限与当前值,并给压缩建议)。 */
|
||||
private String rejectReason;
|
||||
|
||||
/** 秒传命中时给出的已存在文件 id;正常上传路径下为 null(上传成功才有)。 */
|
||||
private Long fileId;
|
||||
|
||||
/** 秒传命中:前端据此**跳过上传**。 */
|
||||
private Boolean existed;
|
||||
|
||||
private String uploadUrl;
|
||||
private String objectKey;
|
||||
private Long expireSeconds;
|
||||
private Map<String, String> headers = new LinkedHashMap<>();
|
||||
|
||||
/** 一期恒为 false(见 `MaterialFileViewService` 的类注释)。 */
|
||||
private Boolean direct;
|
||||
|
||||
/** 服务端代收入口要带的一次性凭证(15 分钟)。 */
|
||||
private String uploadToken;
|
||||
|
||||
private CompressHint clientCompressHint;
|
||||
|
||||
private List<String> notes = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CompressHint {
|
||||
private Boolean enabled;
|
||||
private Integer maxLongSide;
|
||||
private Integer quality;
|
||||
private Long targetSizeKb;
|
||||
private Long currentSizeKb;
|
||||
/** 是否**需要**压缩(当前体积超了上限)。 */
|
||||
private Boolean needed;
|
||||
}
|
||||
|
||||
// ── §9.7 批量处理 ──────────────────────────────
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "多材料一次处理")
|
||||
public static class BatchProcessReq {
|
||||
private Long submissionId;
|
||||
private List<Long> fileIds;
|
||||
/** 契约有它;一期**同步跑完**(一次最多 20 个文件),返回里直接给结果。 */
|
||||
private Boolean async;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class BatchItemVO {
|
||||
private Long fileId;
|
||||
/** {@code 0} 待检 / {@code 1} 通过 / {@code 2} 警告 / {@code 3} 不合格或处理失败。 */
|
||||
private Integer status;
|
||||
/** 学生放的位置。 */
|
||||
private String materialCode;
|
||||
/** 系统识别出的位置;**一期恒为空**(M4 未接)。 */
|
||||
private String detectMaterialCode;
|
||||
private Boolean conflict;
|
||||
/** 识别置信度;一期恒为空。 */
|
||||
private Double detectConfidence;
|
||||
private Boolean needManualPlace;
|
||||
private String standardName;
|
||||
private String error;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class BatchSummary {
|
||||
private int ok;
|
||||
private int conflict;
|
||||
private int failed;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class BatchProcessVO {
|
||||
private String batchToken;
|
||||
private int total;
|
||||
private List<BatchItemVO> results = new ArrayList<>();
|
||||
private BatchSummary summary;
|
||||
private List<String> notes = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
+81
-3
@@ -29,6 +29,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import cn.tianbaotong.fill.module.validation.service.ValidationService;
|
||||
|
||||
/**
|
||||
* 材料文件的上传与规范化(《03-接口设计》§9.5~§9.7 的核心)。
|
||||
@@ -55,6 +56,7 @@ import java.util.Map;
|
||||
public class MaterialFileService {
|
||||
|
||||
private final FileMapper fileMapper;
|
||||
private final ValidationService validationService;
|
||||
private final SubmissionMapper submissionMapper;
|
||||
private final MaterialDefService materialDefService;
|
||||
private final MpRosterMapper rosterMapper;
|
||||
@@ -73,6 +75,31 @@ public class MaterialFileService {
|
||||
if (sub == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你");
|
||||
}
|
||||
return storeFile(sub, materialCode, originalName, bytes, clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份材料存到**给定的填报单**上(不含"这份单子是不是你的"的判断)。
|
||||
*
|
||||
* <p>抽出来是为了让两条路共用同一套校验与规范化:
|
||||
* <ul>
|
||||
* <li>学生自己传({@link #upload})—— 先校验填报单属于他;</li>
|
||||
* <li>班主任代传({@code MpTeacherProxyService})—— 先校验他管得着这个班。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>⚠️ <b>为什么这里不自己判权限</b>:两条路的授权口径完全不同
|
||||
* (一个按 submitter_id,一个按班级范围)。把它们塞进这个方法里,
|
||||
* 迟早会出现"某一处忘了传一个参数于是越权" —— 授权放在调用方、
|
||||
* 并且调用方各自只有一条明确的判定,更容易看住。
|
||||
*
|
||||
* <p>但**已提交不能再传**这条留在这里:它对两条路都一样
|
||||
* (代传也不能给正在审的单子塞材料 —— 那会让审到一半的材料清单变了)。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long storeFile(CollectSubmission sub, String materialCode, String originalName,
|
||||
byte[] bytes, String clientIp) {
|
||||
LoginUser me = UserContext.require();
|
||||
Long submissionId = sub.getId();
|
||||
if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) {
|
||||
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, "已提交的填报单不能再传材料");
|
||||
}
|
||||
@@ -171,7 +198,32 @@ public class MaterialFileService {
|
||||
f.setValidateStatus(CollectFile.VALIDATE_PENDING);
|
||||
f.setNeedManualPlace(CollectFile.NEED_MANUAL_NO);
|
||||
f.setUploadIp(clientIp);
|
||||
// ★ 来源要**显式写**:V12 之前这一列从来没被写过,库里全是列默认值 1 ——
|
||||
// 也就是"学生自己传的"与"老师代传的"在数据上区分不出来。
|
||||
// 代传路径(TeacherProxyService)会在插入后把它改成 2/3。
|
||||
f.setSource(CollectFile.SOURCE_SELF);
|
||||
fileMapper.insert(f);
|
||||
|
||||
// ★ 把"系统替你改过文件名"记进校验问题清单(契约 §12.2 的 `byRule.autoFixed`)。
|
||||
//
|
||||
// 不记的话那个字段永远是 0,而客户在问题清单上看到的是
|
||||
// 「双扩展名 0 项」—— 可实际上系统刚刚把"身份证.jpg.jpg"改成了规范名。
|
||||
// "系统自动做了 56 件、只有 1 件要你判断"正是这个功能最有说服力的一句话(§13.2),
|
||||
// 而它需要有地方把这个数记下来。
|
||||
//
|
||||
// 记的是 VAL-06(命名规范):契约 §5.7 里 VAL-06 就是"文件名不符合规范"。
|
||||
if (originalName != null && !originalName.equals(standardName)) {
|
||||
try {
|
||||
validationService.recordAutoFixed(sub.getTaskId(), submissionId, f.getId(),
|
||||
"VAL-06", "文件名不符合规范,已自动规范化",
|
||||
standardName, originalName);
|
||||
} catch (RuntimeException e) {
|
||||
// 记留痕失败**不能**让上传失败:材料已经存好了,用户要的是"传上去了"。
|
||||
// 记一条 WARN 就够了 —— 少一条留痕比让用户重传一份材料轻得多
|
||||
log.warn("记录自动修复留痕失败(不影响上传)sub={} {} → {}",
|
||||
submissionId, originalName, standardName, e);
|
||||
}
|
||||
}
|
||||
log.info("上传并规范化:task={} sub={} material={} {} → {}({} → {} 字节 q={})",
|
||||
sub.getTaskId(), submissionId, materialCode, originalName, standardName,
|
||||
bytes.length, p.bytes().length, p.quality());
|
||||
@@ -295,7 +347,8 @@ public class MaterialFileService {
|
||||
// ── URL 与内容 ────────────────────────────────
|
||||
|
||||
/** 一份材料的可访问链接。 */
|
||||
public record FileUrl(String url, long expiresIn, String provider, String name, boolean original) {
|
||||
public record FileUrl(String url, long expiresIn, String provider, String name,
|
||||
boolean original, String thumbUrl, int thumbMaxLongSide) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,6 +372,15 @@ public class MaterialFileService {
|
||||
return urlOf(f, original, expireSeconds, LOCAL_DOWNLOAD_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核列表用的缩略图长边(像素)。
|
||||
*
|
||||
* <p>400 是"能看清这是不是一张身份证"与"体积足够小"的平衡点:
|
||||
* 手机竖拍原图 700KB → 缩略图约 10KB,**一屏 200 张从 140MB 降到 2MB**。
|
||||
* 审核详情页仍然取原图(要看防伪纹、要看有没有裁掉边角)。
|
||||
*/
|
||||
public static final int THUMB_LONG_SIDE = 400;
|
||||
|
||||
/** 本地存储时,下载/预览走哪个前缀。学生端与审核端各有一份(权限检查在各自的服务里)。 */
|
||||
public static final String LOCAL_DOWNLOAD_PREFIX = "/api/v1/mp/files/";
|
||||
|
||||
@@ -333,7 +395,20 @@ public class MaterialFileService {
|
||||
* <p>⚠️ 不要让审核端复用 {@link #url}:那个方法一开头就查 {@code submitter_id},
|
||||
* 为了"让管理员也能看"而在里面加 or 条件,学生端就漏了。
|
||||
*/
|
||||
public FileUrl urlOf(CollectFile f, boolean original, Long expireSeconds, String localDownloadPrefix) {
|
||||
public FileUrl urlOf(CollectFile f, boolean original, Long expireSeconds,
|
||||
String localDownloadPrefix) {
|
||||
return urlOf(f, original, expireSeconds, localDownloadPrefix, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取链接(可带缩略图)。
|
||||
*
|
||||
* @param thumbMaxLongSide 缩略图长边像素;{@code <= 0} 表示不要缩略图
|
||||
* (此时 {@code thumbUrl} 与 {@code url} 相同)。
|
||||
* 审核列表传 {@link #THUMB_LONG_SIDE}。
|
||||
*/
|
||||
public FileUrl urlOf(CollectFile f, boolean original, Long expireSeconds,
|
||||
String localDownloadPrefix, int thumbMaxLongSide) {
|
||||
// 取哪一份:优先按参数,**取不到就退到另一份**。
|
||||
// 「原始件默认不保留」(客户决定),所以 original=true 时很可能根本没有原始件;
|
||||
// 这时退回到规范件并如实标注,比报"文件不存在"有用得多 ——
|
||||
@@ -359,7 +434,10 @@ public class MaterialFileService {
|
||||
String name = useRaw
|
||||
? f.getOriginalName()
|
||||
: (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName());
|
||||
return new FileUrl(url, exp, storage.provider(), name, useRaw);
|
||||
// ★ 缩略图:**由存储层拼参数并签名**(顺序错了会 401,见 StorageService#thumbUrl)
|
||||
int thumbSide = thumbMaxLongSide > 0 ? thumbMaxLongSide : 0;
|
||||
String thumb = thumbSide > 0 ? storage.thumbUrl(path, exp, thumbSide) : url;
|
||||
return new FileUrl(url, exp, storage.provider(), name, useRaw, thumb, thumbSide);
|
||||
}
|
||||
|
||||
/** 一份材料的内容与其文件名(文件名用来推 Content-Type)。 */
|
||||
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
package cn.tianbaotong.fill.module.file.service;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
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.module.file.dto.MaterialDtos;
|
||||
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.entity.CollectMaterialDef;
|
||||
import cn.tianbaotong.fill.module.material.mapper.MaterialDefMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 文件的三件事(《03-接口设计》§9.5 直传凭证 / §9.7 批量处理 / §9.8 预览)。
|
||||
*
|
||||
* <h3>§9.5 直传凭证:一期为什么返回"服务端代收地址"而不是七牛直传</h3>
|
||||
*
|
||||
* <p>契约给的是 S3 风格的"签名 PUT URL"。而本项目存储走**七牛**,
|
||||
* 七牛的直传是"POST 到 upload.qiniup.com + form 里带 token",
|
||||
* 而且**必须配回调**(`callbackUrl`)才能让服务端知道"传完了、文件在哪" ——
|
||||
* 没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它。
|
||||
*
|
||||
* <p>所以一期:
|
||||
* <ul>
|
||||
* <li>{@code direct: false},`uploadUrl` 就是本项目已有的上传接口
|
||||
* (学生端 `wx.uploadFile` 直接 POST 它,与现在 H5 的路径**完全一样**);</li>
|
||||
* <li>直传凭证这一层仍然有价值:**预检 + 秒传 + 压缩提示**,
|
||||
* 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题
|
||||
* (学生传了 8MB 才发现格式不对/重复了);</li>
|
||||
* <li>七牛直传等回调打通之后再加 `direct: true`(记 F-71)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>§9.7 批量处理:冲突只提示、不自动改位置</h3>
|
||||
*
|
||||
* <p>契约 FR-614a 明确写了。这条规则的理由很实在:
|
||||
* 识别是**猜**的(契约自己的示例里 `detectConfidence: 0.71`),
|
||||
* 而"把学生放的身份证自动挪到工作证明"这种动作,一旦猜错,
|
||||
* 学生会在审核时被驳回,而他完全不知道系统动过他的东西。
|
||||
* 所以这里只返回 `conflict: true` + `needManualPlace`,让人决定。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MaterialFileViewService {
|
||||
|
||||
/** 直传凭证的有效期(契约 §9.5:900 秒)。 */
|
||||
private static final long UPLOAD_TOKEN_TTL_SECONDS = 900L;
|
||||
|
||||
private static final String TOKEN_KEY = "xt:file:uptoken:";
|
||||
|
||||
private final FileMapper fileMapper;
|
||||
private final SubmissionMapper submissionMapper;
|
||||
private final MaterialDefMapper materialDefMapper;
|
||||
private final MaterialFileService fileService;
|
||||
private final StringRedisTemplate redis;
|
||||
|
||||
// ── §9.8 预览 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* 取一份材料的签名 URL 与元信息。
|
||||
*
|
||||
* <p>⚠️ 授权按 `submitter_id`:这是**学生端**的接口。
|
||||
* 教师要看他班学生的材料走 `/mp/teacher/files/{id}/preview`(按班级范围授权)——
|
||||
* 两个入口的授权依据不同,合并成一个会让"谁能看谁的身份证照片"变得说不清。
|
||||
*/
|
||||
public MaterialDtos.FileViewVO view(Long fileId, String prefer) {
|
||||
LoginUser me = UserContext.require();
|
||||
CollectFile f = fileMapper.selectById(fileId);
|
||||
if (f == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或已删除");
|
||||
}
|
||||
CollectSubmission sub = f.getSubmissionId() == null
|
||||
? null : submissionMapper.selectMineById(f.getSubmissionId(), me.tenantId(), me.uid());
|
||||
if (sub == null) {
|
||||
// 不区分"不存在"与"不是你的":后者能用来枚举别人有哪些文件
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
|
||||
}
|
||||
// prefer=original 取原始件;原始件默认不保留,取不到会退到规范件并标注
|
||||
boolean wantOriginal = "original".equalsIgnoreCase(prefer);
|
||||
MaterialFileService.Content c = fileService.contentOf(f, wantOriginal);
|
||||
MaterialFileService.FileUrl u = fileService.urlOf(f, wantOriginal, 1800L, "file-view");
|
||||
|
||||
MaterialDtos.FileViewVO vo = new MaterialDtos.FileViewVO();
|
||||
vo.setFileId(f.getId());
|
||||
vo.setSubmissionId(f.getSubmissionId());
|
||||
vo.setMaterialCode(f.getMaterialCode());
|
||||
vo.setStandardName(f.getStandardName());
|
||||
vo.setOriginalName(f.getOriginalName());
|
||||
vo.setPreviewUrl(u.url());
|
||||
// 缩略图与预览图同一地址:七牛的图片处理参数是"在 URL 上追加",
|
||||
// 而我们不保证开了那个服务 —— 返回同一地址比返回一个 404 的"缩略图"好
|
||||
vo.setThumbUrl(u.url());
|
||||
vo.setFinalSizeKb(f.getFinalSize() == null ? null : f.getFinalSize() / 1024);
|
||||
vo.setRawSizeKb(f.getRawSize() == null ? null : f.getRawSize() / 1024);
|
||||
vo.setWidth(f.getWidth());
|
||||
vo.setHeight(f.getHeight());
|
||||
vo.setDpi(f.getDpi());
|
||||
vo.setValidateStatus(f.getValidateStatus());
|
||||
vo.setSource(f.getSource());
|
||||
vo.setProxied(f.isProxied());
|
||||
vo.setProxyReason(f.getProxyReason());
|
||||
vo.setVersion(f.getVersion());
|
||||
vo.setIsCurrent(Integer.valueOf(CollectFile.CURRENT).equals(f.getIsCurrent()));
|
||||
// 明确告诉前端"你要的原件没有了,这是规范件"—— 比给他一个空的 previewUrl 有用
|
||||
vo.setActualKind(wantOriginal && !u.original() ? "STANDARD" : (wantOriginal ? "ORIGINAL" : "STANDARD"));
|
||||
vo.setExpireSeconds(u.expiresIn());
|
||||
vo.setContentLength((long) c.bytes().length);
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── §9.5 直传凭证 ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 直传凭证(预检 + 秒传 + 压缩提示)。
|
||||
*
|
||||
* <p>幂等:同一个 `fileHash` 会命中同一条已存在的文件并返回 `existed: true`。
|
||||
*/
|
||||
public MaterialDtos.UploadTokenVO uploadToken(MaterialDtos.UploadTokenReq req) {
|
||||
LoginUser me = UserContext.require();
|
||||
if (req == null || req.getTaskId() == null || req.getMaterialCode() == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请传 taskId 与 materialCode");
|
||||
}
|
||||
CollectSubmission sub = submissionMapper.selectOne(Wrappers.<CollectSubmission>lambdaQuery()
|
||||
.eq(CollectSubmission::getTaskId, req.getTaskId())
|
||||
.eq(CollectSubmission::getSubmitterId, me.uid())
|
||||
.last("limit 1"));
|
||||
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,
|
||||
"这份填报单已经提交,要换材料请先撤回");
|
||||
}
|
||||
CollectMaterialDef def = materialDefMapper.selectOne(
|
||||
Wrappers.<CollectMaterialDef>lambdaQuery()
|
||||
.eq(CollectMaterialDef::getTaskId, req.getTaskId())
|
||||
.eq(CollectMaterialDef::getMaterialCode, req.getMaterialCode())
|
||||
.last("limit 1"));
|
||||
if (def == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"这份任务里没有「" + req.getMaterialCode() + "」这项材料");
|
||||
}
|
||||
|
||||
MaterialDtos.UploadTokenVO vo = new MaterialDtos.UploadTokenVO();
|
||||
|
||||
// ① 体积预检(与上传时同一口径的 maxSizeKb)——
|
||||
// 在"还没传一个字节"的时候就拦,比传完 8MB 再拒要好得多
|
||||
if (req.getFileSize() != null && def.getMaxSizeKb() != null
|
||||
&& req.getFileSize() > def.getMaxSizeKb() * 1024L) {
|
||||
vo.setOk(false);
|
||||
vo.setRejectReason("「" + def.getMaterialName() + "」最多 "
|
||||
+ def.getMaxSizeKb() + "KB,你选的文件是 "
|
||||
+ (req.getFileSize() / 1024) + "KB。前端可以先压缩再传");
|
||||
vo.setClientCompressHint(hint(def, req.getFileSize()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ② 秒传:**必须排在份数检查之前**。
|
||||
//
|
||||
// 顺序体现的是"哪个答案对用户更有用":学生传完网络断了、客户端重试时,
|
||||
// 正确的回答是「你已经传过了」(不需要他做任何事),
|
||||
// 而不是先撞上份数上限、得到「最多 2 份,要换请先删掉上一份」——
|
||||
// 那会让他在"我明明刚传的"这件事上原地打转。
|
||||
// **"你已经传过了"永远比"你超了"更有用。**:客户端给了 fileHash 且这个人在这个槽位已经有一份同样的文件
|
||||
if (req.getFileHash() != null && !req.getFileHash().isBlank()) {
|
||||
CollectFile exist = fileMapper.selectOne(Wrappers.<CollectFile>lambdaQuery()
|
||||
.eq(CollectFile::getSubmissionId, sub.getId())
|
||||
.eq(CollectFile::getMaterialCode, req.getMaterialCode())
|
||||
.eq(CollectFile::getFileHash, req.getFileHash().trim())
|
||||
.eq(CollectFile::getIsCurrent, CollectFile.CURRENT)
|
||||
.last("limit 1"));
|
||||
if (exist != null) {
|
||||
vo.setOk(true);
|
||||
vo.setExisted(true);
|
||||
vo.setFileId(exist.getId());
|
||||
// 秒传时**不返回 uploadUrl**:前端据此跳过上传,直接标记完成。
|
||||
// 返回一个地址而前端不用,会让"到底传没传"变成两处判断
|
||||
vo.setUploadUrl(null);
|
||||
vo.setObjectKey(exist.getStdPath());
|
||||
vo.setExpireSeconds(0L);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 份数预检(放在秒传之后,见上)
|
||||
long have = fileMapper.selectCount(Wrappers.<CollectFile>lambdaQuery()
|
||||
.eq(CollectFile::getSubmissionId, sub.getId())
|
||||
.eq(CollectFile::getMaterialCode, req.getMaterialCode())
|
||||
.eq(CollectFile::getIsCurrent, CollectFile.CURRENT));
|
||||
int max = def.getMaxCount() == null ? 1 : def.getMaxCount();
|
||||
if (have >= max) {
|
||||
vo.setOk(false);
|
||||
vo.setRejectReason("「" + def.getMaterialName() + "」最多 " + max + " 份,你已经传了 "
|
||||
+ have + " 份。要换请先删掉上一份");
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ④ 给出"代收"地址(一期不直传,见类注释)
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
JSONObject bind = JSONUtil.createObj()
|
||||
.set("submissionId", sub.getId())
|
||||
.set("taskId", req.getTaskId())
|
||||
.set("materialCode", req.getMaterialCode())
|
||||
.set("uid", me.uid());
|
||||
redis.opsForValue().set(TOKEN_KEY + token, bind.toString(),
|
||||
Duration.ofSeconds(UPLOAD_TOKEN_TTL_SECONDS));
|
||||
|
||||
String ext = extOf(req.getFileName());
|
||||
String objectKey = String.format("raw/%d/%d/%s/%s.%s",
|
||||
me.tenantId(), req.getTaskId(), req.getMaterialCode(), token, ext);
|
||||
vo.setOk(true);
|
||||
vo.setExisted(false);
|
||||
vo.setFileId(null); // 上传成功之后才有文件记录
|
||||
vo.setUploadToken(token);
|
||||
vo.setObjectKey(objectKey);
|
||||
vo.setUploadUrl("/api/v1/mp/files/upload");
|
||||
vo.setDirect(false);
|
||||
vo.setExpireSeconds(UPLOAD_TOKEN_TTL_SECONDS);
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("Content-Type", "multipart/form-data");
|
||||
vo.setHeaders(headers);
|
||||
vo.setClientCompressHint(hint(def, req.getFileSize()));
|
||||
vo.getNotes().add("一期不是直传:uploadUrl 是本项目的接收地址"
|
||||
+ "(学生端用 wx.uploadFile 直接 POST 它,与 H5 路径一致)。"
|
||||
+ "七牛直传要先把「上传完成回调」打通,见 F-71");
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 消费一次直传凭证(上传接口用)。 */
|
||||
public JSONObject consumeUploadToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String v = redis.opsForValue().get(TOKEN_KEY + token);
|
||||
if (v == null) {
|
||||
// ⚠️ 用契约 §15 里已有的码,不自己造:
|
||||
// `40001` 的语义是"存在阻断性校验问题,无法提交",而"凭证过期"就是
|
||||
// 一种"这次提交不成立、要重来"的情形。造一个新码会让前端/客服
|
||||
// 每次都要查"这个 400xx 是什么"
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"上传凭证已过期或不存在(15 分钟有效),请重新获取");
|
||||
}
|
||||
return JSONUtil.parseObj(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端压缩提示。
|
||||
*
|
||||
* <p>契约 §9.5 的 `clientCompressHint`。为什么让**客户端**压:
|
||||
* 学生用手机流量传 8MB 的原图,压缩在手机上做(几百毫秒)比传完再在服务端压
|
||||
* 省掉的是**用户的流量与等待**。服务端仍会压一遍(保证口径一致)。
|
||||
*/
|
||||
private MaterialDtos.CompressHint hint(CollectMaterialDef def, Long fileSize) {
|
||||
MaterialDtos.CompressHint h = new MaterialDtos.CompressHint();
|
||||
h.setEnabled(true);
|
||||
// 证件与 A4 扫描件长边 1600 足够(300 DPI 下 A4 约 2480 长边,
|
||||
// 但手机看与 OCR 都用不到那么多,1600 是"清晰且小"的平衡点)
|
||||
h.setMaxLongSide(1600);
|
||||
h.setQuality(80);
|
||||
h.setTargetSizeKb(def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb().longValue());
|
||||
h.setCurrentSizeKb(fileSize == null ? null : fileSize / 1024);
|
||||
h.setNeeded(fileSize != null && def.getMaxSizeKb() != null
|
||||
&& fileSize > def.getMaxSizeKb() * 1024L);
|
||||
return h;
|
||||
}
|
||||
|
||||
// ── §9.7 批量处理 ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 多材料一次处理。
|
||||
*
|
||||
* <p>它做的事:把每个 `fileId` 走一遍规范化(与单份 `/process` 同一个方法),
|
||||
* 并**顺带做一次"这看起来是哪种材料"的判断**,冲突时只提示(见类注释)。
|
||||
*
|
||||
* <p>`async` 参数:契约有它,但一期**同步跑完**(一次最多几个文件,
|
||||
* 每个几百毫秒)。同步的好处是响应里就能给出 `results` ——
|
||||
* 而异步的话前端要再轮询一次,而它拿到的"结果"和现在这份是同一个东西。
|
||||
*/
|
||||
public MaterialDtos.BatchProcessVO batchProcess(MaterialDtos.BatchProcessReq req) {
|
||||
LoginUser me = UserContext.require();
|
||||
if (req == null || req.getSubmissionId() == null
|
||||
|| req.getFileIds() == null || req.getFileIds().isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请传 submissionId 与 fileIds");
|
||||
}
|
||||
if (req.getFileIds().size() > 20) {
|
||||
throw new BizException(ErrorCode.BATCH_LIMIT_EXCEEDED,
|
||||
"一次最多处理 20 个文件(当前 " + req.getFileIds().size() + " 个)");
|
||||
}
|
||||
CollectSubmission sub = submissionMapper.selectMineById(req.getSubmissionId(),
|
||||
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,
|
||||
"这份填报单已经提交,要改材料请先撤回");
|
||||
}
|
||||
|
||||
MaterialDtos.BatchProcessVO out = new MaterialDtos.BatchProcessVO();
|
||||
out.setBatchToken("b_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
|
||||
out.setTotal(req.getFileIds().size());
|
||||
int ok = 0;
|
||||
int conflict = 0;
|
||||
int failed = 0;
|
||||
|
||||
for (Long fileId : req.getFileIds()) {
|
||||
MaterialDtos.BatchItemVO item = new MaterialDtos.BatchItemVO();
|
||||
item.setFileId(fileId);
|
||||
CollectFile f = fileMapper.selectById(fileId);
|
||||
if (f == null || !req.getSubmissionId().equals(f.getSubmissionId())) {
|
||||
item.setStatus(3);
|
||||
item.setError("文件不存在或不属于这份填报单");
|
||||
failed++;
|
||||
out.getResults().add(item);
|
||||
continue;
|
||||
}
|
||||
item.setMaterialCode(f.getMaterialCode());
|
||||
try {
|
||||
// 与单份 /process 走**同一个**方法:规范化口径只有一处
|
||||
// (两处各写一遍的话,"压到多少 KB、按什么模板改名"早晚会分叉)
|
||||
fileService.process(fileId);
|
||||
CollectFile after = fileMapper.selectById(fileId);
|
||||
item.setStatus(after == null ? 0 : after.getValidateStatus());
|
||||
item.setStandardName(after == null ? null : after.getStandardName());
|
||||
// needManualPlace:0 = 系统判得出这是哪种材料,1 = 判不出、要人工归位
|
||||
item.setNeedManualPlace(after != null
|
||||
&& after.getNeedManualPlace() != null
|
||||
&& after.getNeedManualPlace() != CollectFile.NEED_MANUAL_NO);
|
||||
} catch (RuntimeException e) {
|
||||
// 一个文件失败不该让整批失败 —— 其余文件已经处理好了
|
||||
item.setStatus(3);
|
||||
item.setError(e.getMessage() == null ? "处理失败" : e.getMessage());
|
||||
failed++;
|
||||
out.getResults().add(item);
|
||||
continue;
|
||||
}
|
||||
// 冲突判断:一期**不做形态识别**(M4 未接),所以 detectMaterialCode 留空、
|
||||
// conflict 恒为 false。**不编一个"看起来识别了"的值** ——
|
||||
// 那会让前端弹出"这看起来是工作证明,要移过去吗",而系统其实什么都没看
|
||||
item.setDetectMaterialCode(null);
|
||||
item.setConflict(false);
|
||||
item.setDetectConfidence(null);
|
||||
ok++;
|
||||
out.getResults().add(item);
|
||||
}
|
||||
|
||||
MaterialDtos.BatchSummary sum = new MaterialDtos.BatchSummary();
|
||||
sum.setOk(ok);
|
||||
sum.setConflict(conflict);
|
||||
sum.setFailed(failed);
|
||||
out.setSummary(sum);
|
||||
if (conflict == 0) {
|
||||
out.getNotes().add("一期没有形态识别(M4 未接),所以 conflict 恒为 false、"
|
||||
+ "detectMaterialCode 恒为空 —— 不是「识别后没问题」,而是「还没识别」");
|
||||
}
|
||||
log.info("批量处理:submission={} 共 {} 个,成功 {},失败 {}",
|
||||
req.getSubmissionId(), out.getTotal(), ok, failed);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String extOf(String name) {
|
||||
if (name == null) {
|
||||
return "bin";
|
||||
}
|
||||
int dot = name.lastIndexOf('.');
|
||||
return dot > 0 && dot < name.length() - 1
|
||||
? name.substring(dot + 1).toLowerCase() : "bin";
|
||||
}
|
||||
}
|
||||
+12
@@ -61,6 +61,18 @@ public class LocalStorageService implements StorageService {
|
||||
path, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地磁盘存储**没有**图片处理能力,所以缩略图回退到原图。
|
||||
*
|
||||
* <p>⚠️ 这不是"偷懒":回退让调用方**不必判断当前是什么存储** ——
|
||||
* 否则每处用到缩略图的地方都要写 `if (provider == qiniu)`,
|
||||
* 而那几处迟早会有人忘。代价是本地开发时缩略图不省流量(本地也无所谓)。
|
||||
*/
|
||||
@Override
|
||||
public String thumbUrl(String path, long expireSeconds, int maxLongSide) {
|
||||
return url(path, expireSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String path) {
|
||||
return Files.exists(resolve(path));
|
||||
|
||||
+26
@@ -141,6 +141,32 @@ public class QiniuStorageService implements StorageService {
|
||||
return auth.privateDownloadUrl(publicUrl(path), deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩略图链接(七牛图片处理:`imageView2/2/w/{n}`)。
|
||||
*
|
||||
* <p>⚠️⚠️ **参数必须拼在签名之前**:`privateDownloadUrl` 的签名覆盖
|
||||
* **整个 URL 字符串**(含 query)。先签名再拼参数 → 签名对不上 → 服务端 401,
|
||||
* 而现象是"图片加载不出来",很容易被当成前端或网络问题查半天。
|
||||
*
|
||||
* <p>用 `imageView2/2/w/{n}`(限定宽度、等比缩放)而不是 `imageMogr2`:
|
||||
* 前者是七牛最省资源的模式,而审核列表这种"一次几百张"的场景正是它擅长的。
|
||||
* `w` 是**宽**,不是长边 —— 材料以竖拍为主,限宽后的高度会等比放大,
|
||||
* 所以这里**同时给 `/h/` 限制高度**,避免竖图仍然很大:
|
||||
* `imageView2/2/w/{n}/h/{n}` 的实际语义是"在 n×n 的框内等比缩放"(不变形)。
|
||||
*/
|
||||
@Override
|
||||
public String thumbUrl(String path, long expireSeconds, int maxLongSide) {
|
||||
if (maxLongSide <= 0) {
|
||||
return url(path, expireSeconds);
|
||||
}
|
||||
long seconds = expireSeconds > 0 ? expireSeconds : defaultExpireSeconds;
|
||||
long deadline = System.currentTimeMillis() / 1000 + seconds;
|
||||
// ★ 先把图片处理参数拼上,再整体签名
|
||||
String withStyle = publicUrl(path) + "?imageView2/2/w/" + maxLongSide
|
||||
+ "/h/" + maxLongSide;
|
||||
return auth.privateDownloadUrl(withStyle, deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* 不带签名的公网地址(私有空间直接访问会 403,仅供日志排查与签名内部使用)。
|
||||
*/
|
||||
|
||||
+23
@@ -53,6 +53,29 @@ public interface StorageService {
|
||||
*/
|
||||
String url(String path, long expireSeconds);
|
||||
|
||||
/**
|
||||
* **缩略图**的访问链接(长边压到 {@code maxLongSide} 像素)。
|
||||
*
|
||||
* <h3>为什么要有它</h3>
|
||||
*
|
||||
* <p>审核列表一屏 200 张材料,每张是手机拍的 700KB 原图 → 一次列表 **140MB**。
|
||||
* 而审核时老师要判断的是"这是不是一张身份证",不是"看清防伪纹"。
|
||||
* 长边 400px 的缩略图约 10KB → **2MB**,差两个数量级。
|
||||
*
|
||||
* <h3>⚠️ 参数必须加在签名之前</h3>
|
||||
*
|
||||
* <p>私有空间的下载链接是签名的,而签名覆盖**整个 URL 字符串**。
|
||||
* "先签名再拼参数"会让签名对不上,服务端回 401 ——
|
||||
* 而表现是"图片加载不出来",排查时很容易怀疑前端或网络。
|
||||
* 这个坑由实现层负责避开(见 {@code QiniuStorageService})。
|
||||
*
|
||||
* <h3>回退语义</h3>
|
||||
*
|
||||
* <p>{@code maxLongSide <= 0}、或存储本身不支持缩略图(本地磁盘)时,
|
||||
* **返回与 {@link #url} 完全相同的结果** —— 调用方不必判断"当前是什么存储"。
|
||||
*/
|
||||
String thumbUrl(String path, long expireSeconds, int maxLongSide);
|
||||
|
||||
boolean exists(String path);
|
||||
|
||||
/** 删除。软删业务记录时**不要**调它 —— 原始件要留着备查。 */
|
||||
|
||||
Reference in New Issue
Block a user