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
+36 -7
View File
@@ -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] 幂等:重复处理")