feat(storage): 对象存储接七牛云 —— 解除"不能上多副本"

文件以前直接写本地磁盘:两个实例各存各的,学生这次传到 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,不误报失败。
This commit is contained in:
2026-09-29 14:09:13 +08:00
parent ef903a55c3
commit 65ebf8de90
15 changed files with 1164 additions and 23 deletions
+37
View File
@@ -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