From d8463a051fd46f52683103ac6722050a959c12d9 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 14:52:51 +0800 Subject: [PATCH] =?UTF-8?q?verify(storage):=20=E4=B8=83=E7=89=9B=E4=BA=91?= =?UTF-8?q?=E6=8E=A5=E9=80=9A=E5=B9=B6=E9=AA=8C=E8=AF=81=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E8=BF=99=E5=9D=97=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因就是那个"多出来的一个字符":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。 --- docs/交接摘要.md | 69 ++++++++++++++++++++++++++++++++++++++++++ scripts/e2e-m3-file.py | 43 +++++++++++++++++++++----- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 56aa1bb..77097b9 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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),可随时删。 diff --git a/scripts/e2e-m3-file.py b/scripts/e2e-m3-file.py index 575ae76..b80e269 100644 --- a/scripts/e2e-m3-file.py +++ b/scripts/e2e-m3-file.py @@ -121,6 +121,22 @@ def call(method: str, path: str, token: str | None = None, body: dict | None = N return {"_http": e.code} +def http_get(url: str) -> tuple[int, bytes]: + """ + 下载一个 URL 的内容。 + + ⚠️ 七牛的签名链接在**响应体**里也是文件内容,但错误时不是(403 会回一段 JSON)—— + 所以调用方要按状态码分支,不要盲目当文件用。 + """ + 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 upload(path: str, token: str, submission_id: int, material_code: str, filename: str, data: bytes, ctype: str) -> dict: boundary = "----xte2e" + uuid.uuid4().hex[:12] @@ -270,14 +286,27 @@ def main() -> int: print("\n[4] 原始件必须保留") rawpath = sql(f"SELECT coalesce(raw_path,'') FROM collect_file WHERE id={fid}") check("raw_path 还在(没有被规范文件覆盖)", rawpath != "", f"raw_path={rawpath}") - full = os.path.join(DATA_DIR, rawpath.replace("/", os.sep)) - check("原始文件真的存在磁盘上", os.path.exists(full), f"路径={full}") - if os.path.exists(full): - check("原始文件大小与上传一致(一字不差)", - os.path.getsize(full) == len(raw), f"{os.path.getsize(full)} vs {len(raw)}") + # ⚠️ **不要断言"文件在磁盘上"** —— 存储是可换的(本地磁盘 / 七牛云)。 + # 写死磁盘路径的断言,在切到对象存储后会整片变红,而那看起来像代码坏了。 + # 正确做法是**通过产品接口把文件取回来比字节** —— 它对两种存储都成立, + # 而且比"文件在不在盘上"更强:它顺带验证了签名链接、私有空间访问、内容完整性。 + uraw = call("GET", f"/api/v1/mp/files/{fid}/url?original=true", tok) + url_raw = ((uraw.get("data") or {}).get("url")) or "" + check("能取到原始件的访问链接", url_raw != "", f"{uraw}") + status_raw, body_raw = http_get(url_raw) + check("原始件取得回来(HTTP 200)", status_raw == 200, f"HTTP {status_raw}") + if status_raw == 200: + check("原始件字节与上传的**一模一样**(原始件绝不能被动过)", + body_raw == raw, f"上传 {len(raw)} 字节 / 取回 {len(body_raw)} 字节") + stdpath = sql(f"SELECT coalesce(std_path,'') FROM collect_file WHERE id={fid}") - full2 = os.path.join(DATA_DIR, stdpath.replace("/", os.sep)) - check("规范文件也落盘了", os.path.exists(full2), f"路径={full2}") + check("std_path 已写入", stdpath != "", f"std_path={stdpath}") + ustd = call("GET", f"/api/v1/mp/files/{fid}/url", tok) + url_std = ((ustd.get("data") or {}).get("url")) or "" + status_std, body_std = http_get(url_std) + check("规范件取得回来(HTTP 200)", status_std == 200, f"HTTP {status_std}") + if status_std == 200: + check("规范件是张真图片(JPEG 开头)", body_std[:2] == b"\xff\xd8", f"{body_std[:2]!r}") # ── 5. 幂等 ─────────────────────────────────────── print("\n[5] 幂等:重复处理")