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:
2026-09-30 09:02:56 +08:00
parent 9216bd66d2
commit c8837ee602
10 changed files with 813 additions and 12 deletions
@@ -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));
}
}
@@ -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<>();
}
}
@@ -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)。 */
@@ -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";
}
}
@@ -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));
@@ -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,仅供日志排查与签名内部使用)。
*/
@@ -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);
/** 删除。软删业务记录时**不要**调它 —— 原始件要留着备查。 */
+15
View File
@@ -0,0 +1,15 @@
# H5 开发时的环境变量(`pnpm dev:h5` 用)
#
# ⚠️ 这个文件**只在 dev server 下生效**(vite 的 .env.development 约定)。
# 它是给"独立 dev server"用的,不是给构建产物用的。
#
# 为什么是**空串**:
# `src/config/index.ts` 的逻辑是"优先取 VITE_API_BASE,没有才用 location.origin"。
# dev server 下 location.origin 是 dev server 自己(≠ 后端),
# 所以这里显式设成空 → 走相对路径 → 由 `vite.config.ts` 的 server.proxy
# 把 /api 转发到 http://localhost:8080。
#
# ⚠️ 构建产物(`pnpm build:h5`,由后端托管)**不要**带这个变量 ——
# 那种场景下 location.origin 就是对的(页面与接口同源)。
# 所以它写在这里而不是 .env(后者对所有模式生效)。
VITE_API_BASE = ''
+18 -8
View File
@@ -19,17 +19,27 @@
*/
// #ifdef H5
/**
* H5:用**绝对的同源地址**(`location.origin`),不要用相对路径。
* H5:优先取 `VITE_API_BASE`,没有才用**同源的** `location.origin`。
*
* ⚠️ 实测:`uni.request` 传相对路径(`/api/v1/...`)会以 `request:fail` 失败,
* 而**同样地址用原生 XHR / fetch 是成功的**(都是 200)。也就是说这是 uni-app
* 自身的处理差异,不是服务端或跨域问题 —— 但排查时它长得像"网络不通",
* 很容易往 CORS / 域名 / 后端去查,白绕一大圈。
* ⚠️ 为什么要有 `VITE_API_BASE` 这一档:
*
* 用 `location.origin` 而不是 `''`:既绕开这个差异,又保持**同源**
* (不经过 CORS),换域名也不用改配置。
* `location.origin` 是"与页面同源",这在**后端托管构建产物**时是对的
* (`http://localhost:8080` 上页面与接口同源)。
* 但 `pnpm dev:h5` 起的是一个**独立的 vite dev server**(另一个端口)——
* 那时 `location.origin` 指的是 dev server 自己,接口就打到 dev server 上去了,
* 于是只能"build 一次再看",改一行要等一次完整构建。
*
* 所以 dev server 下把这个值设成**空串**(见 `.env.development`),
* 让请求走相对路径 `/api/...`,再由 vite 的 `server.proxy` 转发到后端。
*
* ⚠️ 另外:`uni.request` **不能**传相对路径(实测会 `request:fail`,
* 而同样地址用原生 XHR/fetch 是 200)。所以空串时补上 `location.origin`,
* 保持"绝对地址"这个前提不变 —— 只是那个 origin 由代理兜住。
*/
export const API_BASE_URL = typeof location !== 'undefined' ? location.origin : ''
const VITE_API_BASE = (import.meta.env?.VITE_API_BASE ?? '') as string
export const API_BASE_URL =
VITE_API_BASE !== '' ? VITE_API_BASE
: (typeof location !== 'undefined' ? location.origin : '')
// #endif
// #ifndef H5
+35 -1
View File
@@ -1,7 +1,41 @@
import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// https://vitejs.dev/config/
/**
* 学生端(uni-app)的 vite 配置。
*
* ## ⚠️ 这里加了一个 dev proxy,解决一个真实的开发痛点
*
* H5 **没有独立 dev server** 是之前的状态:`src/config/index.ts` 里
* `API_BASE_URL = location.origin`,而 vite dev server 不代理 `/api`,
* 于是 `pnpm dev:h5`(另一个端口)打不到后端 8080 上的接口 ——
* 只能 `pnpm build:h5` 再访问**后端托管的产物**(http://localhost:8080)。
*
* 后果不只是"麻烦一点":**改一行要等一次完整构建**,而学生端正是改得最多的地方
* (表单、上传、核验页),反馈慢会直接改变开发方式 ——
* 人会倾向"一次改一堆再构建",而那时候出问题已经分不清是哪一处引起的。
*
* 所以这里补两件事(**缺一不可**):
* ① 本文件的 `server.proxy`:把 `/api` 转发到后端;
* ② `src/config/index.ts` 里让 `API_BASE_URL` 优先取 `VITE_API_BASE` ——
* 否则它仍然是 `location.origin`(= dev server 自己),代理配得再对也没用。
*
* ## ⚠️ 微信小程序端不受影响
*
* 小程序的请求不走 vite(它在微信开发者工具里跑),而且微信的
* **request 合法域名是固定白名单**,配不了 localhost —— 所以那边照旧。
* 由 `#ifdef H5` 条件编译保证。
*/
export default defineConfig({
plugins: [uni()],
server: {
port: 5174,
proxy: {
// 与 frontend-pc 同一做法:dev server 上的 /api 转发到本机后端
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});