feat(h5): 学生端材料预览 + 删掉部署里的 MinIO

一、部署配置清理
  docker-compose.yml 里的 MinIO 删了(连 minio-data 卷)。它当初是
  "M0 阶段不写入但先起好 —— 本地/生产走同一套 S3 接口",但代码最终走
  七牛官方 SDK,那个容器**从来没被连过** —— 留着只会让人以为"本地还有个
  对象存储要用"。.env.example / .env 的 MINIO_* 换成 QINIU_* + XT_STORAGE_*。

  ⚠️ 删的时候我自己犯了个错:用 volumes:\n 当插入锚点,结果匹配到了
     **pg 服务内部的 `  volumes:`**,把注释插进 pg 定义里还把它顶到第 0 列,
     **YAML 结构就这么坏了**。好在写完立刻用 yaml.safe_load 复查(services: [pg, redis])
     才发现。教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。

二、学生端材料预览(新功能)
  已传材料每行加「预览」,点开全屏浮层(深底 —— 材料照片多是白底证件件,
  浅底会看不清边界),点图片以外关闭。

  **两种存储两种取法,不能只写一种**:
    · 七牛:接口返回带签名的绝对 URL,直接给 <image src> 用
    · 本地磁盘:那个接口要带 Authorization,而 <image src> 发不了请求头 ——
      先用 uni.request 取字节,再转 blob URL
  只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。
  签名链接有有效期(30 分钟),所以每次预览都现取,不缓存 URL。

  顺手修两个后端问题:
    · LocalStorageService.url() 返回的路径指向一个**不存在的接口**
      (我建的是 /{id}/content,不是 ?path=)→ 改由 MaterialFileService 按
      provider 决定:七牛给签名链接、本地给 /{id}/content(按 id 取能复用归属校验)
    · /content 原来固定 application/octet-stream —— 表现是**点开身份证照片
      变成下载一个文件**。现在按文件名推 Content-Type + inline

三、浏览器实测
  点预览 → 浮层打开、图片真的解码(naturalWidth=900、complete=true);
  直接 fetch 那个签名 URL → 200 / 17728 字节。
  中间发现一个视觉问题:**浮层标题与「关闭」被 uni-app 导航栏盖住了**
  (page-head 的 z-index 是 998,我给的 95)—— 看起来像"标题没渲染出来"。
  改成 9999 + padding-top 让出导航高度;问题清单浮层一并提到 9998。复验通过。

四、顺带
  dev-seed-warn-state.py 改为没有可用草稿时自己建一份 —— 原来写死 submission id,
  被 E2E 清理后就报"填报单不存在或不属于你",而那看起来像权限问题。

验收:e2e-m3-file 32/32、m3-submit 31/31、m1-storage 16/16
This commit is contained in:
2026-09-29 15:16:36 +08:00
parent d8463a051f
commit fe7615d12b
7 changed files with 263 additions and 40 deletions
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -123,9 +124,14 @@ public class MaterialFileController {
+ "两种实现都要求**登录且文件属于你**")
public ResponseEntity<byte[]> content(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean original) {
byte[] bytes = fileService.content(id, original);
MaterialFileService.Content c = fileService.content(id, original);
// 按文件名推 Content-Type:猜错的表现很别扭(身份证照片点开变成下载一个文件)
MediaType type = MediaTypeFactory.getMediaType(c.name())
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes);
.contentType(type)
// inline:让浏览器直接展示(图片/PDF),而不是弹出下载
.header("Content-Disposition", "inline")
.body(c.bytes());
}
}
@@ -262,20 +262,43 @@ public class MaterialFileService {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传");
}
long exp = expireSeconds == null || expireSeconds <= 0 ? 1800 : expireSeconds;
return new FileUrl(storage.url(path, exp), exp, storage.provider(),
String url = storage.url(path, exp);
if (!"qiniu".equals(storage.provider())) {
// 本地存储没有"公网地址"这回事:七牛那边给的是带签名的绝对 URL,
// 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口
// (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId,
// 而按 id 取能复用"这份文件属于你"的归属校验)
url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + original;
}
return new FileUrl(url, exp, storage.provider(),
original ? f.getOriginalName() : (f.getStandardName() == null
? f.getOriginalName() : f.getStandardName()));
}
/** 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。 */
public byte[] content(Long fileId, boolean original) {
/** 一份材料的内容与其文件名(文件名用来推 Content-Type)。 */
public record Content(byte[] bytes, String name) {
}
/**
* 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。
*
* <p>连文件名一起返回,是因为要由它推 {@code Content-Type} ——
* 否则浏览器拿到 {@code application/octet-stream},表现是
* <b>点开身份证照片变成了下载一个文件</b>,而不是直接看到图。
*/
public Content content(Long fileId, boolean original) {
LoginUser me = UserContext.require();
CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid());
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
String path = original ? f.getRawPath() : f.getStdPath();
return readFile(path == null || path.isBlank() ? f.getRawPath() : path);
if (path == null || path.isBlank()) {
path = f.getRawPath();
}
String name = original ? f.getOriginalName()
: (f.getStandardName() == null ? f.getOriginalName() : f.getStandardName());
return new Content(readFile(path), name);
}
// ── 内部 ──────────────────────────────────────