生成器可检测与管理远端主题素材(列表 / 差异 / 覆盖 / 删除)

「传上去」之后还得知道「传得对不对」:同一 slug 传过三次、换过 slug 旧图还在、
重新导出后远端仍是旧版,这些在页面上都看不出来。

- serve.py:s3_request 支持 query;新增 ListObjectsV2(含分页)与 DeleteObject,
  端点 /api/s3/list、/api/s3/delete。
- index.html:远端素材清单按对象键名跟本地比 —— 一致 / 旧图(同名但大小不同)/
  多余(当前主题已不引用)/ 缺失。「补齐差异」只传缺的与旧图,「删除多余」批量删,
  单行可 ↻ 覆盖或 ✕ 删除;删除走 confirm 二次确认。改过 slug 会提示重新检测。
- 修:list 返回完整键而 upload/delete 收相对键,不剥前缀会删到不存在的键
  (S3 回 204,看起来删成功其实没删)。加 relKey() 统一。
- check_sigv4.py 补带 query 的用例(ListObjectsV2 三参数、乱序 token)→ 8/8;
  selftest_upload.py 补列表/分页/幂等删除 → 20 项全过。
This commit is contained in:
tamakyi
2026-10-21 21:29:35 +08:00
parent 9c961b3c05
commit 12c4f3e1be
9 changed files with 549 additions and 23 deletions
+36
View File
@@ -91,7 +91,15 @@ class MockS3(BaseHTTPRequestHandler):
self.end_headers()
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
qs = urllib.parse.parse_qs(parsed.query)
bucket, key = self._split()
if not key and qs.get("list-type") == ["2"]:
if not self._check(bucket):
return
return self._list(qs.get("prefix", [""])[0],
int(qs.get("max-keys", ["1000"])[0] or 1000),
int(qs.get("continuation-token", ["0"])[0] or 0))
MockS3.requests.append((self.command, self.path, dict(self.headers)))
if key not in MockS3.store:
self._xml(404, "NoSuchKey", "The specified key does not exist")
@@ -103,6 +111,34 @@ class MockS3(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
def _list(self, prefix, max_keys, offset):
# 只实现 ListObjectsV2 里服务端用到的字段;分页用 offset 当 token,够测真实分页逻辑。
keys = sorted(k for k in MockS3.store if k.startswith(prefix))
page = keys[offset:offset + max_keys]
truncated = offset + len(page) < len(keys)
items = []
for k in page:
data = MockS3.store[k][0]
items.append(
"<Contents><Key>%s</Key><Size>%d</Size>"
"<LastModified>2026-10-21T00:00:00.000Z</LastModified><ETag>&quot;mock&quot;</ETag></Contents>"
% (k, len(data)))
token = ("<NextContinuationToken>%d</NextContinuationToken>" % (offset + len(page))) if truncated else ""
body = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
"<Name>%s</Name><Prefix>%s</Prefix><KeyCount>%d</KeyCount>"
"<MaxKeys>%d</MaxKeys><IsTruncated>%s</IsTruncated>%s%s"
"</ListBucketResult>"
% (BUCKET, prefix, len(page), max_keys, "true" if truncated else "false",
token, "".join(items))
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
bucket, key = self._split()
if not self._check(bucket):