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 88a7b4e..a7b17d8 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 @@ -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 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()); } } 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 f6c0731..160a6ff 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 @@ -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) { + } + + /** + * 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。 + * + *

连文件名一起返回,是因为要由它推 {@code Content-Type} —— + * 否则浏览器拿到 {@code application/octet-stream},表现是 + * 点开身份证照片变成了下载一个文件,而不是直接看到图。 + */ + 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); } // ── 内部 ────────────────────────────────────── diff --git a/deploy/.env.example b/deploy/.env.example index 1f95ff4..c413ded 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -12,8 +12,13 @@ PG_PORT=5432 REDIS_PORT=16379 REDIS_PASSWORD=redis_dev_pwd -# MinIO -MINIO_USER=minioadmin -MINIO_PASSWORD=minioadmin -MINIO_PORT=9000 -MINIO_CONSOLE_PORT=9001 +# ── 对象存储(七牛云)─────────────────────────────── +# ⚠️ AK/SK 是**有计费与数据权限的真凭据**,不要写进 .env.example(那个是提交的) +XT_STORAGE_PROVIDER=qiniu +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= +QINIU_BUCKET=tools-matrix +QINIU_DOMAIN=https://file.tools.coding.design +QINIU_REGION=auto +# 换存储/换密钥之后值得开一次:启动时真的走一遍 写入→存在→读回→删除 +XT_STORAGE_SELF_CHECK=false diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 765ee7a..f15a2fa 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -7,6 +7,13 @@ # # ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379 # (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们) +# +# 对象存储:用**七牛云**,这里不起容器。 +# 材料原图与规范件存七牛(空间 tools-matrix,私有 → 下载走签名链接), +# 由后端用官方 SDK 直连,不走 S3 协议。 +# 曾经起过一个 MinIO 备用("本地/生产走同一套 S3 接口"),但代码最终走的是七牛 SDK, +# 那个容器从来没被连过 —— 留着只会让人以为"本地还有个对象存储要用",所以删掉了。 +# 本地开发也不需要它:provider=local 时文件落在 backend/data/uploads。 name: xiaotutongxue @@ -63,31 +70,6 @@ services: retries: 12 start_period: 5s - # ── MinIO(对象存储,S3 兼容)───────────────────── - # M0 阶段不写入,但先起好——本地/生产的存储层走同一套 S3 接口, - # 换阿里云 OSS 只改配置,不改代码。 - minio: - image: minio/minio:latest - container_name: xt-minio - restart: unless-stopped - environment: - MINIO_ROOT_USER: ${MINIO_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin} - TZ: Asia/Shanghai - ports: - - "${MINIO_PORT:-9000}:9000" - - "${MINIO_CONSOLE_PORT:-9001}:9001" - volumes: - - minio-data:/data - command: server /data --console-address ":9001" - healthcheck: - test: ["CMD-SHELL", "mc ready local || exit 1"] - interval: 10s - timeout: 5s - retries: 6 - start_period: 15s - volumes: pg-data: redis-data: - minio-data: diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 77097b9..c52b366 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -1334,3 +1334,68 @@ m1-materials 68 / m1-form 48 / m1-task 71 / m1-orgs 48 `_selfcheck/`、`_probe_/`)。**这是刻意的**:原始件要留着备查。 要清就在七牛控制台按前缀清,或跑一次带清理的脚本。 - `backend/data/uploads/` 里有本地磁盘时代的旧测试文件(已 gitignore),可随时删。 + + +--- + +## §一·十九 部署配置清理 + 学生端材料预览 + +### ① 部署配置:删掉 MinIO +`deploy/docker-compose.yml` 里的 MinIO 删了(连带 `minio-data` 卷)。 +它当初是"M0 阶段不写入,但先起好 —— 本地/生产走同一套 S3 接口", +但代码最终走的是**七牛官方 SDK**,那个容器**从来没被连过** —— +留着只会让人以为"本地还有个对象存储要用"。已在文件头写明原因。 +`.env.example` / `.env` 里的 `MINIO_*` 换成 `QINIU_*` + `XT_STORAGE_*`。 + +> ⚠️ 我自己在删的时候犯了个错,值得记:用 `volumes: +` 当锚点插入说明, +> 结果匹配到了 **pg 服务内部的 ` volumes:`**,把注释插进了 pg 的定义里, +> 还把它顶到了第 0 列 —— **YAML 结构就这么坏了**(`pg.volumes` 变成顶层键)。 +> 好在写完立刻用 `yaml.safe_load` 复查了一遍(`services: [pg, redis]`)才发现。 +> **教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。** + +### ② 学生端材料预览(新功能) +已传材料每一行现在有「预览」,点开是全屏浮层(深底 —— 材料照片多是白底证件件, +浅底会看不清边界),点图片以外的地方关闭。 + +**两种存储两种取法,不能只写一种**: +- **七牛**:接口返回**带签名的绝对 URL**,直接给 `` 用; +- **本地磁盘**:那个接口要带 `Authorization`,而 `` **发不了请求头** —— + 所以先用 `uni.request` 把字节取回来,再转成 blob URL。 + +> 只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。 +> 另外:签名链接有有效期(30 分钟),所以**每次预览都现取**,不要缓存 URL —— +> 缓存的那份过期后是 403,而学生只会觉得"打不开"。 + +**顺手修的两个后端问题**: +1. `LocalStorageService.url()` 返回的路径指向一个**不存在的接口** + (我当时建的是 `/{id}/content`,不是 `?path=`)—— + 现在由 `MaterialFileService` 按 provider 决定:七牛给签名链接、本地给 `/{id}/content` + (按 id 取能复用"这份文件属于你"的归属校验) +2. `/content` 原来固定返回 `application/octet-stream` —— + 表现是**点开身份证照片变成下载一个文件**。现在按文件名推 Content-Type + `inline` + +### ③ 浏览器实测 +- 点「预览」→ 全屏浮层打开、图片**真的解码出来**(`naturalWidth=900`、`complete=true`) +- 直接 fetch 那个签名 URL:**200 / 17728 字节** +- 中间发现一个视觉问题:**浮层的标题与「关闭」被 uni-app 的导航栏盖住了** + (uni-app 的 page-head `z-index` 是 **998**,我给的 95)—— + 看起来像"标题没渲染出来"。改成 9999 + `padding-top` 让出导航栏高度; + 问题清单浮层的 z-index 一并提到 9998 +- 修完复验:`headVisible: true`、标题与「关闭」都在顶层可见、图片铺满宽度 + +### ④ 顺带 +`dev-seed-warn-state.py` 改为**没有可用草稿时自己建一份** —— +原来写死 submission id,被 E2E 清理后就报"填报单不存在或不属于你", +**而那看起来像权限问题,不像"数据被清了"**。 + +### 验收 +`e2e-m3-file 32/32`、`m3-submit 31/31`、`m1-storage 16/16` + +### 下一步 +1. **直传** `POST /mp/files/upload-token`(有了七牛就能按契约实现; + 省的是学生那一段上行 —— 几十 MB 的翻拍件不占用我们的带宽与连接) +2. `M1-05` 规则模板 +3. 学生端提交前的**图片自检提示**(现在只有校验清单,预览已经能用,可以串起来) +4. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、 + C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 diff --git a/frontend-mp/src/pages/fill/fill.vue b/frontend-mp/src/pages/fill/fill.vue index 833324f..f9f51f8 100644 --- a/frontend-mp/src/pages/fill/fill.vue +++ b/frontend-mp/src/pages/fill/fill.vue @@ -88,7 +88,12 @@ {{ f.standardName || f.originalName }} - 删除 + + + 预览 + 删除 + @@ -121,6 +126,16 @@ 用页面内浮层而不是 uni.showModal:showModal 只能放一段纯文本, 而问题清单要分级上色、每条要带规则编号 —— 这些正是学生能看懂"哪里不对"的关键。 全屏纯文本会让人只看到"提交失败",然后放弃 --> + + + + {{ previewName }} + 关闭 + + + 点图片以外的地方关闭 + + @@ -551,6 +566,87 @@ function removeFile(f: UploadedFile) { }) } +// ── 材料预览 ───────────────────────────────── + +const previewSrc = ref(null) +const previewName = ref('') +const previewing = ref(false) + +/** 扩展名 → MIME。做 blob 时要用,猜错的表现是"图裂了"。 */ +function mimeOf(name: string): string { + const ext = (name.split('.').pop() || '').toLowerCase() + if (ext === 'png') return 'image/png' + if (ext === 'pdf') return 'application/pdf' + if (ext === 'webp') return 'image/webp' + return 'image/jpeg' +} + +/** + * 预览一份材料。 + * + *

两种存储两种取法,**不能只写一种**: + *

+ * + *

⚠️ 签名链接有有效期(默认 30 分钟),所以**每次预览都现取**, + * 不要把 URL 缓存起来 —— 缓存那份过期后是 403,而学生只会觉得"打不开"。 + */ +async function preview(f: UploadedFile) { + if (previewing.value) return + previewing.value = true + uni.showLoading({ title: '加载中…' }) + try { + const r = await request<{ url: string, name: string }>({ + url: `/api/v1/mp/files/${f.id}/url`, + token: token() + }) + previewName.value = r.name || f.standardName || f.originalName + if (/^https?:\/\//i.test(r.url)) { + previewSrc.value = r.url + } else { + const buf = await new Promise((resolve, reject) => { + uni.request({ + url: API_BASE + r.url, + header: token() ? { Authorization: `Bearer ${token()}` } : {}, + responseType: 'arraybuffer', + success: (res) => { + if (res.statusCode === 200) resolve(res.data as ArrayBuffer) + else reject(new Error(`打不开(HTTP ${res.statusCode})`)) + }, + fail: (e) => reject(new Error(e.errMsg || '打不开')) + }) + }) + revokePreview() + previewSrc.value = URL.createObjectURL(new Blob([buf], { type: mimeOf(previewName.value) })) + } + } catch (e) { + uni.showModal({ + title: '打不开这份材料', + content: e instanceof Error ? e.message : '请稍后重试', + showCancel: false + }) + } finally { + previewing.value = false + uni.hideLoading() + } +} + +/** blob URL 是浏览器持有的内存,不释放就会一直占着(学生可能连看十几张)。 */ +function revokePreview() { + if (previewSrc.value && previewSrc.value.startsWith('blob:')) { + URL.revokeObjectURL(previewSrc.value) + } +} + +function closePreview() { + revokePreview() + previewSrc.value = null +} + /** * 提交按钮的入口。 * @@ -770,9 +866,33 @@ page { background-color: $page-bg; } background: #f0f0f0; border-radius: 12rpx; } + &__file-ops { display: flex; flex-shrink: 0; align-items: baseline; gap: 20rpx; } + &__file-view { font-size: 24rpx; color: $primary; } + + // ── 材料预览(全屏,深底 —— 材料照片常是白底的证件件,浅底会看不清边界)── + // + // ⚠️ z-index 必须**高过 uni-app 的页面导航栏**(它是 998),否则浮层的标题与 + // 「关闭」会被导航栏盖住 —— 而那看起来像"标题没渲染出来"。 + // 同时用 padding-top 给导航栏让出位置(44px ≈ 88rpx),再叠加刘海屏安全区。 + &__viewer { + position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9999; + display: flex; flex-direction: column; overflow-y: auto; background: rgba(0, 0, 0, .92); + padding-top: calc(88rpx + constant(safe-area-inset-top)); + padding-top: calc(88rpx + env(safe-area-inset-top)); + } + &__viewer-head { + display: flex; align-items: center; justify-content: space-between; + padding: 32rpx 28rpx 16rpx; + } + &__viewer-name { flex: 1; font-size: 28rpx; color: #fff; word-break: break-all; } + &__viewer-close { flex-shrink: 0; margin-left: 24rpx; font-size: 28rpx; color: #fff; } + &__viewer-img { width: 100%; } + &__viewer-hint { padding: 24rpx; font-size: 24rpx; text-align: center; color: rgba(255, 255, 255, .6); } + // ── 校验问题清单(红黄绿)── &__mask { - position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 90; + /* 同样要盖过 uni-app 的导航栏(998),否则清单高一点时顶部会被压住 */ + position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9998; display: flex; align-items: flex-end; background: rgba(0, 0, 0, .45); } &__sheet { diff --git a/scripts/dev-seed-warn-state.py b/scripts/dev-seed-warn-state.py index 7660b8c..1125f24 100644 --- a/scripts/dev-seed-warn-state.py +++ b/scripts/dev-seed-warn-state.py @@ -28,7 +28,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources") UID = 151 TASK = 17 -SID = int(sys.argv[1]) if len(sys.argv) > 1 else 21 +WANT_SID = int(sys.argv[1]) if len(sys.argv) > 1 else None def read_cfg() -> dict: @@ -97,9 +97,31 @@ def upload(token, sid, code, filename, data): return {"_http": e.code} +def ensure_draft(token: str, task_id: int, want: int | None) -> int: + """ + 拿一份可用的草稿 id。 + +

不写死某个 id:草稿会被 E2E 清理掉,写死的脚本下次跑就报 + "填报单不存在或不属于你" —— 而那看起来像权限问题,不像"数据被清了"。 + 没给 id 或给的那个不可用,就现建一份(服务端是幂等的)。 + """ + if want: + r = call("GET", f"/api/v1/mp/files/submissions/{want}", token) + if r.get("code") == 0: + return want + print(f" 给定的草稿 {want} 不可用({r.get('msg')}),改为现建一份") + r = call("POST", "/api/v1/mp/submissions", token, {"taskId": task_id}) + if r.get("code") != 0: + raise SystemExit(f" 建草稿失败:{r}") + sid = r["data"]["id"] + print(f" 已建草稿 submission={sid}") + return sid + + def main() -> int: cfg = read_cfg() tok = mint(cfg["jwt"], cfg["issuer"], UID, 1) + SID = ensure_draft(tok, TASK, WANT_SID) # ① 填掉全部必填(key 是 snake_case;is_retake=false 让补考三科不可见 → 不必填) form = {"work_unit": "无", "is_retake": False, "is_student": True,