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
+88
View File
@@ -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 微信三通道体积上限真机实测