diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java index 0515e3a..84fea1c 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java @@ -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 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 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 view( + @PathVariable Long id, + @RequestParam(defaultValue = "preview") String prefer) { + return ApiResult.ok(viewService.view(id, prefer)); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/dto/MaterialDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/dto/MaterialDtos.java new file mode 100644 index 0000000..99dd06a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/dto/MaterialDtos.java @@ -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}。 + * + *

⚠️ 这个字段是必要的,不是冗余:请求 {@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 headers = new LinkedHashMap<>(); + + /** 一期恒为 false(见 `MaterialFileViewService` 的类注释)。 */ + private Boolean direct; + + /** 服务端代收入口要带的一次性凭证(15 分钟)。 */ + private String uploadToken; + + private CompressHint clientCompressHint; + + private List 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 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 results = new ArrayList<>(); + private BatchSummary summary; + private List notes = new ArrayList<>(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java index ca9e419..bc7010e 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java @@ -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); + } + + /** + * 把一份材料存到**给定的填报单**上(不含"这份单子是不是你的"的判断)。 + * + *

抽出来是为了让两条路共用同一套校验与规范化: + *

    + *
  • 学生自己传({@link #upload})—— 先校验填报单属于他;
  • + *
  • 班主任代传({@code MpTeacherProxyService})—— 先校验他管得着这个班。
  • + *
+ * + *

⚠️ 为什么这里不自己判权限:两条路的授权口径完全不同 + * (一个按 submitter_id,一个按班级范围)。把它们塞进这个方法里, + * 迟早会出现"某一处忘了传一个参数于是越权" —— 授权放在调用方、 + * 并且调用方各自只有一条明确的判定,更容易看住。 + * + *

但**已提交不能再传**这条留在这里:它对两条路都一样 + * (代传也不能给正在审的单子塞材料 —— 那会让审到一半的材料清单变了)。 + */ + @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); } + /** + * 审核列表用的缩略图长边(像素)。 + * + *

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 { *

⚠️ 不要让审核端复用 {@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)。 */ diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileViewService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileViewService.java new file mode 100644 index 0000000..82270de --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileViewService.java @@ -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 预览)。 + * + *

§9.5 直传凭证:一期为什么返回"服务端代收地址"而不是七牛直传

+ * + *

契约给的是 S3 风格的"签名 PUT URL"。而本项目存储走**七牛**, + * 七牛的直传是"POST 到 upload.qiniup.com + form 里带 token", + * 而且**必须配回调**(`callbackUrl`)才能让服务端知道"传完了、文件在哪" —— + * 没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它。 + * + *

所以一期: + *

    + *
  • {@code direct: false},`uploadUrl` 就是本项目已有的上传接口 + * (学生端 `wx.uploadFile` 直接 POST 它,与现在 H5 的路径**完全一样**);
  • + *
  • 直传凭证这一层仍然有价值:**预检 + 秒传 + 压缩提示**, + * 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题 + * (学生传了 8MB 才发现格式不对/重复了);
  • + *
  • 七牛直传等回调打通之后再加 `direct: true`(记 F-71)。
  • + *
+ * + *

§9.7 批量处理:冲突只提示、不自动改位置

+ * + *

契约 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 与元信息。 + * + *

⚠️ 授权按 `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 直传凭证 ───────────────────────────── + + /** + * 直传凭证(预检 + 秒传 + 压缩提示)。 + * + *

幂等:同一个 `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.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.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.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.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 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); + } + + /** + * 客户端压缩提示。 + * + *

契约 §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 批量处理 ───────────────────────────── + + /** + * 多材料一次处理。 + * + *

它做的事:把每个 `fileId` 走一遍规范化(与单份 `/process` 同一个方法), + * 并**顺带做一次"这看起来是哪种材料"的判断**,冲突时只提示(见类注释)。 + * + *

`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"; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java index 9867dc7..faf5c72 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java @@ -61,6 +61,18 @@ public class LocalStorageService implements StorageService { path, java.nio.charset.StandardCharsets.UTF_8); } + /** + * 本地磁盘存储**没有**图片处理能力,所以缩略图回退到原图。 + * + *

⚠️ 这不是"偷懒":回退让调用方**不必判断当前是什么存储** —— + * 否则每处用到缩略图的地方都要写 `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)); diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java index 573d41e..de2c427 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java @@ -141,6 +141,32 @@ public class QiniuStorageService implements StorageService { return auth.privateDownloadUrl(publicUrl(path), deadline); } + /** + * 缩略图链接(七牛图片处理:`imageView2/2/w/{n}`)。 + * + *

⚠️⚠️ **参数必须拼在签名之前**:`privateDownloadUrl` 的签名覆盖 + * **整个 URL 字符串**(含 query)。先签名再拼参数 → 签名对不上 → 服务端 401, + * 而现象是"图片加载不出来",很容易被当成前端或网络问题查半天。 + * + *

用 `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,仅供日志排查与签名内部使用)。 */ diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java index 53a485e..6582317 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java @@ -53,6 +53,29 @@ public interface StorageService { */ String url(String path, long expireSeconds); + /** + * **缩略图**的访问链接(长边压到 {@code maxLongSide} 像素)。 + * + *

为什么要有它

+ * + *

审核列表一屏 200 张材料,每张是手机拍的 700KB 原图 → 一次列表 **140MB**。 + * 而审核时老师要判断的是"这是不是一张身份证",不是"看清防伪纹"。 + * 长边 400px 的缩略图约 10KB → **2MB**,差两个数量级。 + * + *

⚠️ 参数必须加在签名之前

+ * + *

私有空间的下载链接是签名的,而签名覆盖**整个 URL 字符串**。 + * "先签名再拼参数"会让签名对不上,服务端回 401 —— + * 而表现是"图片加载不出来",排查时很容易怀疑前端或网络。 + * 这个坑由实现层负责避开(见 {@code QiniuStorageService})。 + * + *

回退语义

+ * + *

{@code maxLongSide <= 0}、或存储本身不支持缩略图(本地磁盘)时, + * **返回与 {@link #url} 完全相同的结果** —— 调用方不必判断"当前是什么存储"。 + */ + String thumbUrl(String path, long expireSeconds, int maxLongSide); + boolean exists(String path); /** 删除。软删业务记录时**不要**调它 —— 原始件要留着备查。 */ diff --git a/frontend-mp/.env.development b/frontend-mp/.env.development new file mode 100644 index 0000000..b9593a0 --- /dev/null +++ b/frontend-mp/.env.development @@ -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 = '' diff --git a/frontend-mp/src/config/index.ts b/frontend-mp/src/config/index.ts index e63078f..c266510 100644 --- a/frontend-mp/src/config/index.ts +++ b/frontend-mp/src/config/index.ts @@ -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 diff --git a/frontend-mp/vite.config.ts b/frontend-mp/vite.config.ts index 46e36fe..9238015 100644 --- a/frontend-mp/vite.config.ts +++ b/frontend-mp/vite.config.ts @@ -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, + }, + }, + }, });