feat: SimApiStorage 增加 deleteFiles 批量删除(S3 DeleteObjects)

- POST /{bucket}?delete + XML 一次删除多个对象(Minio RemoveObjects 同款接口)
- MinIO 强制要求 Content-Md5 头(缺失返回 MissingContentMD5),MD5 经 stdx 计算 + Base64
- 已用 oss.anymemory.cn 实测:批量删除后访问对象返回 404
This commit is contained in:
2026-08-17 00:22:08 +08:00
parent b4e808e775
commit 67f7c16a3f
2 changed files with 85 additions and 0 deletions
+1
View File
@@ -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 前缀) |
+84
View File
@@ -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 RemoveObjectsAsyncS3 原生 DeleteObjects 接口:
* POST /{bucket}?delete,一次请求删除多个对象,无需逐个删除)。
* @param paths 对象路径数组(每个须以 / 开头)。
*/
public func deleteFiles(paths: Array<String>): Unit {
if (paths.isEmpty()) {
return
}
for (p in paths) {
checkPath(p)
}
var sb = StringBuilder()
sb.append("<Delete xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">")
for (p in paths) {
sb.append("<Object><Key>${xmlEscape(trimLeadingSlash(p))}</Key></Object>")
}
sb.append("<Quiet>true</Quiet></Delete>")
deleteObjects(sb.toString())
}
/**
* 使用 path 获取完整的访问 URL(对齐 C# FullUrl)。
*/
@@ -308,6 +330,45 @@ public class SimApiStorage {
}
}
/// 批量删除请求(S3 DeleteObjectsPOST /{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("&amp;")
case '<' => sb.append("&lt;")
case '>' => sb.append("&gt;")
case '"' => sb.append("&quot;")
case '\'' => sb.append("&apos;")
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 → Base64Content-Md5 头,S3 DeleteObjects 必需)
private static func md5Base64(data: Array<Byte>): String {
let md = MD5()
md.write(data)
toBase64String(md.finish())
}
}