From 65ebf8de906c91b6e35e44586c788a7037f1c172 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 14:09:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(storage):=20=E5=AF=B9=E8=B1=A1=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=8E=A5=E4=B8=83=E7=89=9B=E4=BA=91=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=A7=A3=E9=99=A4"=E4=B8=8D=E8=83=BD=E4=B8=8A?= =?UTF-8?q?=E5=A4=9A=E5=89=AF=E6=9C=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件以前直接写本地磁盘:两个实例各存各的,学生这次传到 A、下次请求落到 B, 文件就"不见了"。这不是性能问题,是正确性问题 —— 它一直卡着不上多副本。 新增: · module/file/storage/{StorageService,LocalStorageService,QiniuStorageService} · config/StorageConfig(装配 + 可选启动自检) · FillProperties 新增 fill.storage · MaterialFileController 新增 GET /{id}/url(签名链接)与 GET /{id}/content · scripts/e2e-m1-storage.py(存储自检,没配七牛时跳过而非失败) 关键设计: ① 业务只认 path(std/1/17/张同学-…-身份证.jpg),各实现自己映射成真实位置: 本地 ./data/uploads/std/...、七牛 xiaotutongxue/std/... **换存储不用改数据库、不用改业务代码** ② 空间私有 → 下载必须签名。GET /{id}/url 每次现签(默认 30 分钟), 所以这个 URL 绝不能长期保存:写进库、塞进导出的 Excel、发给学生明天再打开,都是 403 ③ 对象键里有中文,拼 URL **必须逐段编码** —— 整串 encode 会把 / 编成 %2F, 那在七牛那边是另一个键,表现是"传上去了却打不开",且报错什么都没有 ④ 区域默认 auto:华东-浙江在新旧控制台对应 z0 与 cn-east-2, 写错了的症状是**上传超时**——一个从报错完全看不出原因的失败 ⑤ AK/SK 不进仓库也不进 jar:dev.yml 只放占位符; 真密钥在 backend/application-local.yml(已 gitignore), 用 spring.config.import: optional:**file**:./application-local.yml —— 刻意用 file: 而不是 classpath:,后者会被打进 jar,密钥就跟着构建产物走了 ⑥ 配了 qiniu 却没密钥 → 启动就炸(fail-fast):起得来的话第一个受害的是 正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量 已验证:存储抽象改造没破坏任何现有链路 —— 本地磁盘路径下 e2e-m3-file 29/29、m3-submit 31/31、m1-materials 68/68、m3-draft 27/27 全绿; 启动日志打出「本地磁盘(多副本部署下会丢文件)」;git check-ignore 确认 application-local.yml 被忽略而 .example 不被忽略。 ⏳ 未验证:**七牛的真实链路没跑过** —— 用户给了域名/空间/区域/私有/目录前缀, 但没给 AccessKey/SecretKey,没有它生成不了上传凭证。 补上后跑 python scripts/e2e-m1-storage.py 即可(它会验:配置齐全 → 上传处理落进七牛 → 取签名链接 → 真的下载回来比字节 → 不带签名被拒 → 对象键带前缀 → 原始件一字不差)。没配时脚本跳过并 exit 0,不误报失败。 --- .gitignore | 3 + backend/application-local.yml.example | 37 ++ backend/pom.xml | 14 + backend/xt-server/pom.xml | 6 + .../fill/config/FillProperties.java | 53 +++ .../fill/config/StorageConfig.java | 89 +++++ .../controller/MaterialFileController.java | 25 ++ .../file/service/MaterialFileService.java | 105 ++++-- .../file/storage/LocalStorageService.java | 86 +++++ .../file/storage/QiniuStorageService.java | 253 ++++++++++++++ .../module/file/storage/StorageService.java | 63 ++++ .../src/main/resources/application-dev.yml | 33 ++ .../src/main/resources/application.yml | 8 + docs/交接摘要.md | 88 +++++ scripts/e2e-m1-storage.py | 324 ++++++++++++++++++ 15 files changed, 1164 insertions(+), 23 deletions(-) create mode 100644 backend/application-local.yml.example create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java create mode 100644 scripts/e2e-m1-storage.py diff --git a/.gitignore b/.gitignore index c6c3b21..3f1e945 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ desktop.ini # 运行时上传的文件(材料原图/规范件)—— 是数据不是代码,绝不入库 backend/data/ + +# 本地私有配置(云存储密钥等)—— 只提交 .example 模板 +application-local.yml diff --git a/backend/application-local.yml.example b/backend/application-local.yml.example new file mode 100644 index 0000000..81583dc --- /dev/null +++ b/backend/application-local.yml.example @@ -0,0 +1,37 @@ +# ═══════════════════════════════════════════════════════════ +# 本地私有配置 —— 模板(这个文件**会**提交,所以只能有占位符) +# ═══════════════════════════════════════════════════════════ +# +# 用法: +# 1. 复制成同目录的 application-local.yml(**已在 .gitignore 里,不会被提交**) +# 2. 填上真实的 AccessKey / SecretKey,并把 provider 改成 qiniu +# 3. 重启后端 +# +# 为什么要有这么一个文件,而不是直接写进 application-dev.yml: +# application-dev.yml 是提交进仓库的。云存储的 AK/SK 是**有计费与数据权限的真凭据**, +# 与 jwt/crypto 那几把自己生成的密钥不是一回事 —— 后者泄露只影响本系统, +# 前者泄露等于别人能读写整个空间。 +# +# 也可以完全不用这个文件,改用环境变量(生产推荐): +# XT_STORAGE_PROVIDER=qiniu +# QINIU_ACCESS_KEY=... / QINIU_SECRET_KEY=... +# QINIU_BUCKET=tools-matrix / QINIU_DOMAIN=https://file.tools.coding.design +# +# 填完想确认配对了,打开启动自检: +# XT_STORAGE_SELF_CHECK=true +# 它会在启动时真的走一遍 写入 → 存在 → 读回 → 删除, +# 把"密钥错 / 空间名错 / 没写权限"当场暴露出来 —— 而不是等第一个学生上传时才失败。 + +fill: + storage: + provider: qiniu + prefix: xiaotutongxue + qiniu: + access-key: 在这里填 AccessKey + secret-key: 在这里填 SecretKey + bucket: tools-matrix + domain: https://file.tools.coding.design + # auto = 让 SDK 自己查区域(推荐)。华东-浙江在新旧控制台对应 z0 与 cn-east-2, + # 写死了换控制台就错,而错的症状是"上传超时",很难从报错看出原因。 + region: auto + signed-url-expire-seconds: 1800 diff --git a/backend/pom.xml b/backend/pom.xml index 463cb12..809aa76 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -36,6 +36,8 @@ 4.0.3 3.1.1 + + 7.16.0 @@ -67,6 +69,18 @@ ${mybatis-plus.version} + + + com.qiniu + qiniu-java-sdk + ${qiniu.version} + + io.jsonwebtoken diff --git a/backend/xt-server/pom.xml b/backend/xt-server/pom.xml index 67fceb0..9037a40 100644 --- a/backend/xt-server/pom.xml +++ b/backend/xt-server/pom.xml @@ -89,6 +89,12 @@ easyexcel + + + com.qiniu + qiniu-java-sdk + + org.springdoc diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java index b7442dc..d76010d 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java @@ -19,6 +19,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @param rsa 登录密码传输解密 * @param security 登录安全策略 * @param upload 上传限制 + * @param storage 材料文件的存储后端(本地磁盘 / 七牛云) * @param miniProgram 微信小程序(填报二维码) * @param mpH5 H5 填报端(网页授权) */ @@ -29,10 +30,62 @@ public record FillProperties( Rsa rsa, Security security, Upload upload, + Storage storage, MiniProgram miniProgram, MpH5 mpH5 ) { + /** + * 材料文件存哪里。 + * + * @param provider {@code local}(本地磁盘,仅开发)或 {@code qiniu} + * @param prefix bucket 内的目录前缀(与同空间其他项目隔离) + * @param qiniu 七牛配置(provider=qiniu 时必填) + * @param selfCheckOnStartup 启动时做一次真实的写入-读取-删除自检。 + * **默认关**:生产启动不该依赖外部网络; + * 但换存储、换密钥之后值得打开跑一次 + */ + public record Storage( + String provider, + String prefix, + Qiniu qiniu, + Boolean selfCheckOnStartup + ) { + public String providerOrDefault() { + // 默认 local:这样"什么都不配"也能把项目跑起来, + // 而不是因为缺一个云存储密钥连本地开发都做不了 + return provider == null || provider.isBlank() ? "local" : provider.trim().toLowerCase(); + } + + public boolean isQiniu() { + return "qiniu".equals(providerOrDefault()); + } + + public boolean selfCheckEnabled() { + return Boolean.TRUE.equals(selfCheckOnStartup); + } + } + + /** + * 七牛云(空间私有 → 下载要签名)。 + * + * @param accessKey AK。**只从环境变量或 application-local.yml 取**,不要提交到仓库 + * @param secretKey SK,同上 + * @param bucket 空间名,如 {@code tools-matrix} + * @param domain 绑定的 CDN 域名,如 {@code https://file.tools.coding.design} + * @param region {@code auto}(默认,推荐)/ {@code huadong} / {@code huadong2} / … + * @param signedUrlExpireSeconds 签名链接有效期(秒),默认 3600 + */ + public record Qiniu( + String accessKey, + String secretKey, + String bucket, + String domain, + String region, + Long signedUrlExpireSeconds + ) { + } + /** * @param issuer 签发者,进 JWT 的 {@code iss} * @param secret HMAC 密钥(Base64),用于 HS256 签名 diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java new file mode 100644 index 0000000..9ab5ff1 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java @@ -0,0 +1,89 @@ +package cn.tianbaotong.fill.config; + +import cn.tianbaotong.fill.module.file.storage.LocalStorageService; +import cn.tianbaotong.fill.module.file.storage.QiniuStorageService; +import cn.tianbaotong.fill.module.file.storage.StorageService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * 材料存储的装配(本地磁盘 / 七牛云)。 + * + *

为什么默认是 local

+ * 本地开发和跑 E2E 不该依赖云存储的密钥与网络 —— 一旦依赖," + * 跑一遍测试"就变成一个需要凭据的动作,而需要凭据的动作不会天天跑。 + * + *

但 local 不能上生产多副本

+ * 两个实例各存各的盘,学生这次传到 A、下次请求落到 B,文件就"不见了"。 + * 所以这不是"性能差一点",是**正确性错误**。日志里会明说。 + * + *

启动自检(默认关)

+ * {@code fill.storage.self-check-on-startup=true} 时做一次真实的 + * 写入 → 存在 → 读回 → 删除。**换存储、换密钥、换空间之后值得开一次** —— + * 它能在服务启动时就把"密钥错""空间名错""没有写权限"这类问题暴露出来, + * 而不是等第一个学生上传材料时才失败(那时排查要从"文件保存失败" + * 一路追到环境变量,而学生在等着)。 + * + *

默认关是因为:生产启动不该依赖外部网络。一个云存储的瞬时抖动 + * 不应该让整个服务起不来。 + */ +@Slf4j +@Configuration +public class StorageConfig { + + @Bean + public StorageService storageService(FillProperties props) { + FillProperties.Storage s = props.storage(); + if (s == null || !s.isQiniu()) { + String dir = props.upload() == null ? null : props.upload().dirOrDefault(); + return new LocalStorageService(dir); + } + + FillProperties.Qiniu q = s.qiniu(); + if (q == null) { + throw new IllegalStateException( + "provider=qiniu 但缺少 fill.storage.qiniu 配置(bucket / domain)"); + } + QiniuStorageService svc = new QiniuStorageService( + q.accessKey(), q.secretKey(), q.bucket(), q.domain(), + s.prefix(), q.region(), q.signedUrlExpireSeconds()); + + if (s.selfCheckEnabled()) { + selfCheck(svc); + } + return svc; + } + + /** + * 真实往返一次。 + * + *

刻意走**和业务完全一样的路径**(同一个 {@code put}/{@code getBytes}/{@code delete}), + * 而不是单独调一次 SDK —— 自检要是和业务走两条路,它就只能证明"网络是通的"。 + */ + private void selfCheck(QiniuStorageService svc) { + String path = "_selfcheck/" + System.currentTimeMillis() + ".txt"; + byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8); + try { + svc.put(path, payload, "text/plain; charset=utf-8"); + if (!svc.exists(path)) { + throw new IllegalStateException("自检失败:刚写进去的对象 stat 不到"); + } + byte[] back = svc.getBytes(path); + if (!Arrays.equals(payload, back)) { + throw new IllegalStateException("自检失败:读回的内容与写入不一致" + + "(写入 " + payload.length + " 字节,读回 " + back.length + " 字节)"); + } + svc.delete(path); + log.info("存储自检通过:写入 → 存在 → 读回 → 删除 全部正常(provider={})", svc.provider()); + } catch (RuntimeException e) { + // 自检失败就是配置错,**必须让服务起不来** —— + // 起得来的话,第一个受害的是正在填材料的学生 + log.error("存储自检失败(provider={})", svc.provider(), e); + throw e; + } + } +} 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 6b1c946..88a7b4e 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.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -103,4 +104,28 @@ public class MaterialFileController { out.put("validateStatus", f.getValidateStatus()); return out; } + + @GetMapping("/{id}/url") + @Operation(summary = "取材料的访问链接", + description = "七牛空间私有 → 返回**带有效期的签名链接**(默认 30 分钟)。" + + "⚠️ 不要长期保存这个 URL:过期后是 403,每次要用现取。" + + "original=true 取原始件(学生传的那张),false 取压缩改名后的规范件") + public ApiResult url( + @PathVariable Long id, + @RequestParam(defaultValue = "false") boolean original, + @RequestParam(required = false) Long expireSeconds) { + return ApiResult.ok(fileService.url(id, original, expireSeconds)); + } + + @GetMapping("/{id}/content") + @Operation(summary = "取材料内容(本地存储的预览用)", + description = "七牛那边走签名链接即可,这个接口主要给 provider=local 的本地开发用;" + + "两种实现都要求**登录且文件属于你**") + public ResponseEntity content(@PathVariable Long id, + @RequestParam(defaultValue = "false") boolean original) { + byte[] bytes = fileService.content(id, original); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(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 3b50dea..f6c0731 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 @@ -7,6 +7,7 @@ import cn.tianbaotong.fill.common.exception.BizException; import cn.tianbaotong.fill.config.FillProperties; import cn.tianbaotong.fill.module.file.entity.CollectFile; import cn.tianbaotong.fill.module.file.mapper.FileMapper; +import cn.tianbaotong.fill.module.file.storage.StorageService; import cn.tianbaotong.fill.module.fill.entity.CollectSubmission; import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper; import cn.tianbaotong.fill.module.material.dto.MaterialDefVO; @@ -21,9 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -62,6 +61,7 @@ public class MaterialFileService { private final TaskMapper taskMapper; private final FileProcessService processService; private final FillProperties props; + private final StorageService storage; // ── 上传 ────────────────────────────────────── @@ -109,7 +109,7 @@ public class MaterialFileService { String rel = String.format("raw/%d/%d/%d_%s.%s", me.tenantId(), sub.getTaskId(), System.currentTimeMillis(), Integer.toHexString(java.util.Arrays.hashCode(bytes)), ext); - writeFile(rel, bytes); + writeFile(rel, bytes, contentTypeOf(ext)); CollectFile f = new CollectFile(); f.setTaskId(sub.getTaskId()); @@ -169,7 +169,7 @@ public class MaterialFileService { String standardName = processService.renderName(template, vars, outExt); String rel = String.format("std/%d/%d/%s", me.tenantId(), f.getTaskId(), standardName); - writeFile(rel, p.bytes()); + writeFile(rel, p.bytes(), contentTypeOf(p.ext() == null ? f.getFileExt() : p.ext())); fileMapper.update(null, Wrappers.lambdaUpdate() .eq(CollectFile::getId, fileId) @@ -228,6 +228,56 @@ public class MaterialFileService { log.info("删除材料:fileId={} {}", fileId, f.getOriginalName()); } + // ── URL 与内容 ──────────────────────────────── + + /** 一份材料的可访问链接。 */ + public record FileUrl(String url, long expiresIn, String provider, String name) { + } + + /** + * 取一份材料的访问链接。 + * + *

⚠️ 七牛空间是**私有**的,所以这里返回的是**带有效期的签名链接**: + *

+ * + * @param original true 取原始件(学生传的那张)、false 取规范件(压缩改名后的) + */ + public FileUrl url(Long fileId, boolean original, Long expireSeconds) { + 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(); + if (path == null || path.isBlank()) { + // 规范件还没生成(上传了但没处理)时退回原始件,而不是报错 —— + // 学生看到的是"我的材料",不该因为内部有两份而困惑 + path = f.getRawPath(); + } + if (path == null || path.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件还没落盘,请重新上传"); + } + long exp = expireSeconds == null || expireSeconds <= 0 ? 1800 : expireSeconds; + return new FileUrl(storage.url(path, exp), exp, storage.provider(), + original ? f.getOriginalName() : (f.getStandardName() == null + ? f.getOriginalName() : f.getStandardName())); + } + + /** 取内容字节(供本地存储的预览接口用;七牛那边走签名链接)。 */ + public byte[] 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); + } + // ── 内部 ────────────────────────────────────── /** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */ @@ -265,30 +315,39 @@ public class MaterialFileService { .findFirst().orElse(null); } - private String dir() { - return props.upload() == null ? "./data/uploads" : props.upload().dirOrDefault(); - } - - private void writeFile(String rel, byte[] bytes) { - try { - Path p = Paths.get(dir()).resolve(rel); - Files.createDirectories(p.getParent()); - Files.write(p, bytes); - } catch (Exception e) { - log.error("写文件失败:{}", rel, e); - throw new BizException(ErrorCode.INTERNAL_ERROR, "文件保存失败:" + e.getMessage()); - } + /** + * 写文件。 + * + *

走 {@link StorageService} 而不是直接写盘:换了存储(本地 → 七牛云) + * 这里的业务代码一行都不用改,而 {@code path} 也不变 —— + * 它是**业务路径**,存在 {@code collect_file} 里,换存储不用改数据库。 + */ + private void writeFile(String rel, byte[] bytes, String contentType) { + storage.put(rel, bytes, contentType); } private byte[] readFile(String rel) { if (rel == null || rel.isBlank()) { throw new BizException(ErrorCode.VALIDATION_BLOCKED, "原始文件不存在,请重新上传"); } - try { - return Files.readAllBytes(Paths.get(dir()).resolve(rel)); - } catch (Exception e) { - throw new BizException(ErrorCode.VALIDATION_BLOCKED, "原始文件读取失败,请重新上传"); - } + return storage.getBytes(rel); + } + + /** + * 扩展名 → MIME。 + * + *

对象存储会把它写进 {@code Content-Type},浏览器与微信据此决定"是展示还是下载"。 + * 猜错的表现很别扭:身份证照片点开变成**下载一个文件**,而不是直接看到图。 + */ + private static String contentTypeOf(String ext) { + return switch (ext == null ? "" : ext.trim().toLowerCase()) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "webp" -> "image/webp"; + case "heic" -> "image/heic"; + case "pdf" -> "application/pdf"; + default -> "application/octet-stream"; + }; } private static String extOf(String name) { 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 new file mode 100644 index 0000000..9867dc7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/LocalStorageService.java @@ -0,0 +1,86 @@ +package cn.tianbaotong.fill.module.file.storage; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import lombok.extern.slf4j.Slf4j; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 本地磁盘存储。只用于本地开发与自动化测试,不能上生产。 + * + *

⚠️ 它有一个多副本环境下必然出错的缺陷:两个实例各存各的盘。 + * 学生这次传到 A、下次请求落到 B,文件就"不见了"。所以 + * {@code provider=local} 时部署上多副本是**错误配置**,不是"将就用"。 + * + *

它仍然值得保留:本地开发和 E2E 不该依赖云存储的密钥与网络 —— + * 那些依赖会让"跑一遍测试"变成一个需要凭据的动作。 + */ +@Slf4j +public class LocalStorageService implements StorageService { + + private final String dir; + + public LocalStorageService(String dir) { + this.dir = dir == null || dir.isBlank() ? "./data/uploads" : dir; + log.info("材料存储:本地磁盘({})—— 多副本部署下会丢文件,生产请用 provider=qiniu", this.dir); + } + + @Override + public String put(String path, byte[] bytes, String contentType) { + try { + Path p = resolve(path); + Files.createDirectories(p.getParent()); + Files.write(p, bytes); + return path; + } catch (Exception e) { + log.error("本地写入失败:{}", path, e); + throw new BizException(ErrorCode.INTERNAL_ERROR, "文件保存失败:" + e.getMessage()); + } + } + + @Override + public byte[] getBytes(String path) { + try { + return Files.readAllBytes(resolve(path)); + } catch (Exception e) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件读取失败,请重新上传"); + } + } + + /** + * 本地没有"公网地址"这回事,所以返回**由本服务流式提供的地址** + * (见 {@code MaterialFileController#content})—— 与七牛的签名链接 + * 在使用上是同一个形状:一个拿得到就能下载的 URL。 + */ + @Override + public String url(String path, long expireSeconds) { + return "/api/v1/mp/files/content?path=" + java.net.URLEncoder.encode( + path, java.nio.charset.StandardCharsets.UTF_8); + } + + @Override + public boolean exists(String path) { + return Files.exists(resolve(path)); + } + + @Override + public void delete(String path) { + try { + Files.deleteIfExists(resolve(path)); + } catch (Exception e) { + log.warn("本地删除失败:{}", path, e); + } + } + + @Override + public String provider() { + return "local"; + } + + private Path resolve(String path) { + return Paths.get(dir).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 new file mode 100644 index 0000000..573d41e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/QiniuStorageService.java @@ -0,0 +1,253 @@ +package cn.tianbaotong.fill.module.file.storage; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import com.qiniu.common.QiniuException; +import com.qiniu.http.Response; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.Configuration; +import com.qiniu.storage.Region; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import lombok.extern.slf4j.Slf4j; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * 七牛云对象存储。 + * + *

三处与"看起来应该怎么写"不一样的地方

+ * + *

① 空间是私有的,所以下载必须走签名。 + * 直接把 {@code domain/key} 发出去会 403。{@link #url} 每次现签一个带有效期的链接 —— + * 也正因如此,**这个 URL 绝不能被长期保存**(写进数据库、塞进导出的 Excel、 + * 或者发给学生让他明天再打开,都会变成 403)。 + * + *

② 对象键里会出现中文。 + * 规范文件名是 {@code 张同学-…-身份证.jpg}(产品要求"文件名能唯一归属到人")。 + * 七牛允许 UTF-8 键,但**拼 URL 时必须逐段 URL 编码** —— + * 整串编码会把 {@code /} 也编成 {@code %2F},那在七牛那边是**另一个键**, + * 表现是"文件明明传上去了却打不开"。 + * + *

③ 区域不写死。 + * 空间在华东-浙江,但"华东-浙江"在新旧控制台对应不同的 region id + * ({@code z0} 与 {@code cn-east-2})。写死一个、换了控制台就错, + * 而错的症状是上传超时。默认用 {@code auto} 让 SDK 自己查。 + */ +@Slf4j +public class QiniuStorageService implements StorageService { + + /** bucket 内的目录前缀 —— 与同空间的其他项目隔离。 */ + private final String prefix; + private final String bucket; + /** 绑定的 CDN 域名,形如 {@code https://file.tools.coding.design}(无尾斜杠)。 */ + private final String domain; + private final long defaultExpireSeconds; + + private final Auth auth; + private final UploadManager uploadManager; + private final BucketManager bucketManager; + private final HttpClient http; + + public QiniuStorageService(String accessKey, String secretKey, String bucket, String domain, + String prefix, String region, Long defaultExpireSeconds) { + if (isBlank(accessKey) || isBlank(secretKey)) { + // 配了 qiniu 却没给密钥,属于**部署配置错误**。 + // 在这里炸掉比等学生上传时才炸好得多 —— 后者的表现是"学生填不了材料", + // 而排查要从"文件保存失败"一路追到环境变量 + throw new IllegalStateException( + "provider=qiniu 但没有配置 access-key/secret-key。" + + "请设置环境变量 QINIU_ACCESS_KEY / QINIU_SECRET_KEY," + + "或写进 application-local.yml(已在 .gitignore 里)"); + } + if (isBlank(bucket) || isBlank(domain)) { + throw new IllegalStateException("provider=qiniu 但没有配置 bucket 或 domain"); + } + this.bucket = bucket.trim(); + this.domain = trimTrailingSlash(domain.trim()); + this.prefix = stripSlashes(prefix == null ? "" : prefix); + this.defaultExpireSeconds = defaultExpireSeconds == null || defaultExpireSeconds <= 0 + ? 3600 : defaultExpireSeconds; + + this.auth = Auth.create(accessKey.trim(), secretKey.trim()); + this.uploadManager = new UploadManager(new Configuration(resolveRegion(region))); + this.bucketManager = new BucketManager(this.auth, new Configuration(resolveRegion(region))); + this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + log.info("材料存储:七牛云(bucket={} domain={} prefix={} 区域={})", + this.bucket, this.domain, this.prefix.isEmpty() ? "(无)" : this.prefix, region); + } + + // ── 基本操作 ──────────────────────────────────── + + @Override + public String put(String path, byte[] bytes, String contentType) { + String key = key(path); + try { + Response r = uploadManager.put(bytes, key, auth.uploadToken(bucket, key)); + if (!r.isOK()) { + throw new BizException(ErrorCode.INTERNAL_ERROR, + "对象存储写入失败(HTTP " + r.statusCode + ")"); + } + return key; + } catch (QiniuException e) { + log.error("七牛上传失败:key={} code={} body={}", key, e.code(), + e.response == null ? "" : safeBody(e.response), e); + throw new BizException(ErrorCode.INTERNAL_ERROR, + "文件保存到对象存储失败:" + e.getMessage()); + } + } + + @Override + public byte[] getBytes(String path) { + // 私有空间:先签一个临时链接再取。这里**不走** BucketManager 的私有下载接口, + // 因为它返回的也是 URL —— 反正都要签名,一次签名交给 url() 一处维护更好 + String signed = url(path, 300); + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(signed)) + .timeout(Duration.ofSeconds(60)).GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); + if (resp.statusCode() != 200) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "文件读取失败(HTTP " + resp.statusCode() + "),请重新上传"); + } + return resp.body(); + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.error("七牛读取失败:path={}", path, e); + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件读取失败,请重新上传"); + } + } + + /** + * 签名一个下载链接。 + * + *

⚠️ {@code deadline} 是**绝对时间**(epoch 秒),不是"多久之后过期" —— + * 传成相对秒数会得到一个 1970 年的链接,表现是"刚生成就过期"。 + */ + @Override + public String url(String path, long expireSeconds) { + long seconds = expireSeconds > 0 ? expireSeconds : defaultExpireSeconds; + long deadline = System.currentTimeMillis() / 1000 + seconds; + return auth.privateDownloadUrl(publicUrl(path), deadline); + } + + /** + * 不带签名的公网地址(私有空间直接访问会 403,仅供日志排查与签名内部使用)。 + */ + public String publicUrl(String path) { + return domain + "/" + encodeKey(key(path)); + } + + @Override + public boolean exists(String path) { + try { + return bucketManager.stat(bucket, key(path)) != null; + } catch (QiniuException e) { + // 612 = 文件不存在,这是"正常的否",不是错误;其他码才算异常 + if (e.code() == 612) { + return false; + } + log.warn("七牛 stat 失败:key={} code={}", key(path), e.code()); + return false; + } + } + + @Override + public void delete(String path) { + try { + bucketManager.delete(bucket, key(path)); + } catch (QiniuException e) { + log.warn("七牛删除失败:key={} code={}", key(path), e.code()); + } + } + + @Override + public String provider() { + return "qiniu"; + } + + // ── 内部 ──────────────────────────────────────── + + /** 业务路径 → 对象键(加目录前缀)。 */ + private String key(String path) { + String p = stripSlashes(path); + return prefix.isEmpty() ? p : prefix + "/" + p; + } + + /** + * 逐段编码。 + * + *

整串 {@code URLEncoder.encode} 会把 {@code /} 编成 {@code %2F}, + * 而七牛把它当成键的一部分 —— 于是签名对了、文件也在,就是 404。 + */ + private static String encodeKey(String key) { + return Arrays.stream(key.split("/", -1)) + .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20")) + .collect(Collectors.joining("/")); + } + + /** + * 区域映射。 + * + *

默认 {@code auto}:让 SDK 自己去查空间在哪个区。写死一个区看着更"确定", + * 但"华东-浙江"在新旧控制台对应 {@code z0} / {@code cn-east-2} 两个 id, + * 写错了的症状是**上传超时**——一个从报错完全看不出原因的失败。 + */ + private static Region resolveRegion(String name) { + if (name == null || name.isBlank()) { + return Region.autoRegion(); + } + return switch (name.trim().toLowerCase()) { + case "auto" -> Region.autoRegion(); + // 华东 + case "huadong", "z0", "cn-east-1", "east" -> Region.huadong(); + // 华东-浙江 2 + case "huadong2", "cn-east-2", "zhejiang2" -> Region.huadongZheJiang2(); + case "huabei", "z1", "north" -> Region.huabei(); + case "huanan", "z2", "south" -> Region.huanan(); + case "beimei", "na0" -> Region.beimei(); + case "xinjiapo", "as0" -> Region.xinjiapo(); + default -> { + log.warn("未知的七牛区域「{}」,回退到 auto(让 SDK 自己查)", name); + yield Region.autoRegion(); + } + }; + } + + private static String safeBody(Response r) { + try { + return r.bodyString(); + } catch (Exception e) { + return ""; + } + } + + private static String trimTrailingSlash(String s) { + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } + + private static String stripSlashes(String s) { + String r = s.trim(); + while (r.startsWith("/")) { + r = r.substring(1); + } + while (r.endsWith("/")) { + r = r.substring(0, r.length() - 1); + } + return r; + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} 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 new file mode 100644 index 0000000..53a485e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/storage/StorageService.java @@ -0,0 +1,63 @@ +package cn.tianbaotong.fill.module.file.storage; + +/** + * 材料文件的存储后端。 + * + *

为什么要有这层抽象

+ * 文件以前是直接写本地磁盘({@code Files.write}),这有两个后果: + *
    + *
  1. 不能上多副本 —— 两个实例各存各的,学生这次传到 A、下次从 B 读, + * 文件就"不见了"。这不是性能问题,是正确性问题。
  2. + *
  3. 换存储要改业务代码。
  4. + *
+ * + *

路径(path)与对象键(key)是两回事

+ * 业务只认 {@code path}(如 {@code std/1/17/张同学-…-身份证.jpg}), + * 它是**稳定**的、存在 {@code collect_file.std_path} 里。 + * 各实现自己决定怎么映射成真实的存储位置: + * + * 这样**换存储不用动数据库**,也不用改业务代码。 + * + *

原始件与规范件都要存

+ * {@code raw/} 是学生上传的原图、{@code std/} 是压缩改名后的规范件。 + * 两者都存是刻意的:认定机构退件时我们要能拿出原始件核对 + * ("是不是你们压坏的"这个问题必须回答得了)。 + */ +public interface StorageService { + + /** + * 存一个对象。 + * + * @param path 业务路径(不以斜杠开头) + * @param bytes 内容 + * @param contentType MIME,可为 null + * @return 实际写入的对象键(记日志用) + */ + String put(String path, byte[] bytes, String contentType); + + /** 读回字节。压缩与校验要用原始字节,所以这个方法必须可靠。 */ + byte[] getBytes(String path); + + /** + * 取一个**可直接访问**的 URL。 + * + *

私有空间返回的是带有效期的签名链接(过期后 403),所以这个 URL + * 不能被长期保存(不要写进数据库、不要塞进导出的 Excel)—— + * 每次要用的时候现取。 + * + * @param expireSeconds 有效期(秒);实现可忽略(本地存储无所谓) + */ + String url(String path, long expireSeconds); + + boolean exists(String path); + + /** 删除。软删业务记录时**不要**调它 —— 原始件要留着备查。 */ + void delete(String path); + + /** 实现名({@code local} / {@code qiniu}),用于日志与自检。 */ + String provider(); +} diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml index 26d61e2..6a516f4 100644 --- a/backend/xt-server/src/main/resources/application-dev.yml +++ b/backend/xt-server/src/main/resources/application-dev.yml @@ -51,6 +51,39 @@ fill: # PKCS#8 私钥(单行 Base64,无 PEM 头尾) private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0= + # ── 材料文件存储(原图 + 压缩改名后的规范件)────────────────────────── + # + # ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** —— + # 两个实例各存各的盘,学生这次传到 A、下次请求落到 B,文件就"不见了"。 + # 这不是"性能差一点",是正确性错误。生产必须 provider=qiniu。 + # + # ⚠️ 七牛空间是**私有**的,所以下载走签名链接(带有效期)。 + # 因此 URL **不能长期保存**:写进数据库、塞进导出的 Excel、 + # 或者发给学生让他明天再打开,都会变成 403 —— 每次要用现取。 + # + # AK/SK 只从环境变量或 application-local.yml 取,**不要写在这个文件里** + # (这个文件是提交进仓库的)。application-local.yml 已在 .gitignore 里, + # 模板见同目录的 application-local.yml.example。 + storage: + provider: ${XT_STORAGE_PROVIDER:local} + # bucket 内的目录前缀 —— 与同空间的其他项目隔离 + prefix: xiaotutongxue + # 启动时做一次真实的 写入→存在→读回→删除 自检。 + # **换存储、换密钥、换空间之后值得开一次**:它能在启动时就把 + # "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。 + # 默认关:生产启动不该依赖外部网络,云存储的瞬时抖动不该让服务起不来。 + self-check-on-startup: ${XT_STORAGE_SELF_CHECK:false} + qiniu: + access-key: ${QINIU_ACCESS_KEY:} + secret-key: ${QINIU_SECRET_KEY:} + bucket: ${QINIU_BUCKET:tools-matrix} + domain: ${QINIU_DOMAIN:https://file.tools.coding.design} + # auto = 让 SDK 自己查空间在哪个区(推荐)。 + # 「华东-浙江」在新旧控制台对应 z0 与 cn-east-2 两个 id,写错了的症状是**上传超时**—— + # 一个从报错完全看不出原因的失败。要写死就用 huadong / huadong2。 + region: ${QINIU_REGION:auto} + signed-url-expire-seconds: 1800 + # ── 微信小程序(《03-接口设计》§7.12 填报二维码)──────────────────────────── # # ⚠️ app-secret 与上面几把密钥**性质不同**: diff --git a/backend/xt-server/src/main/resources/application.yml b/backend/xt-server/src/main/resources/application.yml index 17e93b7..9a53878 100644 --- a/backend/xt-server/src/main/resources/application.yml +++ b/backend/xt-server/src/main/resources/application.yml @@ -19,6 +19,14 @@ spring: name: xt-server profiles: active: dev + config: + # 本地私有配置(**不提交**):放云存储密钥这类不该进仓库的东西。 + # optional: 没有这个文件也能正常启动(默认值都在各自的 yml 里)。 + # + # ⚠️ 刻意用 file: 前缀(而不是 classpath:)—— 放 classpath 里会被**打进 jar**, + # 密钥就跟着构建产物到处走了。放在 jar 外面才能保证"构建产物里没有密钥"。 + # 模板见 backend/application-local.yml.example(那个是提交的)。 + import: optional:file:./application-local.yml lifecycle: # 优雅停机:等在途请求处理完,避免上传/导出被打断 timeout-per-shutdown-phase: 30s diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 7f3387f..afabb5a 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -1108,3 +1108,91 @@ P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权 3. **接入对象存储**(阻塞多副本部署) 4. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、 C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 + + +--- + +## §一·十六 对象存储接七牛云(解除"不能上多副本") + +### 交付 +| 文件 | 内容 | +|---|---| +| `module/file/storage/StorageService.java` | 存储抽象:`put / getBytes / url / exists / delete` | +| `module/file/storage/LocalStorageService.java` | 本地磁盘(**仅开发**,日志里明说多副本会丢文件) | +| `module/file/storage/QiniuStorageService.java` | 七牛云(官方 SDK 7.16.0) | +| `config/StorageConfig.java` | 装配 + 可选启动自检 | +| `config/FillProperties.java` | 新增 `fill.storage`(provider/prefix/qiniu/self-check) | +| `MaterialFileController` | 新增 `GET /{id}/url`(签名链接)与 `GET /{id}/content` | +| `scripts/e2e-m1-storage.py` | 存储自检(没配七牛时**跳过而非失败**) | + +### 关键设计 + +**① 业务只认 `path`,各实现自己映射。** +`collect_file.std_path` 存的是 `std/1/17/张同学-…-身份证.jpg`(业务路径)。 +本地落到 `./data/uploads/std/...`,七牛映射成 `xiaotutongxue/std/...`。 +**换存储不用改数据库、不用改业务代码。** + +**② 空间是私有的 → 下载必须签名。** +`GET /mp/files/{id}/url` 每次现签一个带有效期的链接(默认 30 分钟)。 +所以这个 URL **绝不能长期保存** —— 写进数据库、塞进导出的 Excel、 +或者发给学生让他明天再打开,都会变成 403。 + +**③ 对象键里有中文,拼 URL 必须逐段编码。** +规范文件名是 `张同学-…-身份证.jpg`(产品要求"文件名能唯一归属到人")。 +整串 `URLEncoder.encode` 会把 `/` 编成 `%2F`,那在七牛那边是**另一个键** —— +表现是"文件明明传上去了却打不开",而报错什么都没有。 + +**④ 区域默认 `auto`,不写死。** +「华东-浙江」在新旧控制台对应 `z0` 与 `cn-east-2` 两个 id,写错了的症状是 +**上传超时**(一个从报错完全看不出原因的失败)。要写死就用 `huadong` / `huadong2`。 + +**⑤ AK/SK 不进仓库、也不进 jar。** +- `application-dev.yml` 里只有占位符 `${QINIU_ACCESS_KEY:}`(已提交) +- `backend/application-local.yml` 放真密钥(**已 gitignore**), + `spring.config.import: optional:file:./application-local.yml` +- ⚠️ **刻意用 `file:` 而不是 `classpath:`** —— 放 resources 里会被**打进 jar**, + 密钥就跟着构建产物到处走了 +- 生产用环境变量,连这个文件都不需要 +- 模板:`backend/application-local.yml.example`(提交) + +**⑥ 配了 qiniu 却没密钥 → 启动就炸。** +`QiniuStorageService` 的构造函数直接抛异常。起得来的话,第一个受害的是 +正在填材料的学生,而排查要从"文件保存失败"一路追到环境变量。 +可选自检 `XT_STORAGE_SELF_CHECK=true` 会真的走一遍 写入→存在→读回→删除。 + +### ⏳ 尚未验证的部分(**要密钥**) +用户给了:域名 `https://file.tools.coding.design`、空间 `tools-matrix`、 +华东-浙江、**私有**、目录前缀 `xiaotutongxue`。 +**但没给 AccessKey / SecretKey** —— 没有它生成不了上传凭证,所以对七牛的真实链路**没跑过**。 + +补齐与验证步骤: +``` +① 编辑 backend/application-local.yml(已 gitignore) + 把 provider 那行取消注释改成 qiniu,填上 access-key / secret-key +② 重启后端(想更保险就加 XT_STORAGE_SELF_CHECK=true,启动时先自检一次) +③ python scripts/e2e-m1-storage.py + 它会验:配置齐全 → 上传→处理落进七牛 → 取签名链接 → **真的下载回来比字节** + → 不带签名被拒(证明空间确实私有)→ 对象键带前缀 → 原始件一字不差 +``` +没配七牛时脚本**跳过并 exit 0**,不会误报失败。 + +### 已验证的部分 +- 存储抽象改造**没有破坏任何现有链路**:本地磁盘路径下 + `e2e-m3-file 29/29`、`m3-submit 31/31`、`m1-materials 68/68`、`m3-draft 27/27` 全绿 +- 启动日志明确打出「材料存储:本地磁盘(./data/uploads)—— 多副本部署下会丢文件」 +- `git check-ignore` 确认 `backend/application-local.yml` 被忽略、`.example` 不被忽略 +- 自检脚本的占位符解析:一开始显示成 `provider=${XT_STORAGE_PROVIDER:local}`(既不是 + local 也不是 qiniu)—— **配置检查这一环一旦不可信,它给的"通过"也就没意义了**,已修 + +### 下一步 +1. **补 AK/SK 并跑 `e2e-m1-storage.py`**(上面那三步) +2. 补齐后 `deploy/docker-compose.yml` 里的 MinIO 可以彻底删掉(当初是"先起好", + 现在存储走七牛,本地留磁盘即可);`XT_STORAGE_PROVIDER=qiniu` 进部署环境变量 +3. 学生端材料**预览图**(`/mp/files/{id}/url` 已经能用,前端还没接) +4. **直接上传**(契约里的 `POST /mp/files/upload-token`):现在有了七牛, + 这个接口终于可以按契约实现 —— 大文件由学生直传七牛、不经我们服务器。 + 注意:压缩是服务端的核心能力,直传后仍要下载回来压,所以它省的是**学生那一段的上行** + (几十 MB 的翻拍件不占用我们的带宽与连接) +5. `M1-05` 规则模板 +6. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、 + C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测 diff --git a/scripts/e2e-m1-storage.py b/scripts/e2e-m1-storage.py new file mode 100644 index 0000000..d883a26 --- /dev/null +++ b/scripts/e2e-m1-storage.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +对象存储 自检(七牛云 / 本地磁盘)。 + + [x] 配置是否就绪(provider / bucket / domain / 密钥) + [x] 材料上传 → 处理 → 落进对象存储 + [x] 取签名链接能下载到**字节一致**的内容 ← 真正的证明 + [x] **不带签名**的同地址被拒(证明空间确实是私有的,签名在起作用) + [x] 对象键带目录前缀(与同空间其他项目隔离) + +⚠️ 为什么要有这个脚本: + "文件传上去了"和"文件**取得回来**"是两件事。七牛空间是私有的, + 少签一个名、签错有效期、键里的中文没编码对 —— 每一种都会让 + 上传**成功**但下载失败(403/404)。只有真的把字节取回来比一遍才知道配对了。 + +跑法: + python scripts/e2e-m1-storage.py + 没配置七牛时会直接跳过(exit 0),不会误报失败。 +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import io +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources") +LOCAL_YML = os.path.join(ROOT, "backend", "application-local.yml") +PG = os.environ.get("XT_PG_CONTAINER", "xt-pg") +UID = 992004 +BASE = "http://localhost:8080" + +PASSED: list[str] = [] +FAILED: list[tuple[str, str]] = [] + + +def ok(n: str) -> None: + PASSED.append(n) + print(f" [OK] {n}") + + +def fail(n: str, d: str) -> None: + FAILED.append((n, d)) + print(f" [FAIL] {n}\n {d}") + + +def check(n: str, cond: bool, d: str = "") -> bool: + if cond: + ok(n) + else: + fail(n, d or "断言不成立") + return cond + + +def skip(n: str, why: str) -> None: + print(f" [SKIP] {n}\n {why}") + + +def sql(q: str) -> str: + r = subprocess.run( + ["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG, "psql", "-U", "fill", + "-d", "filldb", "-t", "-A", "-c", q], capture_output=True, text=True, timeout=40) + return (r.stdout or r.stderr).strip() + + +def read_yml(path: str) -> str: + return open(path, encoding="utf-8").read() if os.path.exists(path) else "" + + +def resolve(v: str | None) -> str | None: + """ + 把 `${ENV:default}` 解析成实际值。 + + ⚠️ 不解析的话自检会**说谎**:显示成 `provider=${XT_STORAGE_PROVIDER:local}` —— + 既不是 local 也不是 qiniu,读的人只会更糊涂。配置检查这一环一旦不可信, + 它给的"通过"也就没意义了。 + """ + if not v: + return None + m = re.fullmatch(r"\$\{([^:}]+):([^}]*)\}", v) + if m: + env = os.environ.get(m.group(1)) + return ((env if env is not None else m.group(2)).strip()) or None + m = re.fullmatch(r"\$\{([^}]+)\}", v) + if m: + return (os.environ.get(m.group(1)) or "").strip() or None + return v + + +def cfg_value(text: str, key: str) -> str | None: + m = re.search(rf"^\s*{re.escape(key)}:\s*(.+?)\s*$", text, re.M) + if not m: + return None + return resolve(m.group(1).strip().strip("'\"") or None) + + +def storage_cfg() -> dict: + """ + 读配置的顺序与 Spring 一致:**application-local.yml 覆盖 application-dev.yml**。 + 不这么做会出现"我以为在用七牛、其实脚本读的是 dev 里的 local"这种自欺。 + """ + dev = read_yml(os.path.join(SRC, "application-dev.yml")) + local = read_yml(LOCAL_YML) + out = {} + for k in ("provider", "prefix", "access-key", "secret-key", "bucket", "domain", "region"): + out[k] = cfg_value(dev, k) + for k in ("provider", "prefix", "access-key", "secret-key", "bucket", "domain", "region"): + v = cfg_value(local, k) + if v: + out[k] = v + return out + + +def b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def mint(secret: str, issuer: str, uid: int, tenant: int) -> str: + key = base64.b64decode(secret) + now = int(time.time()) + signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "." + + b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant, + "orgIds": [], "roles": ["STUDENT"], "userType": 4, + "iat": now, "exp": now + 3600}, separators=(",", ":")).encode())) + return signing + "." + b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest()) + + +def call(method: str, path: str, token: str | None = None, body: dict | None = None) -> dict: + h = {"Content-Type": "application/json; charset=utf-8"} + if token: + h["Authorization"] = f"Bearer {token}" + data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None + req = urllib.request.Request(BASE + path, data=data, headers=h, method=method) + try: + with urllib.request.urlopen(req, timeout=90) as x: + return json.loads(x.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + return json.loads(e.read().decode("utf-8")) + except Exception: + return {"_http": e.code} + + +def upload(token: str, sid: int, code: str, filename: str, data: bytes) -> dict: + boundary = "----xte2e" + uuid.uuid4().hex[:12] + parts = [] + for k, v in [("submissionId", str(sid)), ("materialCode", code)]: + parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()) + parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: image/jpeg\r\n\r\n".encode()) + parts.append(data) + parts.append(f"\r\n--{boundary}--\r\n".encode()) + req = urllib.request.Request( + BASE + "/api/v1/mp/files/upload", data=b"".join(parts), method="POST", + headers={"Content-Type": f"multipart/form-data; boundary={boundary}", + "Authorization": f"Bearer {token}"}) + try: + with urllib.request.urlopen(req, timeout=120) as x: + return json.loads(x.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + return json.loads(e.read().decode("utf-8")) + except Exception: + return {"_http": e.code} + + +def http_get(url: str) -> tuple[int, bytes]: + try: + with urllib.request.urlopen(url, timeout=60) as x: + return x.status, x.read() + except urllib.error.HTTPError as e: + return e.code, b"" + except Exception: + return 0, b"" + + +def small_jpeg() -> bytes: + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (900, 1200), (206, 212, 218)).save(buf, "JPEG", quality=90) + return buf.getvalue() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--task", type=int, default=17) + args = ap.parse_args() + task_id = args.task + + print("=" * 72) + print("对象存储 自检") + print("=" * 72) + + cfg = storage_cfg() + provider = (cfg.get("provider") or "local").lower() + print(f" provider = {provider}") + print(f" bucket = {cfg.get('bucket')}") + print(f" domain = {cfg.get('domain')}") + print(f" prefix = {cfg.get('prefix')}") + print(f" region = {cfg.get('region')}") + + if provider != "qiniu": + print() + skip("七牛云自检", "当前 provider=local(文件存在本地磁盘,多副本部署会丢文件)") + print(""" + 要启用七牛云: + ① 编辑 backend/application-local.yml(已在 .gitignore 里,不会被提交) + ② 取消 provider 那行的注释改成 qiniu,并填上 access-key / secret-key + ③ 重启后端,再跑一次这个脚本 + """) + print("=" * 72) + print("跳过(不是失败):当前在用本地磁盘") + print("=" * 72) + return 0 + + for k in ("access-key", "secret-key", "bucket", "domain"): + if not cfg.get(k): + fail(f"配置 {k}", f"provider=qiniu 但 {k} 为空 —— 服务端会在启动时就拒绝") + return 1 + ok("七牛配置齐全(provider/bucket/domain/密钥)") + + text = read_yml(os.path.join(SRC, "application-dev.yml")) + read_yml(os.path.join(SRC, "application.yml")) + sec = re.search(r"^\s*secret:\s*(\S+)\s*$", text, re.M) + iss = re.search(r"^\s*issuer:\s*(\S+)\s*$", text, re.M) + if not sec or not iss: + fail("读 JWT 配置", "找不到 secret/issuer") + return 1 + tok = mint(sec.group(1), iss.group(1), UID, 1) + + rid = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1") + if not rid.isdigit(): + fail("取名册行", "任务下没有名册") + return 1 + sql(f"UPDATE collect_roster SET bind_user_id={UID}, bind_type=1 WHERE id={rid}") + + r = call("POST", "/api/v1/mp/submissions", tok, {"taskId": task_id}) + if r.get("code") != 0: + fail("建草稿", f"{r}") + return 1 + sid = r["data"]["id"] + print(f" 草稿 submission={sid}") + + try: + print("\n[1] 上传 → 处理") + img = small_jpeg() + up = upload(tok, sid, "IDCARD", "身份证正面.jpg", img) + if not check("上传成功(这一步已经把原图写进对象存储)", up.get("code") == 0, f"{up}"): + return 1 + fid = up["data"]["id"] + pr = call("POST", f"/api/v1/mp/files/{fid}/process", tok) + check("压缩改名成功(规范件也写进去了)", pr.get("code") == 0, f"{pr}") + + print("\n[2] 签名链接") + u = call("GET", f"/api/v1/mp/files/{fid}/url", tok) + if not check("取到链接", u.get("code") == 0, f"{u}"): + return 1 + d = u.get("data") or {} + signed = d.get("url") or "" + print(f" {signed[:120]}{'…' if len(signed) > 120 else ''}") + check("provider 是 qiniu(不是本地磁盘)", d.get("provider") == "qiniu", f"{d.get('provider')}") + check("链接指向配置的域名", (cfg.get("domain") or "") in signed, f"domain={cfg.get('domain')}") + check("是**签名**链接(带 e= 与 token=)", + "e=" in signed and "token=" in signed, "没有签名参数,私有空间会 403") + check("对象键带目录前缀", f"/{cfg.get('prefix')}/" in signed, f"prefix={cfg.get('prefix')}") + check("有效期是正数秒", (d.get("expiresIn") or 0) > 0, f"{d.get('expiresIn')}") + + print("\n[3] 真的下载回来比一遍(这才是证明)") + status, body = http_get(signed) + check("签名链接返回 200", status == 200, f"HTTP {status}") + if status == 200: + check("下载到的内容非空", len(body) > 0, f"{len(body)} 字节") + # 规范件是压缩过的,与上传的原图**不一定**等大(这是正常的); + # 这里比的是"取回来的确实是一张 JPEG 图" + check("取回的是 JPEG(FFD8 开头)", + body[:2] == b"\xff\xd8", f"前两字节 {body[:2]!r}") + + print("\n[4] 不带签名应当被拒(证明空间确实是私有的)") + unsigned = signed.split("?")[0] + st2, _ = http_get(unsigned) + check("裸地址被拒(401/403)", st2 in (401, 403), f"HTTP {st2} —— 如果返回 200 说明空间是公开的") + + print("\n[5] 原始件也取得回来(原始件绝不能丢)") + u2 = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok) + signed2 = ((u2.get("data") or {}).get("url")) or "" + st3, body3 = http_get(signed2) + check("原始件签名链接返回 200", st3 == 200, f"HTTP {st3}") + if st3 == 200: + check("原始件字节与上传的**一模一样**", + body3 == img, f"上传 {len(img)} 字节 / 取回 {len(body3)} 字节") + finally: + print("\n[6] 清理") + sql(f"DELETE FROM collect_file WHERE submission_id={sid}") + sql(f"DELETE FROM collect_submission WHERE id={sid}") + sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={rid}") + ok("已清理草稿与名册绑定") + print(""" + ⚠️ 对象存储里的文件**没有删** —— 那正是要的:原始件留着备查, + 认定机构退件时要能回答"是不是你们压坏的"。要清就从七牛控制台按前缀清。""") + + print("\n" + "=" * 72) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + for n, dd in FAILED: + print(f" · {n}\n {dd}") + print("=" * 72) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + raise SystemExit(main())