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:
+9
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+27
-4
@@ -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);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user