verify(storage): 七牛云接通并验证通过 —— 对象存储这块完成

根因就是那个"多出来的一个字符":secret-key 原来是 **41** 字符,
而七牛的 AK/SK 都是 40 字符的 URL 安全 base64。用户重新复制后(SK 40 字符)一切正常。

排查方式值得记:没有凭一次失败下结论,而是做了三次互相独立的验证 ——
  ① 我手写的管理 API 签名(可能是我的 bug,不能作数)
  ② 应用启动自检里的**七牛官方 SDK** 的 uploadToken
  ③ 一个**能把原因分开**的调用:BucketManager.stat(bucket, 一个一定不存在的键)
     612 = 鉴权通过(只是文件不存在)/ 401 = 密钥无效 / 631 = bucket 名不对
三次一致指向"密钥无效",所以给用户的结论是确定的,而不是"可能是配置问题"。

验证证据:
  启动自检通过:写入 → 存在 → 读回 → 删除 全部正常(provider=qiniu)
  材料存储:七牛云(bucket=tools-matrix domain=https://file.tools.coding.design)
  e2e-m1-storage.py 16/16:
    · 签名链接形如 .../xiaotutongxue/std/1/17/%E5%BC%A0%E5%90%8C%E5%AD%A6-…?e=…&token=…
    · **中文对象键逐段编码正确**(就是设计时专门防的那个坑)
    · 签名链接下载回来是真 JPEG、非空
    · **不带签名的裸地址被拒(401/403)→ 空间确实私有,签名在起作用**
    · 对象键带 xiaotutongxue/ 前缀
    · **原始件字节与上传的一模一样**
  全量回归 370 条断言、0 失败(此时 provider=qiniu,文件真的进七牛)

顺带修掉一条"写死了存储位置"的 E2E 断言:e2e-m3-file.py 原来断言
  「原始文件真的存在磁盘上」,切到七牛后整片变红,而那看起来像代码坏了。
改成通过产品接口把文件取回来比字节(GET /mp/files/{id}/url → 下载):
  对两种 provider 都成立,而且比原来更强(顺带验了签名链接、私有访问、内容完整性)。
教训:**在"可替换的实现"上写断言,等于把测试绑死在一种实现上。**

状态:backend/application-local.yml 已是 provider: qiniu(密钥 gitignore、不进 jar);
生产用 XT_STORAGE_PROVIDER=qiniu + QINIU_ACCESS_KEY/QINIU_SECRET_KEY。
This commit is contained in:
2026-09-29 14:52:51 +08:00
parent b9373d6ebd
commit d8463a051f
2 changed files with 105 additions and 7 deletions
+69
View File
@@ -1265,3 +1265,72 @@ profile 文件后加载、优先级更高,行为符合直觉。启动日志现
5. `M1-05` 规则模板
6. 待办(非阻塞):C1 **小程序资质**、C5 隐私声明法务文案、D1 OCR 选型、
D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
---
## §一·十八 ✅ 七牛云已接通并验证通过(对象存储这块完成)
### 根因就是那个"多出来的一个字符"
上一轮自检报 `401 BadToken`,我指出 `secret-key` 是 **41 字符**(七牛的 AK/SK 都是 40)。
用户重新复制后 → **SK 40 字符** → 一切正常。
> 值得记的是**排查方式**:我没有凭一次失败就下结论,而是做了**三次互相独立**的验证:
> ① 我手写的管理 API 签名 —— 可能是我的 bug,不能作数;
> ② 应用启动自检里的**七牛官方 SDK** `uploadToken`;
> ③ 一个**能把原因分开**的调用:`BucketManager.stat(bucket, 一个一定不存在的键)`
> —— 612 = 鉴权通过(只是文件不存在)/ 401 = 密钥无效 / 631 = bucket 名不对。
> 三次一致指向"密钥无效",所以到用户手里时结论是确定的,而不是"可能是配置问题"。
### 验证证据(全绿)
```
启动日志:
The following 2 profiles are active: "local", "dev"
材料存储:七牛云(bucket=tools-matrix domain=https://file.tools.coding.design …)
存储自检通过:写入 → 存在 → 读回 → 删除 全部正常(provider=qiniu)
```
`e2e-m1-storage.py` **16/16**,关键几条:
- 签名链接形如
`https://file.tools.coding.design/xiaotutongxue/std/1/17/%E5%BC%A0%E5%90%8C%E5%AD%A6-…-身份证…?e=…&token=…`
- **中文对象键逐段编码正确**(`%E5%BC%A0…`)—— 就是设计时专门防的那个坑
- 签名链接下载回来是**真 JPEG**、非空
- **不带签名的裸地址被拒(401/403)→ 空间确实是私有的,签名在起作用**
- 对象键带 `xiaotutongxue/` 前缀
- **原始件字节与上传的一模一样**
**全量回归 370 条断言、0 失败**(此时 provider=qiniu,文件真的进七牛):
```
m3-file 32 / m3-submit 31 / m3-draft 27 / m3-gate 29 / m1-storage 16
m1-materials 68 / m1-form 48 / m1-task 71 / m1-orgs 48
```
### 顺带修掉一条"写死了存储位置"的 E2E 断言
`e2e-m3-file.py` 原来断言「原始文件**真的存在磁盘上**」「规范文件也落**盘**了」——
切到七牛后这两条整片变红,而**那看起来像代码坏了**。
改成**通过产品接口把文件取回来比字节**(`GET /mp/files/{id}/url` → 下载):
- 对两种 provider 都成立
- 而且比原来**更强**:顺带验证了签名链接、私有空间访问、内容完整性
> 教训:**在"可替换的实现"上写断言,等于把测试绑死在一种实现上。**
> 要么断言行为(能取回、字节一致),要么先探测当前 provider 再分支 —— 别写死。
### 现在的状态
- `backend/application-local.yml`:`provider: qiniu` ✓(密钥在里面,**已 gitignore、不在 jar 里**)
- 生产用环境变量:`XT_STORAGE_PROVIDER=qiniu` + `QINIU_ACCESS_KEY` / `QINIU_SECRET_KEY`
- 启动自检 `XT_STORAGE_SELF_CHECK=true` 保留成可选(默认关:生产启动不该依赖外部网络)
### 下一步
1. 把 `deploy/docker-compose.yml` 里的 **MinIO 删掉**(当初是"先起好",现在存储走七牛);
部署环境变量补 `XT_STORAGE_PROVIDER=qiniu` 与 AK/SK
2. 学生端**材料预览图**(`/mp/files/{id}/url` 已可用,前端未接)
3. **直传** `POST /mp/files/upload-token`(有了七牛就能按契约实现;省的是学生那一段上行)
4. `M1-05` 规则模板
5. 待办(非阻塞):C1 **小程序资质**(已明确会阻塞学生真正开始填报)、
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测
### 遗留物
- 七牛 bucket 里留了自检/回归产生的对象(`xiaotutongxue/raw/…`、`xiaotutongxue/std/…`、
`_selfcheck/`、`_probe_/`)。**这是刻意的**:原始件要留着备查。
要清就在七牛控制台按前缀清,或跑一次带清理的脚本。
- `backend/data/uploads/` 里有本地磁盘时代的旧测试文件(已 gitignore),可随时删。