feat: 添加s3存储支持
torbox-admin / deploy (push) Successful in 2m47s

This commit is contained in:
2026-10-07 05:25:02 -07:00
parent 5754abe713
commit f22f2a81b9
11 changed files with 1061 additions and 38 deletions
+14
View File
@@ -28,3 +28,17 @@ IMAGE_CACHE_DIR=
IMAGE_CACHE_MAX_MB=
# 图片超过多少天没被访问就删除,默认 7;每次访问会刷新
IMAGE_CACHE_TTL_DAYS=
# 图片缓存存到 S3(可选):S3_BUCKET、S3_ACCESS_KEY_ID、S3_SECRET_ACCESS_KEY 都配置后启用,不配置只用本地缓存
# 启用后图片先写到 IMAGE_CACHE_DIR,再在后台上传到桶里,传完删掉本地文件;播放地址不变,图片由服务端从桶里读出返回
# 此时 IMAGE_CACHE_MAX_MB、IMAGE_CACHE_TTL_DAYS 对桶里的缓存生效,可按桶的容量调大;本地只放待上传的图片
# 启用前已缓存的图片会在启动后自动上传;去掉配置后桶里的对象不再使用,需要手动清理
# 兼容 S3 的服务地址,如 Garage 的 http://127.0.0.1:3900;使用 AWS 时留空
S3_ENDPOINT=
# 区域,默认 us-east-1;Garage 须与其配置中的 s3_region 一致(默认 garage)
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# 图片在桶内的 key 前缀,默认 image-cache/;多个环境共用一个桶时用不同前缀区分,以免互相淘汰
S3_PREFIX=
@@ -0,0 +1 @@
ALTER TABLE `image_cache` ADD `location` text DEFAULT 'local' NOT NULL;
@@ -0,0 +1,392 @@
{
"version": "7",
"dialect": "sqlite",
"id": "ffcb9dbc-4e3c-426a-a3a2-857fd4703c03",
"prevIds": [
"92adf4ca-8aec-4041-b8a8-0eaad746e0b7"
],
"ddl": [
{
"name": "image_cache",
"entityType": "tables"
},
{
"name": "torbox_download_urls",
"entityType": "tables"
},
{
"name": "torbox_rate_hits",
"entityType": "tables"
},
{
"name": "torbox_rate_pauses",
"entityType": "tables"
},
{
"name": "torbox_request_log",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'local'",
"generated": null,
"name": "location",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content_type",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_access_at",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "at",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_rate_pauses"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "until",
"entityType": "columns",
"table": "torbox_rate_pauses"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "at",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "endpoint",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cdn_host",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sent",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "duration_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "queued_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "limited_by",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "retry_after_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "usage",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "detail",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "image_cache_pk",
"table": "image_cache",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "torbox_download_urls_pk",
"table": "torbox_download_urls",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "torbox_rate_hits_pk",
"table": "torbox_rate_hits",
"entityType": "pks"
},
{
"columns": [
"rule"
],
"nameExplicit": false,
"name": "torbox_rate_pauses_pk",
"table": "torbox_rate_pauses",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "torbox_request_log_pk",
"table": "torbox_request_log",
"entityType": "pks"
},
{
"columns": [
{
"value": "last_access_at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "image_cache_last_access_idx",
"entityType": "indexes",
"table": "image_cache"
},
{
"columns": [
{
"value": "rule",
"isExpression": false
},
{
"value": "at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "torbox_rate_hits_rule_at_idx",
"entityType": "indexes",
"table": "torbox_rate_hits"
},
{
"columns": [
{
"value": "at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "torbox_request_log_at_idx",
"entityType": "indexes",
"table": "torbox_request_log"
}
],
"renames": []
}
+1
View File
@@ -14,6 +14,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1147.0",
"@base-ui/react": "^1.8.0",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-virtual": "^3.14.13",
+302
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1147.0
version: 3.1147.0
'@base-ui/react':
specifier: ^1.8.0
version: 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -121,6 +124,78 @@ packages:
resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
engines: {node: '>=10'}
'@aws-sdk/checksums@3.1001.1':
resolution: {integrity: sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1147.0':
resolution: {integrity: sha512-uTrcJxnJKPr8bc1z7cMxopYSQyAm2dPfiMK+UiBr51L8MzimDxHbW/d9GR6X/LFbWDpMzfFPLhB4UohDrCSaPQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.1':
resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.72':
resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.74':
resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.17':
resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.79':
resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.84':
resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.72':
resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.16':
resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.78':
resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.77':
resolution: {integrity: sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.46':
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.47':
resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1138.0':
resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.6':
resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.41':
resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -880,6 +955,30 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@smithy/core@3.35.1':
resolution: {integrity: sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.8.0':
resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.12.1':
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.4':
resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.19.0':
resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==}
engines: {node: '>=18.0.0'}
'@solid-primitives/event-listener@2.4.6':
resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==}
peerDependencies:
@@ -1414,6 +1513,9 @@ packages:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
@@ -3829,6 +3931,171 @@ snapshots:
'@alloc/quick-lru@5.3.0': {}
'@aws-sdk/checksums@3.1001.1':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-s3@3.1147.0':
dependencies:
'@aws-sdk/checksums': 3.1001.1
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/middleware-sdk-s3': 3.972.77
'@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/core@3.978.1':
dependencies:
'@aws-sdk/types': 3.974.6
'@aws-sdk/xml-builder': 3.972.41
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.35.1
'@smithy/signature-v4': 5.7.4
'@smithy/types': 4.19.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.72':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.74':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.17':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-env': 3.972.72
'@aws-sdk/credential-provider-http': 3.972.74
'@aws-sdk/credential-provider-login': 3.972.79
'@aws-sdk/credential-provider-process': 3.972.72
'@aws-sdk/credential-provider-sso': 3.973.16
'@aws-sdk/credential-provider-web-identity': 3.972.78
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.79':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.84':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.72
'@aws-sdk/credential-provider-http': 3.972.74
'@aws-sdk/credential-provider-ini': 3.973.17
'@aws-sdk/credential-provider-process': 3.972.72
'@aws-sdk/credential-provider-sso': 3.973.16
'@aws-sdk/credential-provider-web-identity': 3.972.78
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.72':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.16':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/token-providers': 3.1138.0
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.78':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.77':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.46':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.47':
dependencies:
'@aws-sdk/types': 3.974.6
'@smithy/signature-v4': 5.7.4
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1138.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/types@3.974.6':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.41':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -4572,6 +4839,39 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@smithy/core@3.35.1':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.8.0':
dependencies:
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/node-http-handler@4.12.1':
dependencies:
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/signature-v4@5.7.4':
dependencies:
'@smithy/core': 3.35.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/types@4.19.0':
dependencies:
tslib: 2.8.1
'@solid-primitives/event-listener@2.4.6(solid-js@1.9.15)':
dependencies:
'@solid-primitives/utils': 6.4.1(solid-js@1.9.15)
@@ -5136,6 +5436,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
bowser@2.14.1: {}
brace-expansion@1.1.21:
dependencies:
balanced-match: 1.0.0
@@ -107,7 +107,7 @@ const proxy = async (
`${KIND_LABEL[kind]}请求`
);
// 完整的图片请求走本地缓存;播放器的 Range 请求、视频等不缓存
// 完整的图片请求走缓存(本地或 S3);播放器的 Range 请求、视频等不缓存
const cacheKey = imageCacheKey(type, id, fileId);
const cacheable = kind === "image" && method === "GET" && !range;
if (cacheable) {
@@ -122,7 +122,7 @@ const proxy = async (
return new Response(null, { status: 304, headers });
const body = await hit.open();
if (body) {
log.debug(media, "图片:使用本地缓存");
log.debug({ ...media, location: hit.location }, "图片:使用缓存");
headers.set("Content-Length", String(hit.size));
return new Response(body, { headers });
}
+9
View File
@@ -25,6 +25,15 @@ export const IMAGE_CACHE_SWEEP_INTERVAL_MS = HOUR;
/** 超出大小上限后一次删到上限的这个比例,避免之后每写一张就淘汰一轮 */
export const IMAGE_CACHE_TRIM_RATIO = 0.9;
/** 配置 S3 后图片在桶内的 key 前缀默认值,环境变量 S3_PREFIX 可覆盖 */
export const IMAGE_CACHE_S3_DEFAULT_PREFIX = "image-cache/";
/** 同时上传到 S3 的图片数 */
export const IMAGE_CACHE_UPLOAD_CONCURRENCY = 2;
/** 配置 S3 后本地只放待上传的图片;S3 不可用时待上传的累积到这个大小就不再缓存新图,避免写满磁盘 */
export const IMAGE_CACHE_LOCAL_PENDING_MAX_BYTES = 512 * MB;
/**
* 浏览器缓存图片的时长(秒):同一文件内容不变,浏览器缓存期内不再请求服务端。
* 只缓存 200 响应,限流、出错的响应仍是 no-store
+8 -1
View File
@@ -57,12 +57,19 @@ export const torboxRequestLog = sqliteTable(
(table) => [index("torbox_request_log_at_idx").on(table.at)]
);
/** 图片缓存索引:图片本体在 IMAGE_CACHE_DIR 下,这里只记大小与访问时间,淘汰时不用扫目录 */
/**
* 图片缓存索引:图片本体在 IMAGE_CACHE_DIR 下,配置 S3 后上传完移到桶里;
* 这里只记位置、大小与访问时间,淘汰时不用扫目录
*/
export const imageCache = sqliteTable(
"image_cache",
{
/** 来源/资源 id/文件 id,如 torrents/123/4 */
key: text().primaryKey(),
/** 图片本体所在:local 为 IMAGE_CACHE_DIR(配置 S3 时即待上传),s3 为桶内 */
location: text({ enum: ["local", "s3"] })
.notNull()
.default("local"),
/** 文件字节数 */
size: integer().notNull(),
contentType: text("content_type").notNull(),
+41 -3
View File
@@ -3,7 +3,8 @@ import { MOCK_ACCESS_TOKEN } from "@/constants/auth";
import {
IMAGE_CACHE_DEFAULT_DIR,
IMAGE_CACHE_DEFAULT_MAX_MB,
IMAGE_CACHE_DEFAULT_TTL_DAYS
IMAGE_CACHE_DEFAULT_TTL_DAYS,
IMAGE_CACHE_S3_DEFAULT_PREFIX
} from "@/constants/image-cache";
type ServerEnvName =
@@ -17,7 +18,13 @@ type ServerEnvName =
| "DB_FILE_NAME"
| "IMAGE_CACHE_DIR"
| "IMAGE_CACHE_MAX_MB"
| "IMAGE_CACHE_TTL_DAYS";
| "IMAGE_CACHE_TTL_DAYS"
| "S3_ENDPOINT"
| "S3_REGION"
| "S3_BUCKET"
| "S3_ACCESS_KEY_ID"
| "S3_SECRET_ACCESS_KEY"
| "S3_PREFIX";
/** 读取环境变量,空白视为未配置 */
const readEnv = (name: ServerEnvName) => {
@@ -122,10 +129,41 @@ export const getImageCacheTtlMs = () => {
);
};
/** 图片缓存是否存到 S3:桶与密钥缺一不可;未配置时只用本地缓存 */
export const isS3Enabled = () =>
Boolean(
readEnv("S3_BUCKET") &&
readEnv("S3_ACCESS_KEY_ID") &&
readEnv("S3_SECRET_ACCESS_KEY")
);
/** S3 连接配置,仅服务端使用;调用前先用 isS3Enabled 判断 */
export const getS3Config = () => {
const bucket = readEnv("S3_BUCKET")?.trim();
const accessKeyId = readEnv("S3_ACCESS_KEY_ID")?.trim();
const secretAccessKey = readEnv("S3_SECRET_ACCESS_KEY")?.trim();
if (!bucket || !accessKeyId || !secretAccessKey)
throw new Error("未配置 S3_BUCKET、S3_ACCESS_KEY_ID、S3_SECRET_ACCESS_KEY");
return {
/** 自建服务的地址,如 http://127.0.0.1:3900;不配置即 AWS */
endpoint: readEnv("S3_ENDPOINT")?.trim(),
/** Garage 须与其配置中的 s3_region 一致(默认 garage) */
region: readEnv("S3_REGION")?.trim() || "us-east-1",
bucket,
accessKeyId,
secretAccessKey
};
};
/** 图片在桶内的 key 前缀(S3_PREFIX),默认 image-cache/;多个环境共用一个桶时用它区分 */
export const getS3Prefix = () =>
readEnv("S3_PREFIX")?.trim() || IMAGE_CACHE_S3_DEFAULT_PREFIX;
/** 实际配置的密钥值,供日志脱敏使用;演示模式的固定密钥不是机密,不在其中 */
export const getSecretValues = () =>
[
readEnv("ACCESS_TOKEN"),
readEnv("TORBOX_API_KEY"),
readEnv("JACKETT_API_KEY")
readEnv("JACKETT_API_KEY"),
readEnv("S3_SECRET_ACCESS_KEY")
].filter((value): value is string => Boolean(value));
+206 -32
View File
@@ -1,10 +1,21 @@
// 图片缓存:命中后不再向 TorBox 签发直链、不再请求下载节点,不占 TorBox 额度
// 图片本体存在 IMAGE_CACHE_DIR 下,SQLite 的 image_cache 只存索引(大小、访问时间),淘汰时不用扫目录
// 图片本体存在 IMAGE_CACHE_DIR 下,SQLite 的 image_cache 只存索引(位置、大小、访问时间),淘汰时不用扫目录
//
// 配置 S3 后:图片照旧先写本地,再在后台上传到桶里,传完删掉本地文件;命中时由服务端从桶里读出转发,
// 地址与响应头都不变,前端无感。大小上限与保留时间对桶里的缓存生效,本地只放待上传的图片
//
// 不阻塞请求:命中时只有一次主键查询,访问时间按小时粒度更新且延后写;
// 未命中时边下载边写文件,不让客户端等;淘汰和清理放到响应之后
// 未命中时边下载边写文件,不让客户端等;上传、淘汰和清理放到响应之后
import { createWriteStream } from "node:fs";
import { mkdir, open, readdir, rename, rm, stat } from "node:fs/promises";
import {
mkdir,
open,
readFile,
readdir,
rename,
rm,
stat
} from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
@@ -12,19 +23,24 @@ import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { and, asc, eq, gt, lte, sql } from "drizzle-orm";
import {
IMAGE_CACHE_LOCAL_PENDING_MAX_BYTES,
IMAGE_CACHE_MAX_FILE_BYTES,
IMAGE_CACHE_SWEEP_INTERVAL_MS,
IMAGE_CACHE_TOUCH_INTERVAL_MS,
IMAGE_CACHE_TRIM_RATIO
IMAGE_CACHE_TRIM_RATIO,
IMAGE_CACHE_UPLOAD_CONCURRENCY
} from "@/constants/image-cache";
import { db } from "@/server/db";
import { imageCache } from "@/server/db/schema";
import {
getImageCacheDir,
getImageCacheMaxBytes,
getImageCacheTtlMs
getImageCacheTtlMs,
getS3Prefix,
isS3Enabled
} from "@/server/env";
import { createLogger } from "@/server/logger";
import { deleteObjects, getObjectStream, putObject } from "@/server/s3";
import type { SourceType } from "@/types/torbox";
const log = createLogger("image-cache");
@@ -36,21 +52,36 @@ export const imageCacheKey = (type: SourceType, id: string, fileId: string) =>
/** key 只含来源与数字 id(路由已校验),把 / 换成 - 即可作文件名 */
const fileName = (key: string) => key.replaceAll("/", "-");
const filePath = (key: string) => join(getImageCacheDir(), fileName(key));
/** 桶内的对象 key,如 image-cache/torrents/123/4 */
const objectKey = (key: string) => `${getS3Prefix()}${key}`;
const isEnabled = () => getImageCacheMaxBytes() > 0;
type Location = (typeof imageCache.$inferSelect)["location"];
// 正在写入缓存的 key:同一张图同时到来的请求只有第一个负责写,其余直接透传。
// 挂在 globalThis 上,避免各路由打包后各持一份
const WRITING_KEY = Symbol.for("torbox-admin.image-cache-writing");
const globals = globalThis as { [WRITING_KEY]?: Set<string> };
// 上传队列:queued 含排队中与上传中的 key,同一张图不会重复上传
const UPLOADS_KEY = Symbol.for("torbox-admin.image-cache-uploads");
const globals = globalThis as {
[WRITING_KEY]?: Set<string>;
[UPLOADS_KEY]?: { pending: string[]; queued: Set<string>; active: number };
};
const writing = (globals[WRITING_KEY] ??= new Set<string>());
const uploads = (globals[UPLOADS_KEY] ??= {
pending: [],
queued: new Set<string>(),
active: 0
});
export type CachedImage = {
location: Location;
size: number;
contentType: string;
/** 内容不变时 etag 不变,浏览器带 If-None-Match 来可直接回 304 */
etag: string;
/** 打开缓存文件;文件已丢失时清掉索引并返回 null,调用方改走上游 */
/** 打开缓存的图片;已丢失时清掉索引并返回 null,调用方改走上游 */
open: () => Promise<ReadableStream | null>;
};
@@ -63,6 +94,14 @@ const forget = (keys: string[]) => {
const removeFiles = (keys: string[]) =>
Promise.allSettled(keys.map((key) => rm(filePath(key), { force: true })));
const removeObjects = async (keys: string[]) => {
try {
await deleteObjects(keys.map(objectKey));
} catch (error) {
log.warn({ count: keys.length, err: error }, "删除 S3 中的图片失败");
}
};
const openFile = async (key: string) => {
try {
const handle = await open(filePath(key));
@@ -77,6 +116,19 @@ const openFile = async (key: string) => {
}
};
const openObject = async (key: string) => {
try {
const body = await getObjectStream(objectKey(key));
if (body) return body;
log.warn({ key }, "S3 中的缓存图片已不存在,改走上游");
forget([key]);
} catch (error) {
// S3 暂时不可用:保留索引,恢复后仍可命中
log.warn({ key, err: error }, "S3 读取失败,改走上游");
}
return null;
};
/** 查缓存:同步、只读一行;访问时间距上次记录太久才更新,且放到响应之后写 */
export const getCachedImage = (key: string): CachedImage | null => {
if (!isEnabled()) return null;
@@ -92,6 +144,8 @@ export const getCachedImage = (key: string): CachedImage | null => {
)
.get();
if (!row) return null;
// 去掉 S3 配置后桶里的图片读不到,按未命中处理,重新缓存时覆盖这条索引
if (row.location === "s3" && !isS3Enabled()) return null;
if (now - row.lastAccessAt > IMAGE_CACHE_TOUCH_INTERVAL_MS)
setImmediate(() =>
@@ -103,26 +157,27 @@ export const getCachedImage = (key: string): CachedImage | null => {
);
return {
location: row.location,
size: row.size,
contentType: row.contentType,
etag: `"${row.createdAt.toString(36)}-${row.size.toString(36)}"`,
open: () => openFile(key)
open: () => (row.location === "s3" ? openObject(key) : openFile(key))
};
};
/**
* 清理:删掉超过保留时间没访问的,总大小超过上限时再从最久没访问的删起,直到降到上限的一定比例。
* 先在事务里删索引,文件在事务之后异步删除
* 先在事务里删索引,文件与桶内对象在事务之后异步删除
*/
export const trimImageCache = () => {
const doomed: string[] = [];
const doomed: { key: string; location: Location }[] = [];
db.transaction((tx) => {
const expired = tx
.delete(imageCache)
.where(lte(imageCache.lastAccessAt, Date.now() - getImageCacheTtlMs()))
.returning({ key: imageCache.key })
.returning({ key: imageCache.key, location: imageCache.location })
.all();
doomed.push(...expired.map((row) => row.key));
doomed.push(...expired);
let total =
tx
@@ -133,21 +188,29 @@ export const trimImageCache = () => {
if (total <= max) return;
const target = max * IMAGE_CACHE_TRIM_RATIO;
const rows = tx
.select({ key: imageCache.key, size: imageCache.size })
.select({
key: imageCache.key,
location: imageCache.location,
size: imageCache.size
})
.from(imageCache)
.orderBy(asc(imageCache.lastAccessAt))
.all();
for (const row of rows) {
if (total <= target) break;
tx.delete(imageCache).where(eq(imageCache.key, row.key)).run();
doomed.push(row.key);
doomed.push(row);
total -= row.size;
}
});
if (doomed.length) {
log.info({ removed: doomed.length }, "清理图片缓存");
void removeFiles(doomed);
}
if (!doomed.length) return;
log.info({ removed: doomed.length }, "清理图片缓存");
const keysAt = (location: Location) =>
doomed.filter((row) => row.location === location).map((row) => row.key);
void removeFiles(keysAt("local"));
// 未配置 S3 时删不了桶里的对象,只丢掉索引
const remote = keysAt("s3");
if (remote.length && isS3Enabled()) void removeObjects(remote);
};
let trimScheduled = false;
@@ -166,7 +229,114 @@ const scheduleTrim = () => {
});
};
/** 写入中断、进程崩溃留下的文件:没有索引、超过一小时的删掉 */
/** 本地图片总大小;配置 S3 时即待上传的部分 */
const getLocalBytes = () =>
db
.select({ value: sql<number>`coalesce(sum(${imageCache.size}), 0)` })
.from(imageCache)
.where(eq(imageCache.location, "local"))
.get()?.value ?? 0;
/**
* 上传一张本地图片到 S3,成功后改索引、删本地文件。
* 失败只记录,下次清理时重新排队
*/
const upload = async (key: string) => {
const row = db
.select({
createdAt: imageCache.createdAt,
contentType: imageCache.contentType,
size: imageCache.size
})
.from(imageCache)
.where(and(eq(imageCache.key, key), eq(imageCache.location, "local")))
.get();
if (!row) return;
let body: Buffer;
try {
body = await readFile(filePath(key));
} catch (error) {
log.warn({ key, err: error }, "缓存文件无法读取,丢弃索引");
forget([key]);
return;
}
try {
await putObject(objectKey(key), body, row.contentType);
} catch (error) {
log.warn({ key, err: error }, "图片上传 S3 失败,稍后重试");
return;
}
// 上传期间索引可能被淘汰或被新写入覆盖,只在仍是这一份时改到 S3
const moved = db
.update(imageCache)
.set({ location: "s3" })
.where(
and(
eq(imageCache.key, key),
eq(imageCache.createdAt, row.createdAt),
eq(imageCache.location, "local")
)
)
.returning({ key: imageCache.key })
.get();
if (!moved) {
// 没有索引指向刚上传的对象,删掉;被覆盖的新图下次清理时再上传
await removeObjects([key]);
return;
}
log.debug({ key, size: row.size }, "图片已上传 S3");
// 正在读这个文件的请求不受影响;Windows 下删不掉的文件由孤儿清理兜底
await rm(filePath(key), { force: true }).catch(() => undefined);
};
const pumpUploads = () => {
while (uploads.active < IMAGE_CACHE_UPLOAD_CONCURRENCY) {
const key = uploads.pending.shift();
if (key === undefined) return;
uploads.active += 1;
void upload(key)
.catch((error) => log.warn({ key, err: error }, "图片上传 S3 失败"))
.finally(() => {
uploads.active -= 1;
uploads.queued.delete(key);
pumpUploads();
});
}
};
const queueUpload = (key: string) => {
if (uploads.queued.has(key)) return;
uploads.queued.add(key);
uploads.pending.push(key);
pumpUploads();
};
/**
* 配置 S3 时,本地的图片(刚写入的、上次上传失败的、配置 S3 之前缓存的)都排进上传;
* 未配置时丢掉指向桶的索引,桶里的对象需手动清理
*/
const syncS3 = () => {
if (!isS3Enabled()) {
const dropped = db
.delete(imageCache)
.where(eq(imageCache.location, "s3"))
.returning({ key: imageCache.key })
.all();
if (dropped.length)
log.info({ dropped: dropped.length }, "未配置 S3,丢弃桶内图片的索引");
return;
}
const rows = db
.select({ key: imageCache.key })
.from(imageCache)
.where(eq(imageCache.location, "local"))
.all();
for (const row of rows) queueUpload(row.key);
};
/** 写入中断、进程崩溃留下的文件,以及已上传 S3 但没删掉的文件:没有本地索引、超过一小时的删掉 */
const removeOrphans = async () => {
const dir = getImageCacheDir();
const names = await readdir(dir).catch(() => []);
@@ -174,6 +344,7 @@ const removeOrphans = async () => {
db
.select({ key: imageCache.key })
.from(imageCache)
.where(eq(imageCache.location, "local"))
.all()
.map((row) => fileName(row.key))
);
@@ -190,13 +361,14 @@ const removeOrphans = async () => {
const sweep = async () => {
try {
trimImageCache();
syncS3();
await removeOrphans();
} catch (error) {
log.warn({ err: error }, "清理图片缓存失败");
}
};
/** 服务启动时调用:先清理一次,之后每小时清理一次 */
/** 服务启动时调用:先清理一次(配置 S3 时顺带补传),之后每小时一次 */
export const startImageCache = () => {
void sweep();
setInterval(() => void sweep(), IMAGE_CACHE_SWEEP_INTERVAL_MS).unref();
@@ -220,21 +392,20 @@ const store = async (
if (size !== length) throw new Error(`大小不符:${size}/${length}`);
await rename(tmp, filePath(key));
const now = Date.now();
const values = {
location: "local" as const,
size,
contentType,
createdAt: now,
lastAccessAt: now
};
db.insert(imageCache)
.values({
key,
size,
contentType,
createdAt: now,
lastAccessAt: now
})
.onConflictDoUpdate({
target: imageCache.key,
set: { size, contentType, createdAt: now, lastAccessAt: now }
})
.values({ key, ...values })
.onConflictDoUpdate({ target: imageCache.key, set: values })
.run();
log.debug({ key, size }, "图片已缓存");
scheduleTrim();
if (isS3Enabled()) queueUpload(key);
} catch (error) {
// 客户端中途离开会中断上游请求,写入随之失败,属于正常情况
log.debug({ key, err: error }, "图片未能缓存");
@@ -264,7 +435,10 @@ export const cacheWhileStreaming = (
!contentType.startsWith("image/") ||
!(length > 0) ||
length > Math.min(IMAGE_CACHE_MAX_FILE_BYTES, getImageCacheMaxBytes()) ||
writing.has(key)
writing.has(key) ||
// S3 不可用时待上传的图片会积在本地,到上限就先不缓存新图
(isS3Enabled() &&
getLocalBytes() + length > IMAGE_CACHE_LOCAL_PENDING_MAX_BYTES)
)
return null;
+85
View File
@@ -0,0 +1,85 @@
// S3 存储:配置 S3_* 后图片缓存放到桶里(见 server/image-cache.ts)
// 只用 AWS 官方 SDK 的通用 S3 接口,换 Garage、MinIO、RustFS 或 AWS 只改环境变量
import {
DeleteObjectsCommand,
GetObjectCommand,
NoSuchKey,
PutObjectCommand,
S3Client
} from "@aws-sdk/client-s3";
import { getS3Config } from "@/server/env";
/** DeleteObjects 单次最多删除的对象数(S3 接口限制) */
const DELETE_BATCH = 1000;
// 首次使用时按环境变量创建,之后复用;挂在 globalThis 上,避免各路由打包后各持一份
const S3_KEY = Symbol.for("torbox-admin.s3");
const globals = globalThis as {
[S3_KEY]?: { client: S3Client; bucket: string };
};
const getS3 = () => {
const cached = globals[S3_KEY];
if (cached) return cached;
const { endpoint, region, bucket, accessKeyId, secretAccessKey } =
getS3Config();
const client = new S3Client({
endpoint,
region,
// 自建服务(Garage、MinIO 等)按路径风格寻址:endpoint/bucket/key
forcePathStyle: true,
// SDK 默认给每个请求附加 CRC 校验,Garage、RustFS 与旧版 MinIO 不认,只在接口要求时附加
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
credentials: { accessKeyId, secretAccessKey }
});
return (globals[S3_KEY] = { client, bucket });
};
export const putObject = (key: string, body: Buffer, contentType: string) => {
const { client, bucket } = getS3();
return client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
ContentLength: body.length
})
);
};
/** 读取对象,返回 Web 流;对象不存在时返回 null */
export const getObjectStream = async (key: string) => {
const { client, bucket } = getS3();
try {
const { Body } = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key })
);
return Body?.transformToWebStream() ?? null;
} catch (error) {
if (error instanceof NoSuchKey) return null;
throw error;
}
};
/** 批量删除对象,不存在的对象视为已删除 */
export const deleteObjects = async (keys: string[]) => {
const { client, bucket } = getS3();
for (let i = 0; i < keys.length; i += DELETE_BATCH) {
const { Errors } = await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: keys.slice(i, i + DELETE_BATCH).map((Key) => ({ Key })),
Quiet: true
}
})
);
const [first] = Errors ?? [];
if (first)
throw new Error(
`${Errors?.length} 个对象删除失败:${first.Key} ${first.Code} ${first.Message}`
);
}
};