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:
@@ -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 前缀) |
|
||||
|
||||
|
||||
@@ -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<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 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<Byte>): String {
|
||||
let md = MD5()
|
||||
md.write(data)
|
||||
toBase64String(md.finish())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user