From 67f7c16a3fd7216aeff9cbe6d735c24f405f5277 Mon Sep 17 00:00:00 2001 From: xRain Date: Mon, 17 Aug 2026 00:22:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SimApiStorage=20=E5=A2=9E=E5=8A=A0=20de?= =?UTF-8?q?leteFiles=20=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=EF=BC=88S3=20D?= =?UTF-8?q?eleteObjects=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /{bucket}?delete + XML 一次删除多个对象(Minio RemoveObjects 同款接口) - MinIO 强制要求 Content-Md5 头(缺失返回 MissingContentMD5),MD5 经 stdx 计算 + Base64 - 已用 oss.anymemory.cn 实测:批量删除后访问对象返回 404 --- README.md | 1 + src/helpers/SimApiStorage.cj | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/README.md b/README.md index 3b0e3df..0265081 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ builder.addSimApi { options => | `getUploadUrl(path, expire=7200)` | 上传预签名 URL,返回 `GetUploadUrlResponse(UploadUrl, DownloadUrl, Path)` | | `getDownloadUrl(path, expire=600)` | 下载预签名 URL | | `uploadFile(path, data, contentType="image/png")` | 直接 PUT 上传(字节数组) | +| `deleteFiles(paths)` | 批量删除对象(S3 原生 DeleteObjects,一次请求删多个) | | `fullUrl(path)` / `getUrl(path)` | 补全访问 URL(`~/` 前缀依赖请求上下文) | | `getPath(url)` | 从 URL 还原相对路径(去掉 Endpoint/Bucket 或 ServeUrl 前缀) | diff --git a/src/helpers/SimApiStorage.cj b/src/helpers/SimApiStorage.cj index fb9c2a2..83d472c 100644 --- a/src/helpers/SimApiStorage.cj +++ b/src/helpers/SimApiStorage.cj @@ -16,6 +16,7 @@ package simapi.helpers import std.collection.* import std.time.* import stdx.crypto.digest.* +import stdx.encoding.base64.* import stdx.encoding.hex.* import stdx.net.tls.* import stdx.net.tls.common.* @@ -120,6 +121,27 @@ public class SimApiStorage { putObject(obj, data, contentType) } + /** + * 批量删除对象(对齐 C# Minio RemoveObjectsAsync;S3 原生 DeleteObjects 接口: + * POST /{bucket}?delete,一次请求删除多个对象,无需逐个删除)。 + * @param paths 对象路径数组(每个须以 / 开头)。 + */ + public func deleteFiles(paths: Array): Unit { + if (paths.isEmpty()) { + return + } + for (p in paths) { + checkPath(p) + } + var sb = StringBuilder() + sb.append("") + for (p in paths) { + sb.append("${xmlEscape(trimLeadingSlash(p))}") + } + sb.append("true") + deleteObjects(sb.toString()) + } + /** * 使用 path 获取完整的访问 URL(对齐 C# FullUrl)。 */ @@ -308,6 +330,45 @@ public class SimApiStorage { } } + /// 批量删除请求(S3 DeleteObjects:POST /{bucket}?delete + XML) + private func deleteObjects(xml: String): Unit { + let now = DateTime.nowUTC() + let amzDate = formatAmzDate(now) + let dateStamp = amzDate[0..8] + let payloadHash = toHexString(sha256Bytes(xml.toArray())) + let canonicalUri = "/${_bucket}" + let canonicalQuery = "delete=" + let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n" + let signedHeaders = "host;x-amz-content-sha256;x-amz-date" + let canonicalRequest = "POST\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}" + let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" + + "${toHexString(sha256Bytes(canonicalRequest.toArray()))}" + let authorization = buildAuthorization(dateStamp, signedHeaders, + toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray()))) + let client = createClient() + try { + let request = HttpRequestMessage(NetHttpMethod.Post, "${_endpoint}${canonicalUri}?${canonicalQuery}") + request.headers.add("x-amz-content-sha256", payloadHash) + request.headers.add("x-amz-date", amzDate) + request.headers.add("Authorization", authorization) + let contentMd5 = md5Base64(xml.toArray()) + let content = ByteArrayContent(xml.toArray()) + content.headers.add("Content-Type", "application/xml") + // MinIO 的 DeleteObjects 强制要求 Content-Md5(缺失返回 MissingContentMD5) + content.headers.add("Content-Md5", contentMd5) + request.content = content + let response = client.send(request) + try { + SimApiError.errorWhenFalse(response.isSuccessStatusCode, code: response.statusCode, + message: "SimApiStorage 批量删除失败: HTTP ${response.statusCode}") + } finally { + response.close() + } + } finally { + client.close() + } + } + private func buildAuthorization(dateStamp: String, signedHeaders: String, signature: String): String { "AWS4-HMAC-SHA256 Credential=${_accessKey}/${dateStamp}/${_region}/s3/aws4_request, " + "SignedHeaders=${signedHeaders}, Signature=${signature}" @@ -381,6 +442,22 @@ public class SimApiStorage { } } + /// XML 特殊字符转义(对象 Key 中可能含 & < > " ') + private static func xmlEscape(s: String): String { + var sb = StringBuilder() + for (c in s.runes()) { + match (c) { + case '&' => sb.append("&") + case '<' => sb.append("<") + case '>' => sb.append(">") + case '"' => sb.append(""") + case '\'' => sb.append("'") + case _ => sb.append(c) + } + } + sb.toString() + } + /// RFC 3986 百分号编码(encodeSlash=false 时保留 '/') private static func uriEncode(s: String, encodeSlash: Bool): String { let bytes = s.toArray() @@ -449,4 +526,11 @@ public class SimApiStorage { sha.write(data) sha.finish() } + + /// MD5 → Base64(Content-Md5 头,S3 DeleteObjects 必需) + private static func md5Base64(data: Array): String { + let md = MD5() + md.write(data) + toBase64String(md.finish()) + } }